├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── bitbucket-pipelines.yml ├── deploy.bash ├── moonshot ├── __init__.py ├── _cache.py ├── _tests │ ├── __init__.py │ ├── commission │ │ ├── __init__.py │ │ └── test_commissions.py │ ├── fixtures │ │ ├── __init__.py │ │ └── test_model.keras.h5 │ ├── slippage │ │ ├── __init__.py │ │ └── test_slippage.py │ ├── test_allow_rebalance.py │ ├── test_backtest.py │ ├── test_benchmark.py │ ├── test_cache.py │ ├── test_commissions.py │ ├── test_limit_position_sizes.py │ ├── test_ml.py │ ├── test_orders.py │ ├── test_positions_closed_daily.py │ ├── test_prices.py │ ├── test_save_custom_dataframe.py │ ├── test_slippage.py │ ├── test_trade.py │ ├── test_trade_date_validation.py │ ├── test_weight_allocations.py │ └── utils.py ├── _version.py ├── commission │ ├── __init__.py │ ├── base.py │ ├── fut.py │ ├── fx.py │ └── stk.py ├── exceptions.py ├── mixins │ ├── __init__.py │ └── weight.py ├── py.typed ├── slippage │ ├── __init__.py │ ├── base.py │ ├── borrowfee.py │ └── fixed.py └── strategies │ ├── __init__.py │ ├── base.py │ └── ml.py ├── setup.cfg ├── setup.py └── versioneer.py /.gitignore: -------------------------------------------------------------------------------- 1 | /quantrocket_moonshot.egg-info/ 2 | /.tox/ 3 | __pycache__ 4 | /build/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. 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. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor 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, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. 30 | 31 | Subject to the terms and conditions of this License, each Contributor hereby grants to You 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 the Work and such Derivative Works in Source or Object form. 32 | 33 | 3. Grant of Patent License. 34 | 35 | Subject to the terms and conditions of this License, each Contributor hereby grants to You 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 such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 36 | 37 | 4. Redistribution. 38 | 39 | You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 40 | 41 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 42 | You must cause any modified files to carry prominent notices stating that You changed the files; and 43 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 44 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 45 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 46 | 47 | 5. Submission of Contributions. 48 | 49 | Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 50 | 51 | 6. Trademarks. 52 | 53 | This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 54 | 55 | 7. Disclaimer of Warranty. 56 | 57 | Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its 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. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 58 | 59 | 8. Limitation of Liability. 60 | 61 | In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 62 | 63 | 9. Accepting Warranty or Additional Liability. 64 | 65 | While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 66 | 67 | END OF TERMS AND CONDITIONS 68 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include versioneer.py 2 | include moonshot/_version.py 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Moonshot 2 | 3 | Moonshot is a backtester designed for data scientists, created by and for [QuantRocket](https://www.quantrocket.com). 4 | 5 | ## Key features 6 | 7 | **Pandas-based**: Moonshot is based on Pandas, the centerpiece of the Python data science stack. If you love Pandas you'll love Moonshot. Moonshot can be thought of as a set of conventions for organizing Pandas code for the purpose of running backtests. 8 | 9 | **Lightweight**: Moonshot is simple and lightweight because it relies on the power and flexibility of Pandas and doesn't attempt to re-create functionality that Pandas can already do. No bloated codebase full of countless indicators and models to import and learn. Most of Moonshot's code is contained in a single `Moonshot` class. 10 | 11 | **Fast**: Moonshot is fast because Pandas is fast. No event-driven backtester can match Moonshot's speed. Speed promotes alpha discovery by facilitating rapid experimentation and research iteration. 12 | 13 | **Multi-asset class, multi-time frame**: Moonshot supports end-of-day and intraday strategies using equities, futures, and FX. 14 | 15 | **Machine learning support**: Moonshot [supports machine learning and deep learning strategies](#machine-learning-example) using scikit-learn or Keras. 16 | 17 | **Live trading**: Live trading with Moonshot can be thought of as running a backtest on up-to-date historical data and generating a batch of orders based on the latest signals produced by the backtest. 18 | 19 | **No black boxes, no magic**: Moonshot provides many conveniences to make backtesting easier, but it eschews hidden behaviors and complex, under-the-hood simulation rules that are hard to understand or audit. What you see is what you get. 20 | 21 | ## Example 22 | 23 | A basic Moonshot strategy is shown below: 24 | 25 | ```python 26 | from moonshot import Moonshot 27 | 28 | class DualMovingAverageStrategy(Moonshot): 29 | 30 | CODE = "dma-tech" 31 | DB = "tech-giants-1d" 32 | LMAVG_WINDOW = 300 33 | SMAVG_WINDOW = 100 34 | 35 | def prices_to_signals(self, prices): 36 | closes = prices.loc["Close"] 37 | 38 | # Compute long and short moving averages 39 | lmavgs = closes.rolling(self.LMAVG_WINDOW).mean() 40 | smavgs = closes.rolling(self.SMAVG_WINDOW).mean() 41 | 42 | # Go long when short moving average is above long moving average 43 | signals = smavgs > lmavgs 44 | 45 | return signals.astype(int) 46 | 47 | def signals_to_target_weights(self, signals, prices): 48 | # spread our capital equally among our trades on any given day 49 | daily_signal_counts = signals.abs().sum(axis=1) 50 | weights = signals.div(daily_signal_counts, axis=0).fillna(0) 51 | return weights 52 | 53 | def target_weights_to_positions(self, weights, prices): 54 | # we'll enter in the period after the signal 55 | positions = weights.shift() 56 | return positions 57 | 58 | def positions_to_gross_returns(self, positions, prices): 59 | # Our return is the security's close-to-close return, multiplied by 60 | # the size of our position 61 | closes = prices.loc["Close"] 62 | gross_returns = closes.pct_change() * positions.shift() 63 | return gross_returns 64 | ``` 65 | 66 | See the [QuantRocket docs](https://www.quantrocket.com/docs/#moonshot-backtesting) for a fuller discussion. 67 | 68 | ## Machine Learning Example 69 | 70 | Moonshot supports machine learning strategies using [scikit-learn](https://scikit-learn.org) or [Keras](https://keras.io/). The model must be trained outside of Moonshot, either using QuantRocket or by training the model manually and persisting it to disk: 71 | 72 | ```python 73 | from sklearn.tree import DecisionTreeClassifier 74 | import pickle 75 | 76 | model = DecisionTreeClassifier() 77 | X = np.array([[1,1],[0,0]]) 78 | Y = np.array([1,0]) 79 | model.fit(X, Y) 80 | 81 | with open("my_ml_model.pkl", "wb") as f: 82 | pickle.dump(model, f) 83 | ``` 84 | 85 | A basic machine learning strategy is shown below: 86 | 87 | ```python 88 | from moonshot import MoonshotML 89 | 90 | class DemoMLStrategy(MoonshotML): 91 | 92 | CODE = "demo-ml" 93 | DB = "demo-stk-1d" 94 | MODEL = "my_ml_model.pkl" 95 | 96 | def prices_to_features(self, prices): 97 | closes = prices.loc["Close"] 98 | # create a dict of DataFrame features 99 | features = {} 100 | features["returns_1d"]= closes.pct_change() 101 | features["returns_2d"] = (closes - closes.shift(2)) / closes.shift(2) 102 | # targets is used by QuantRocket for training model, can be None if using 103 | # an already trained model 104 | targets = closes.pct_change().shift(-1) 105 | return features, targets 106 | 107 | def predictions_to_signals(self, predictions, prices): 108 | signals = predictions > 0 109 | return signals.astype(int) 110 | ``` 111 | 112 | See the [QuantRocket docs](https://www.quantrocket.com/docs/#ml) for a fuller discussion. 113 | 114 | ## FAQ 115 | 116 | ### Can I use Moonshot without QuantRocket? 117 | 118 | Moonshot depends on QuantRocket for querying historical data in backtesting and for live trading. In the future we hope to add support for running Moonshot on a CSV of data to allow backtesting outside of QuantRocket. 119 | 120 | ## See also 121 | 122 | [Moonchart](https://github.com/quantrocket-llc/moonchart) is a companion library for creating performance tear sheets from a Moonshot backtest. 123 | 124 | ## License 125 | 126 | Moonshot is distributed under the Apache 2.0 License. See the LICENSE file in the release for details. 127 | -------------------------------------------------------------------------------- /bitbucket-pipelines.yml: -------------------------------------------------------------------------------- 1 | image: python:3.11 2 | 3 | pipelines: 4 | tags: 5 | '*': 6 | - step: 7 | script: 8 | - pip install -U build twine 9 | - ./deploy.bash 10 | -------------------------------------------------------------------------------- /deploy.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # TWINE_USERNAME - (Requried) Username for the publisher's account on PyPI 4 | # TWINE_PASSWORD - (Required, Secret) API Token for the publisher's account on PyPI 5 | 6 | cat <> ~/.pypirc 7 | [distutils] 8 | index-servers=pypi 9 | 10 | [pypi] 11 | username=$TWINE_USERNAME 12 | password=$TWINE_PASSWORD 13 | EOF 14 | 15 | # Deploy to pip 16 | python -m build 17 | twine upload dist/* --verbose 18 | -------------------------------------------------------------------------------- /moonshot/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | """ 15 | Vectorized backtesting and trading engine. 16 | 17 | Classes 18 | ------- 19 | Moonshot 20 | Base class for Moonshot strategies. 21 | 22 | MoonshotML 23 | Base class for Moonshot machine learning strategies. 24 | 25 | Modules 26 | ------- 27 | commission 28 | Moonshot commission classes. 29 | 30 | slippage 31 | Moonshot slippage classes. 32 | """ 33 | from ._version import get_versions 34 | __version__ = get_versions()['version'] 35 | del get_versions 36 | 37 | from .strategies import Moonshot, MoonshotML 38 | from . import slippage 39 | from . import commission 40 | 41 | __all__ = [ 42 | 'Moonshot', 43 | 'MoonshotML', 44 | 'slippage', 45 | 'commission' 46 | ] 47 | -------------------------------------------------------------------------------- /moonshot/_cache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import hashlib 17 | import pickle 18 | import time 19 | import six 20 | import inspect 21 | import itertools 22 | import pandas as pd 23 | from filelock import FileLock 24 | from quantrocket.db import list_databases 25 | 26 | TMP_DIR = os.environ.get("MOONSHOT_CACHE_DIR", "/tmp") 27 | 28 | class Cache: 29 | """ 30 | Pickle-based cache for caching arbitrary objects (typically DataFrames) 31 | based on a key which can also be an arbitrary object. 32 | 33 | Examples 34 | -------- 35 | Set and get a DataFrame from the cache based on the prices index and columns, 36 | in backtests only, and don't use the cache if the file containing the strategy 37 | code was modified more recently than the DataFrame was cached. 38 | 39 | >>> from moonshot._cache import Cache 40 | >>> 41 | >>> class MyStrategy(Moonshot): 42 | >>> 43 | >>> def prices_to_signals(self, prices): 44 | >>> 45 | >>> my_dataframe = None 46 | >>> 47 | >>> if self.is_backtest: 48 | >>> # try to load from cache 49 | >>> cache_key = [prices.index.tolist(), prices.columns.tolist()] 50 | >>> my_dataframe = Cache.get(cache_key, prefix="my_df", unless_file_modified=self) 51 | >>> 52 | >>> if my_dataframe is None: 53 | >>> # calculate dataframe here 54 | >>> my_dataframe = expensive_calculations() 55 | >>> if self.is_backtest: 56 | >>> Cache.set(cache_key, my_dataframe, prefix="my_df") 57 | """ 58 | 59 | @classmethod 60 | def _get_filepath(cls, key_obj, prefix=None): 61 | """ 62 | Returns a filepath to use for caching a pickle. The filename contains 63 | a hex digest of the key_obj, ensuring that the cache won't be used if 64 | the key_obj changes. 65 | """ 66 | digest = hashlib.sha224(pickle.dumps(key_obj)).hexdigest() 67 | filepath = "{tmpdir}/moonshot_{prefix}_{digest}.pkl".format( 68 | tmpdir=TMP_DIR, prefix=prefix, digest=digest) 69 | return filepath 70 | 71 | @classmethod 72 | def get(cls, key_obj, prefix=None, unless_file_modified=None, unless_dbs_modified=None): 73 | """ 74 | Returns an object from cache, or None if it is not available or 75 | expired. 76 | 77 | Parameters 78 | ---------- 79 | key_obj : obj, required 80 | the object used as the cache key (a hash of the object 81 | is used, therefore the object must be identical to the 82 | original object but need not be the original object) 83 | 84 | prefix : str, optional 85 | the prefix that was used the cache key, if any 86 | 87 | unless_file_modified : str or class or class instance, optional 88 | don't return cached object if this file (or the file this 89 | class or class instance is defined in) was modified after 90 | the object was cached 91 | 92 | unless_dbs_modified : dict, optional 93 | don't return cached object if any of these dbs were modified 94 | after the object was cached. Pass a dict of kwargs to pass 95 | to list_databases, for example: 96 | {"services":["history"], "codes":["my-db"]} 97 | 98 | Returns 99 | ------- 100 | obj or None 101 | the cached object 102 | 103 | Examples 104 | -------- 105 | See class docstring for typical usage. 106 | """ 107 | 108 | filepath = cls._get_filepath(key_obj, prefix=prefix) 109 | if not os.path.exists(filepath): 110 | return None 111 | 112 | cache_last_modified = os.path.getmtime(filepath) 113 | 114 | if unless_file_modified is not None: 115 | 116 | if not isinstance(unless_file_modified, six.string_types): 117 | 118 | if hasattr(unless_file_modified, "__module__"): 119 | unless_file_modified = inspect.getmodule(unless_file_modified) 120 | elif hasattr(unless_file_modified, "__class__"): 121 | unless_file_modified = unless_file_modified.__class__ 122 | 123 | unless_file_modified = inspect.getfile(unless_file_modified) 124 | 125 | watch_file_last_modified = os.path.getmtime(unless_file_modified) 126 | 127 | if watch_file_last_modified > cache_last_modified: 128 | return None 129 | 130 | if unless_dbs_modified: 131 | unless_dbs_modified["detail"] = True 132 | databases = list_databases(**unless_dbs_modified) 133 | databases = pd.DataFrame.from_records( 134 | itertools.chain(databases["sqlite"], databases["postgres"])) 135 | # databases might be empty if testing with a real-time aggregate 136 | # database because list_databases doesn't report on aggregate 137 | # databases, only tick databases. Ideally we should translate the 138 | # aggregate code to the corresponding tick db code and pass that 139 | # to list_databases, but that is not implemented. 140 | if not databases.empty: 141 | db_last_modified = databases.last_modified.dropna().max() 142 | if not pd.isnull(db_last_modified): 143 | db_last_modified = time.mktime(pd.Timestamp(db_last_modified).timetuple()) 144 | if db_last_modified > cache_last_modified: 145 | return None 146 | 147 | lock = FileLock(filepath + ".lock") 148 | with lock.acquire(timeout=10): 149 | with open(filepath, "rb") as f: 150 | obj = pickle.load(f) 151 | 152 | return obj 153 | 154 | @classmethod 155 | def set(cls, key_obj, obj_to_cache, prefix=None): 156 | """ 157 | Caches an arbitrary object using pickle. 158 | 159 | Parameters 160 | ---------- 161 | obj_to_cache : object, required 162 | an arbitrary object to cache using pickle 163 | 164 | key_obj : object, required 165 | an arbitrary object to use as the cache key (a hash of the object 166 | will be used as the key) 167 | 168 | prefix : str, optional 169 | a prefix to use for the cache key (in case the key_obj is used for 170 | caching multiple objects) 171 | 172 | Returns 173 | ------- 174 | None 175 | 176 | Examples 177 | -------- 178 | See class docstring for typical usage. 179 | """ 180 | filepath = cls._get_filepath(key_obj, prefix=prefix) 181 | lock = FileLock(filepath + ".lock") 182 | with lock.acquire(timeout=10): 183 | with open(filepath, "wb") as f: 184 | pickle.dump(obj_to_cache, f) 185 | -------------------------------------------------------------------------------- /moonshot/_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantrocket-llc/moonshot/2c0fef496ff94f275c85da1da49154a6c42fcfb2/moonshot/_tests/__init__.py -------------------------------------------------------------------------------- /moonshot/_tests/commission/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantrocket-llc/moonshot/2c0fef496ff94f275c85da1da49154a6c42fcfb2/moonshot/_tests/commission/__init__.py -------------------------------------------------------------------------------- /moonshot/_tests/commission/test_commissions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # To run: python3 -m unittest discover -s _tests/ -p test_*.py -t . -v 16 | 17 | import unittest 18 | import pandas as pd 19 | from moonshot.commission import ( 20 | FuturesCommission, PerShareCommission, NoCommission) 21 | from moonshot.commission.fx import SpotFXCommission 22 | 23 | class TestFuturesCommission(FuturesCommission): 24 | BROKER_COMMISSION_PER_CONTRACT = 0.85 25 | EXCHANGE_FEE_PER_CONTRACT = 1.20 26 | CARRYING_FEE_PER_CONTRACT = 0 27 | 28 | class FuturesCommissionTestCase(unittest.TestCase): 29 | 30 | def test_commissions(self): 31 | 32 | turnover = pd.DataFrame( 33 | {"ES201609": [0.1, 0.2], 34 | "NQ201609": [0.18, 0.32]}, 35 | ) 36 | contract_values = pd.DataFrame( 37 | {"ES201609": [70000, 70000], 38 | "NQ201609": [105000, 105000]}, 39 | ) 40 | commissions = TestFuturesCommission.get_commissions( 41 | contract_values, 42 | turnover) 43 | 44 | # expected commission 45 | # ES 0: $2.05 / $70000 * .1 = 0.000002929 46 | # ES 1: $2.05 / $70000 * .2 = 0.000005857 47 | # NQ 0: $2.05 / $105000 * .18 = 0.000003514 48 | # NQ 1: $2.05 / $105000 * .32 = 0.000006248 49 | self.assertEqual(round(commissions.loc[0, "ES201609"], 9), 0.000002929) 50 | self.assertEqual(round(commissions.loc[1, "ES201609"], 9), 0.000005857) 51 | self.assertEqual(round(commissions.loc[0, "NQ201609"], 9), 0.000003514) 52 | self.assertEqual(round(commissions.loc[1, "NQ201609"], 9), 0.000006248) 53 | 54 | class TestStockCommission(PerShareCommission): 55 | BROKER_COMMISSION_PER_SHARE = 0.0035 # broker commission per share 56 | EXCHANGE_FEE_PER_SHARE = 0.0003 57 | MAKER_FEE_PER_SHARE = -0.002 # exchange rebate 58 | TAKER_FEE_PER_SHARE = 0.00118 # exchange fee 59 | MAKER_RATIO = 0.4 60 | MIN_COMMISSION = 0.35 61 | COMMISSION_PERCENTAGE_FEE_RATE = 0.056 62 | PERCENTAGE_FEE_RATE = 0.00002 63 | 64 | class PerShareCommissionTestCase(unittest.TestCase): 65 | 66 | def test_min_commission(self): 67 | 68 | # only buy 50 shares 69 | turnover_pct = 50*250/220000 70 | turnover = pd.DataFrame( 71 | {"LVS": [turnover_pct]}, 72 | ) 73 | contract_values = pd.DataFrame( 74 | {"LVS": [250.00]}, 75 | ) 76 | nlvs = pd.DataFrame( 77 | {"LVS": [220000]}, 78 | ) 79 | commission = TestStockCommission.get_commissions( 80 | contract_values, 81 | turnover, 82 | nlvs=nlvs) 83 | 84 | # expected commission 85 | # exchange fees = 0.0003 + (0.4 * -0.002) + (0.6 * 0.00118) = 0.000208 * 50 shares = 0.0104 86 | # percentage fees = 0.00002 * 50 * 250 = 0.25 87 | # commission based fees = 0.00056 * 0.35 = 0.0196 88 | # 0.35 broker min commission + 0.0104 + 0.25 + 0.0196 = 0.63 / 220000 = 0.000002864 89 | self.assertEqual(round(commission.loc[0, "LVS"], 9), 0.000002864) 90 | 91 | def test_maker_commissions(self): 92 | 93 | class TestMakerCommission(TestStockCommission): 94 | MAKER_RATIO = 1 95 | 96 | turnover = pd.DataFrame( 97 | {"AAPL": [0.1]}, 98 | ) 99 | contract_values = pd.DataFrame( 100 | {"AAPL": [90]}, 101 | ) 102 | nlvs = pd.DataFrame( 103 | {"AAPL": [500000]}, 104 | ) 105 | 106 | commissions = TestMakerCommission.get_commissions( 107 | contract_values, 108 | turnover, 109 | nlvs=nlvs) 110 | 111 | # expected commission 112 | # (0.0035 - 0.002 + 0.0003 + (0.0035 * 0.056)) * 500000 * 0.1 / 90 = 1.108888889 + (500000 * 0.1 * 0.00002) = $2.108888 / $500000 = 0.000004218 113 | self.assertEqual(round(commissions.loc[0, "AAPL"], 9), 0.000004218) 114 | 115 | def test_taker_commissions(self): 116 | 117 | class TestTakerCommission(TestStockCommission): 118 | MAKER_RATIO = 0 119 | 120 | turnover = pd.DataFrame( 121 | {"AAPL": [0.1]}, 122 | ) 123 | contract_values = pd.DataFrame( 124 | {"AAPL": [90]}, 125 | ) 126 | nlvs = pd.DataFrame( 127 | {"AAPL": [500000]}, 128 | ) 129 | 130 | commissions = TestTakerCommission.get_commissions( 131 | contract_values, 132 | turnover, 133 | nlvs=nlvs) 134 | 135 | # expected commission 136 | # (0.0035 + 0.00118 + 0.0003 + (0.0035 * 0.056)) * 500000 * 0.1 / 90 = 2.87555 + (500000 * 0.1 * 0.00002) = $3.8755 / $500000 = 0.000007751 137 | self.assertEqual(round(commissions.loc[0, "AAPL"], 9), 0.000007751) 138 | 139 | def test_maker_taker_commissions(self): 140 | 141 | class TestMakerTakerCommission(TestStockCommission): 142 | MAKER_RATIO = 0.60 143 | 144 | turnover = pd.DataFrame( 145 | {"AAPL": [0.1]}, 146 | ) 147 | contract_values = pd.DataFrame( 148 | {"AAPL": [90]}, 149 | ) 150 | nlvs = pd.DataFrame( 151 | {"AAPL": [500000]}, 152 | ) 153 | 154 | commissions = TestMakerTakerCommission.get_commissions( 155 | contract_values, 156 | turnover, 157 | nlvs=nlvs) 158 | 159 | # expected commission 160 | # (0.0035 + (0.00118*0.4) + (-0.002 * 0.6) + 0.0003 + (0.0035 * 0.056)) * 500000 * 0.1 / 90 = $1.8155 + (500000 * 0.1 * 0.00002) = $2.8155 / $500000 = 0.000005631 161 | self.assertEqual(round(commissions.loc[0, "AAPL"], 9), 0.000005631) 162 | 163 | class NoCommissionTestCase(unittest.TestCase): 164 | 165 | def test_no_commissions(self): 166 | turnover = pd.DataFrame( 167 | {"WYNN": [0.15]}, 168 | ) 169 | contract_values = pd.DataFrame( 170 | {"WYNN": [79.56]}, 171 | ) 172 | commissions = NoCommission.get_commissions( 173 | contract_values, 174 | turnover) 175 | 176 | self.assertEqual(commissions.loc[0, "WYNN"], 0) 177 | 178 | class FXCommissionTestCase(unittest.TestCase): 179 | 180 | def test_commissions_cadhkd(self): 181 | 182 | turnover = pd.DataFrame( 183 | {"CAD.HKD": [0.25]}) 184 | contract_values = None # not used for fixed rate 185 | nlvs = pd.DataFrame( 186 | {"CAD.HKD": [700000]}) 187 | commissions = SpotFXCommission.get_commissions( 188 | contract_values, 189 | turnover, 190 | nlvs=nlvs) 191 | 192 | # expected commission 193 | # .2 bps x 0.25 x 700K USD = $3.50 / 700K USD = 0.000005 194 | commissions = commissions["CAD.HKD"] 195 | self.assertEqual(commissions.iloc[0], 0.000005) 196 | 197 | # Spot fx min commissions aren't currently supported 198 | @unittest.expectedFailure 199 | def test_min_commissions_cadhkd(self): 200 | 201 | turnover = pd.DataFrame( 202 | {"CAD.HKD": [0.01, 0.05]}) 203 | contract_values = None # not used for fixed rate 204 | nlvs = pd.DataFrame( 205 | {"CAD.HKD": [700000,700000]}) 206 | commissions = SpotFXCommission.get_commissions( 207 | contract_values, 208 | turnover, 209 | nlvs=nlvs) 210 | 211 | # expected commission 212 | # 0: .2 bps x 0.01 x 700K USD = $0.14 = $2 min / 700K USD = 0.000002857 213 | # 1: .2 bps x 0.05 x 700K USD = $0.70 = $2 min / 700K USD = 0.000002857 214 | commissions = commissions["CAD.HKD"] 215 | self.assertEqual(round(commissions.iloc[0], 9), 0.000002857) 216 | self.assertEqual(round(commissions.iloc[1], 9), 0.000002857) 217 | -------------------------------------------------------------------------------- /moonshot/_tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantrocket-llc/moonshot/2c0fef496ff94f275c85da1da49154a6c42fcfb2/moonshot/_tests/fixtures/__init__.py -------------------------------------------------------------------------------- /moonshot/_tests/fixtures/test_model.keras.h5: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantrocket-llc/moonshot/2c0fef496ff94f275c85da1da49154a6c42fcfb2/moonshot/_tests/fixtures/test_model.keras.h5 -------------------------------------------------------------------------------- /moonshot/_tests/slippage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantrocket-llc/moonshot/2c0fef496ff94f275c85da1da49154a6c42fcfb2/moonshot/_tests/slippage/__init__.py -------------------------------------------------------------------------------- /moonshot/_tests/slippage/test_slippage.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # To run: python3 -m unittest discover -s _tests/ -p test_*.py -t . -v 16 | 17 | import unittest 18 | from unittest.mock import patch 19 | import pandas as pd 20 | from moonshot.slippage import FixedSlippage, IBKRBorrowFees 21 | from moonshot import Moonshot 22 | 23 | class FixedSlippageForTest(FixedSlippage): 24 | ONE_WAY_SLIPPAGE = 0.0010 25 | 26 | class FixedSlippageTestCase(unittest.TestCase): 27 | 28 | def test_fixed_slippage(self): 29 | 30 | turnover = pd.DataFrame( 31 | {"ES201609": [0.1, 0.2], 32 | "NQ201609": [0.17, 0.32]}, 33 | ) 34 | slippage = FixedSlippageForTest().get_slippage(turnover) 35 | 36 | self.assertListEqual( 37 | slippage.to_dict(orient="records"), 38 | [{'ES201609': 0.0001, 'NQ201609': 0.00017}, 39 | {'ES201609': 0.0002, 'NQ201609': 0.00032}] 40 | ) 41 | 42 | class IBKRBorrowFeesSlippageTestCase(unittest.TestCase): 43 | 44 | @patch("moonshot.slippage.borrowfee.get_ibkr_borrow_fees_reindexed_like") 45 | def test_borrow_fees_slippage(self, mock_get_ibkr_borrow_fees_reindexed_like): 46 | 47 | positions = pd.DataFrame( 48 | {"FI12345": [0.1, 0, -0.2, -0.2, -0.1, 0.5, -0.25], 49 | "FI23456": [-0.17, 0.32, 0.23, 0, -0.4, -0.4, -0.4]}, 50 | index=pd.DatetimeIndex(["2018-06-01", "2018-06-02", "2018-06-03", 51 | "2018-06-04", "2018-06-05", "2018-06-08", 52 | "2018-06-09"])) 53 | 54 | borrow_fee_rates = pd.DataFrame( 55 | {"FI12345": [1.75, 1.75, 1.75, 1.85, 1.85, 1.85, 1.2], 56 | "FI23456": [8.0, 8.0, 8.23, 8.5, 0.25, 0.25, 0.25]}, 57 | index=pd.DatetimeIndex(["2018-06-01", "2018-06-02", "2018-06-03", 58 | "2018-06-04", "2018-06-05", "2018-06-08", 59 | "2018-06-09"])) 60 | 61 | mock_get_ibkr_borrow_fees_reindexed_like.return_value = borrow_fee_rates 62 | 63 | turnover = prices = None 64 | fees = IBKRBorrowFees().get_slippage(turnover, positions, prices) 65 | 66 | mock_get_ibkr_borrow_fees_reindexed_like.assert_called_with(positions) 67 | 68 | fees.index.name = "Date" 69 | fees.index = fees.index.strftime("%Y-%m-%d") 70 | fees = fees.to_dict(orient="dict") 71 | 72 | self.assertAlmostEqual(fees["FI12345"]["2018-06-01"], 0) 73 | self.assertAlmostEqual(fees["FI12345"]["2018-06-02"], 0) 74 | self.assertAlmostEqual(fees["FI12345"]["2018-06-03"], 0.000009917, 9) 75 | self.assertAlmostEqual(fees["FI12345"]["2018-06-04"], 0.000010483, 9) 76 | self.assertAlmostEqual(fees["FI12345"]["2018-06-05"], 0.0000052417, 9) 77 | self.assertAlmostEqual(fees["FI12345"]["2018-06-08"], 0) 78 | self.assertAlmostEqual(fees["FI12345"]["2018-06-09"], 0.0000085, 9) 79 | 80 | self.assertAlmostEqual(fees["FI23456"]["2018-06-01"], 0.00003853, 8) 81 | self.assertAlmostEqual(fees["FI23456"]["2018-06-02"], 0) 82 | self.assertAlmostEqual(fees["FI23456"]["2018-06-03"], 0) 83 | self.assertAlmostEqual(fees["FI23456"]["2018-06-04"], 0) 84 | self.assertAlmostEqual(fees["FI23456"]["2018-06-05"], 0.000002833, 9) 85 | self.assertAlmostEqual(fees["FI23456"]["2018-06-08"], 0.0000085) # 3x b/c of weekend 86 | self.assertAlmostEqual(fees["FI23456"]["2018-06-09"], 0.000002833, 9) 87 | -------------------------------------------------------------------------------- /moonshot/_tests/test_allow_rebalance.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # To run: python3 -m unittest discover -s _tests/ -p test_*.py -t . -v 16 | 17 | import unittest 18 | from unittest.mock import patch 19 | import pandas as pd 20 | from moonshot import Moonshot 21 | from moonshot.exceptions import MoonshotParameterError 22 | 23 | class AllowRebalanceTestCase(unittest.TestCase): 24 | """ 25 | Test cases for the ALLOW_REBALANCE param. 26 | """ 27 | 28 | def test_allow_rebalance(self): 29 | """ 30 | Tests that small rebalancing orders are allowed when there are 31 | existing positions and ALLOW_REBALANCE = True (the default). 32 | """ 33 | 34 | class BuyBelow10(Moonshot): 35 | """ 36 | A basic test strategy that buys below 10. 37 | """ 38 | CODE = "buy-below-10" 39 | 40 | def prices_to_signals(self, prices): 41 | signals = prices.loc["Close"] < 10 42 | return signals.astype(int) 43 | 44 | def signals_to_target_weights(self, signals, prices): 45 | return self.allocate_fixed_weights(signals, 0.5) 46 | 47 | def mock_get_prices(*args, **kwargs): 48 | 49 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 50 | fields = ["Close"] 51 | idx = pd.MultiIndex.from_product([fields, dt_idx], names=["Field", "Date"]) 52 | 53 | prices = pd.DataFrame( 54 | { 55 | "FI12345": [ 56 | # Close 57 | 9, 58 | 11, 59 | 9.50 60 | ], 61 | }, 62 | index=idx 63 | ) 64 | return prices 65 | 66 | def mock_download_master_file(f, *args, **kwargs): 67 | 68 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 69 | securities = pd.DataFrame( 70 | { 71 | "FI12345": [ 72 | "America/New_York", 73 | "STK", 74 | "USD", 75 | None, 76 | None 77 | ], 78 | }, 79 | index=master_fields 80 | ) 81 | securities.columns.name = "Sid" 82 | securities.T.to_csv(f, index=True, header=True) 83 | f.seek(0) 84 | 85 | def mock_download_account_balances(f, **kwargs): 86 | balances = pd.DataFrame(dict(Account=["U123", "DU234"], 87 | NetLiquidation=[85000, 450000], 88 | Currency=["USD", "USD"])) 89 | balances.to_csv(f, index=False) 90 | f.seek(0) 91 | 92 | def mock_download_exchange_rates(f, **kwargs): 93 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 94 | QuoteCurrency=["USD"], 95 | Rate=[1.0])) 96 | rates.to_csv(f, index=False) 97 | f.seek(0) 98 | 99 | def mock_list_positions(**kwargs): 100 | positions = [ 101 | { 102 | "Account": "U123", 103 | "OrderRef": "buy-below-10", 104 | "Sid": "FI12345", 105 | "Quantity": 2240 106 | }, 107 | { 108 | "Account": "DU234", 109 | "OrderRef": "buy-below-10", 110 | "Sid": "FI12345", 111 | "Quantity": 7100 112 | }, 113 | ] 114 | return positions 115 | 116 | def mock_download_order_statuses(f, **kwargs): 117 | pass 118 | 119 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 120 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 121 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 122 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 123 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 124 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 125 | 126 | orders = BuyBelow10().trade( 127 | {"U123": 0.5, 128 | "DU234": 0.3, 129 | }) 130 | 131 | self.assertSetEqual( 132 | set(orders.columns), 133 | {'Sid', 134 | 'Account', 135 | 'Action', 136 | 'OrderRef', 137 | 'TotalQuantity', 138 | 'OrderType', 139 | 'Tif'} 140 | ) 141 | self.assertListEqual( 142 | orders.to_dict(orient="records"), 143 | [ 144 | { 145 | 'Sid': "FI12345", 146 | 'Account': 'U123', 147 | 'Action': 'SELL', 148 | 'OrderRef': 'buy-below-10', 149 | # 0.5 allocation * 0.5 weight * 85K / 9.50 - 2240 150 | 'TotalQuantity': 3, 151 | 'OrderType': 'MKT', 152 | 'Tif': 'DAY' 153 | }, 154 | { 155 | 'Sid': "FI12345", 156 | 'Account': 'DU234', 157 | 'Action': 'BUY', 158 | 'OrderRef': 'buy-below-10', 159 | # 0.3 allocation * 0.5 weight * 450K / 9.50 - 7100 160 | 'TotalQuantity': 5, 161 | 'OrderType': 'MKT', 162 | 'Tif': 'DAY' 163 | } 164 | ] 165 | ) 166 | 167 | def test_disable_rebalance(self): 168 | """ 169 | Tests that rebalancing orders are not allowed when there are existing 170 | positions and ALLOW_REBALANCE = False. However, closing positions and 171 | switching sides is allowed. 172 | """ 173 | 174 | class BuyBelow10(Moonshot): 175 | """ 176 | A basic test strategy that buys below 10. 177 | """ 178 | CODE = "buy-below-10" 179 | ALLOW_REBALANCE = False 180 | 181 | def prices_to_signals(self, prices): 182 | signals = prices.loc["Close"] < 10 183 | return signals.astype(int) 184 | 185 | def signals_to_target_weights(self, signals, prices): 186 | return self.allocate_fixed_weights(signals, 0.5) 187 | 188 | def mock_get_prices(*args, **kwargs): 189 | 190 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 191 | fields = ["Close"] 192 | idx = pd.MultiIndex.from_product([fields, dt_idx], names=["Field", "Date"]) 193 | 194 | prices = pd.DataFrame( 195 | { 196 | "FI12345": [ 197 | # Close 198 | 9, 199 | 11, 200 | 9.50 201 | ], 202 | "FI23456": [ 203 | # Close 204 | 8.9, 205 | 12, 206 | 10.50 207 | ], 208 | }, 209 | index=idx 210 | ) 211 | 212 | return prices 213 | 214 | def mock_download_master_file(f, *args, **kwargs): 215 | 216 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 217 | securities = pd.DataFrame( 218 | { 219 | "FI12345": [ 220 | "America/New_York", 221 | "STK", 222 | "USD", 223 | None, 224 | None 225 | ], 226 | "FI23456": [ 227 | "America/New_York", 228 | "STK", 229 | "USD", 230 | None, 231 | None 232 | ], 233 | }, 234 | index=master_fields 235 | ) 236 | securities.columns.name = "Sid" 237 | securities.T.to_csv(f, index=True, header=True) 238 | f.seek(0) 239 | 240 | def mock_download_account_balances(f, **kwargs): 241 | balances = pd.DataFrame(dict(Account=["U123", "DU234"], 242 | NetLiquidation=[85000, 450000], 243 | Currency=["USD", "USD"])) 244 | balances.to_csv(f, index=False) 245 | f.seek(0) 246 | 247 | def mock_download_exchange_rates(f, **kwargs): 248 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 249 | QuoteCurrency=["USD"], 250 | Rate=[1.0])) 251 | rates.to_csv(f, index=False) 252 | f.seek(0) 253 | 254 | def mock_list_positions(**kwargs): 255 | positions = [ 256 | { 257 | # this position won't be rebalanced 258 | "Account": "U123", 259 | "OrderRef": "buy-below-10", 260 | "Sid": "FI12345", 261 | "Quantity": 200 262 | }, 263 | { 264 | # this position will switch sides 265 | "Account": "DU234", 266 | "OrderRef": "buy-below-10", 267 | "Sid": "FI12345", 268 | "Quantity": -4 269 | }, 270 | { 271 | # this position will be closed 272 | "Account": "DU234", 273 | "OrderRef": "buy-below-10", 274 | "Sid": "FI23456", 275 | "Quantity": -7 276 | }, 277 | 278 | ] 279 | return positions 280 | 281 | def mock_download_order_statuses(f, **kwargs): 282 | pass 283 | 284 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 285 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 286 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 287 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 288 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 289 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 290 | 291 | orders = BuyBelow10().trade( 292 | {"U123": 0.5, 293 | "DU234": 0.3, 294 | }) 295 | 296 | self.assertSetEqual( 297 | set(orders.columns), 298 | {'Sid', 299 | 'Account', 300 | 'Action', 301 | 'OrderRef', 302 | 'TotalQuantity', 303 | 'OrderType', 304 | 'Tif'} 305 | ) 306 | self.assertListEqual( 307 | orders.to_dict(orient="records"), 308 | [ 309 | { 310 | 'Sid': "FI12345", 311 | 'Account': 'DU234', 312 | 'Action': 'BUY', 313 | 'OrderRef': 'buy-below-10', 314 | # 0.3 allocation * 0.5 weight * 450K / 9.50 - (-4) 315 | 'TotalQuantity': 7109.0, 316 | 'OrderType': 'MKT', 317 | 'Tif': 'DAY' 318 | }, 319 | { 320 | 'Sid': "FI23456", 321 | 'Account': 'DU234', 322 | 'Action': 'BUY', 323 | 'OrderRef': 'buy-below-10', 324 | # 0 - (-7) 325 | 'TotalQuantity': 7.0, 326 | 'OrderType': 'MKT', 327 | 'Tif': 'DAY' 328 | } 329 | ] 330 | ) 331 | 332 | def test_min_rebalance(self): 333 | """ 334 | Tests that rebalancing orders are only allowed when above the 335 | ALLOW_REBALANCE threshold, when there are existing positions and 336 | ALLOW_REBALANCE is a float. 337 | """ 338 | 339 | class BuyBelow10(Moonshot): 340 | """ 341 | A basic test strategy that buys below 10. 342 | """ 343 | CODE = "buy-below-10" 344 | ALLOW_REBALANCE = 0.25 345 | 346 | def prices_to_signals(self, prices): 347 | signals = prices.loc["Close"] < 10 348 | return signals.astype(int) 349 | 350 | def signals_to_target_weights(self, signals, prices): 351 | return self.allocate_fixed_weights(signals, 0.5) 352 | 353 | def mock_get_prices(*args, **kwargs): 354 | 355 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 356 | fields = ["Close"] 357 | idx = pd.MultiIndex.from_product([fields, dt_idx], names=["Field", "Date"]) 358 | 359 | prices = pd.DataFrame( 360 | { 361 | "FI12345": [ 362 | # Close 363 | 9, 364 | 11, 365 | 9.50 366 | ], 367 | "FI23456": [ 368 | # Close 369 | 8.9, 370 | 12, 371 | 10.50 372 | ], 373 | }, 374 | index=idx 375 | ) 376 | 377 | return prices 378 | 379 | def mock_download_master_file(f, *args, **kwargs): 380 | 381 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 382 | securities = pd.DataFrame( 383 | { 384 | "FI12345": [ 385 | "America/New_York", 386 | "STK", 387 | "USD", 388 | None, 389 | None 390 | ], 391 | "FI23456": [ 392 | "America/New_York", 393 | "STK", 394 | "USD", 395 | None, 396 | None 397 | ], 398 | }, 399 | index=master_fields 400 | ) 401 | securities.columns.name = "Sid" 402 | securities.T.to_csv(f, index=True, header=True) 403 | f.seek(0) 404 | 405 | def mock_download_account_balances(f, **kwargs): 406 | balances = pd.DataFrame(dict(Account=["U123", "DU234", "U999"], 407 | NetLiquidation=[85000, 450000, 200000], 408 | Currency=["USD", "USD", "USD"])) 409 | balances.to_csv(f, index=False) 410 | f.seek(0) 411 | 412 | def mock_download_exchange_rates(f, **kwargs): 413 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 414 | QuoteCurrency=["USD"], 415 | Rate=[1.0])) 416 | rates.to_csv(f, index=False) 417 | f.seek(0) 418 | 419 | def mock_list_positions(**kwargs): 420 | positions = [ 421 | { 422 | # this position won't be rebalanced 423 | "Account": "U123", 424 | "OrderRef": "buy-below-10", 425 | "Sid": "FI12345", 426 | "Quantity": 2000 427 | }, 428 | { 429 | # this position will be rebalanced 430 | "Account": "U999", 431 | "OrderRef": "buy-below-10", 432 | "Sid": "FI12345", 433 | "Quantity": 3000 434 | }, 435 | { 436 | # this position will switch sides 437 | "Account": "DU234", 438 | "OrderRef": "buy-below-10", 439 | "Sid": "FI12345", 440 | "Quantity": -4 441 | }, 442 | { 443 | # this position will be closed 444 | "Account": "DU234", 445 | "OrderRef": "buy-below-10", 446 | "Sid": "FI23456", 447 | "Quantity": -7 448 | }, 449 | 450 | ] 451 | return positions 452 | 453 | def mock_download_order_statuses(f, **kwargs): 454 | pass 455 | 456 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 457 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 458 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 459 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 460 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 461 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 462 | 463 | orders = BuyBelow10().trade( 464 | {"U123": 0.5, 465 | "DU234": 0.3, 466 | "U999": 0.5 467 | }) 468 | 469 | self.assertSetEqual( 470 | set(orders.columns), 471 | {'Sid', 472 | 'Account', 473 | 'Action', 474 | 'OrderRef', 475 | 'TotalQuantity', 476 | 'OrderType', 477 | 'Tif'} 478 | ) 479 | self.assertListEqual( 480 | orders.to_dict(orient="records"), 481 | [ 482 | { 483 | 'Sid': "FI12345", 484 | 'Account': 'DU234', 485 | 'Action': 'BUY', 486 | 'OrderRef': 'buy-below-10', 487 | # 0.3 allocation * 0.5 weight * 450K / 9.50 - (-4) 488 | 'TotalQuantity': 7109.0, 489 | 'OrderType': 'MKT', 490 | 'Tif': 'DAY' 491 | }, 492 | { 493 | 'Sid': "FI12345", 494 | 'Account': 'U999', 495 | 'Action': 'BUY', 496 | 'OrderRef': 'buy-below-10', 497 | # 0.5 allocation * 0.5 weight * 200K / 9.50 - 3000 498 | 'TotalQuantity': 2263, 499 | 'OrderType': 'MKT', 500 | 'Tif': 'DAY' 501 | }, 502 | 503 | { 504 | 'Sid': "FI23456", 505 | 'Account': 'DU234', 506 | 'Action': 'BUY', 507 | 'OrderRef': 'buy-below-10', 508 | # 0 - (-7) 509 | 'TotalQuantity': 7.0, 510 | 'OrderType': 'MKT', 511 | 'Tif': 'DAY' 512 | } 513 | ] 514 | ) 515 | 516 | def test_complain_if_min_rebalance_not_float(self): 517 | """ 518 | Tests error handling when ALLOW_REBALANCE is not a float or int. 519 | """ 520 | 521 | class BuyBelow10(Moonshot): 522 | """ 523 | A basic test strategy that buys below 10. 524 | """ 525 | CODE = "buy-below-10" 526 | ALLOW_REBALANCE = "always" 527 | 528 | def prices_to_signals(self, prices): 529 | signals = prices.loc["Close"] < 10 530 | return signals.astype(int) 531 | 532 | def signals_to_target_weights(self, signals, prices): 533 | return self.allocate_fixed_weights(signals, 0.5) 534 | 535 | def mock_get_prices(*args, **kwargs): 536 | 537 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 538 | fields = ["Close"] 539 | idx = pd.MultiIndex.from_product([fields, dt_idx], names=["Field", "Date"]) 540 | 541 | prices = pd.DataFrame( 542 | { 543 | "FI12345": [ 544 | # Close 545 | 9, 546 | 11, 547 | 9.50 548 | ], 549 | "FI23456": [ 550 | # Close 551 | 8.9, 552 | 12, 553 | 10.50 554 | ], 555 | }, 556 | index=idx 557 | ) 558 | 559 | return prices 560 | 561 | def mock_download_master_file(f, *args, **kwargs): 562 | 563 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 564 | securities = pd.DataFrame( 565 | { 566 | "FI12345": [ 567 | "America/New_York", 568 | "STK", 569 | "USD", 570 | None, 571 | None 572 | ], 573 | "FI23456": [ 574 | "America/New_York", 575 | "STK", 576 | "USD", 577 | None, 578 | None 579 | ], 580 | }, 581 | index=master_fields 582 | ) 583 | securities.columns.name = "Sid" 584 | securities.T.to_csv(f, index=True, header=True) 585 | f.seek(0) 586 | 587 | def mock_download_account_balances(f, **kwargs): 588 | balances = pd.DataFrame(dict(Account=["U123", "DU234", "U999"], 589 | NetLiquidation=[85000, 450000, 200000], 590 | Currency=["USD", "USD", "USD"])) 591 | balances.to_csv(f, index=False) 592 | f.seek(0) 593 | 594 | def mock_download_exchange_rates(f, **kwargs): 595 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 596 | QuoteCurrency=["USD"], 597 | Rate=[1.0])) 598 | rates.to_csv(f, index=False) 599 | f.seek(0) 600 | 601 | def mock_list_positions(**kwargs): 602 | positions = [ 603 | { 604 | # this position won't be rebalanced 605 | "Account": "U123", 606 | "OrderRef": "buy-below-10", 607 | "Sid": "FI12345", 608 | "Quantity": 2000 609 | }, 610 | { 611 | # this position will be rebalanced 612 | "Account": "U999", 613 | "OrderRef": "buy-below-10", 614 | "Sid": "FI12345", 615 | "Quantity": 3000 616 | }, 617 | { 618 | # this position will switch sides 619 | "Account": "DU234", 620 | "OrderRef": "buy-below-10", 621 | "Sid": "FI12345", 622 | "Quantity": -4 623 | }, 624 | { 625 | # this position will be closed 626 | "Account": "DU234", 627 | "OrderRef": "buy-below-10", 628 | "Sid": "FI23456", 629 | "Quantity": -7 630 | }, 631 | 632 | ] 633 | return positions 634 | 635 | def mock_download_order_statuses(f, **kwargs): 636 | pass 637 | 638 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 639 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 640 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 641 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 642 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 643 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 644 | 645 | with self.assertRaises(MoonshotParameterError) as cm: 646 | BuyBelow10().trade( 647 | {"U123": 0.5, 648 | "DU234": 0.3, 649 | "U999": 0.5 650 | }) 651 | 652 | self.assertIn( 653 | "invalid value for ALLOW_REBALANCE: always (should be a float)", repr(cm.exception)) 654 | -------------------------------------------------------------------------------- /moonshot/_tests/test_orders.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # To run: python3 -m unittest discover -s _tests/ -p test_*.py -t . -v 16 | 17 | import unittest 18 | from unittest.mock import patch 19 | import pandas as pd 20 | from moonshot import Moonshot 21 | from moonshot.exceptions import MoonshotError, MoonshotParameterError 22 | 23 | class OrdersTestCase(unittest.TestCase): 24 | """ 25 | Test cases related to creating orders. 26 | """ 27 | 28 | def test_child_orders(self): 29 | """ 30 | Tests that the orders DataFrame is correct when using orders_to_child_orders. 31 | """ 32 | 33 | class BuyBelow10ShortAbove10Overnight(Moonshot): 34 | """ 35 | A basic test strategy that buys below 10 and shorts above 10. 36 | """ 37 | CODE = "long-short-10" 38 | 39 | def prices_to_signals(self, prices): 40 | long_signals = prices.loc["Open"] <= 10 41 | short_signals = prices.loc["Open"] > 10 42 | signals = long_signals.astype(int).where(long_signals, -short_signals.astype(int)) 43 | return signals 44 | 45 | def signals_to_target_weights(self, signals, prices): 46 | weights = self.allocate_fixed_weights(signals, 0.25) 47 | return weights 48 | 49 | def order_stubs_to_orders(self, orders, prices): 50 | orders["Exchange"] = "SMART" 51 | orders["OrderType"] = 'MKT' 52 | orders["Tif"] = "Day" 53 | 54 | child_orders = self.orders_to_child_orders(orders) 55 | child_orders["OrderType"] = "MOC" 56 | 57 | orders = pd.concat([orders,child_orders]) 58 | return orders 59 | 60 | def mock_get_prices(*args, **kwargs): 61 | 62 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 63 | fields = ["Open"] 64 | idx = pd.MultiIndex.from_product([fields, dt_idx], names=["Field", "Date"]) 65 | 66 | prices = pd.DataFrame( 67 | { 68 | "FI12345": [ 69 | # Open 70 | 9, 71 | 11, 72 | 10.50 73 | ], 74 | "FI23456": [ 75 | # Open 76 | 9.89, 77 | 11, 78 | 8.50, 79 | ], 80 | }, 81 | index=idx 82 | ) 83 | return prices 84 | 85 | def mock_download_master_file(f, *args, **kwargs): 86 | 87 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 88 | securities = pd.DataFrame( 89 | { 90 | "FI12345": [ 91 | "America/New_York", 92 | "STK", 93 | "USD", 94 | None, 95 | None 96 | ], 97 | "FI23456": [ 98 | "America/New_York", 99 | "STK", 100 | "USD", 101 | None, 102 | None, 103 | ] 104 | }, 105 | index=master_fields 106 | ) 107 | securities.columns.name = "Sid" 108 | securities.T.to_csv(f, index=True, header=True) 109 | f.seek(0) 110 | 111 | def mock_download_account_balances(f, **kwargs): 112 | balances = pd.DataFrame(dict(Account=["U123"], 113 | NetLiquidation=[85000], 114 | Currency=["USD"])) 115 | balances.to_csv(f, index=False) 116 | f.seek(0) 117 | 118 | def mock_download_exchange_rates(f, **kwargs): 119 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 120 | QuoteCurrency=["USD"], 121 | Rate=[1.0])) 122 | rates.to_csv(f, index=False) 123 | f.seek(0) 124 | 125 | def mock_list_positions(**kwargs): 126 | return [] 127 | 128 | def mock_download_order_statuses(f, **kwargs): 129 | pass 130 | 131 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 132 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 133 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 134 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 135 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 136 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 137 | orders = BuyBelow10ShortAbove10Overnight().trade({"U123": 0.5}) 138 | 139 | self.assertSetEqual( 140 | set(orders.columns), 141 | {'Sid', 142 | 'Account', 143 | 'Action', 144 | 'OrderRef', 145 | 'TotalQuantity', 146 | 'Exchange', 147 | 'OrderId', 148 | 'ParentId', 149 | 'OrderType', 150 | 'Tif'} 151 | ) 152 | # replace nan with 'nan' to allow equality comparisons 153 | orders = orders.where(orders.notnull(), 'nan') 154 | 155 | # strip timestamp from OrderId/ParentId field 156 | orders.loc[orders.OrderId.notnull(), "OrderId"] = orders.loc[orders.OrderId.notnull()].OrderId.str.split(".").str[0] 157 | orders.loc[orders.ParentId.notnull(), "ParentId"] = orders.loc[orders.ParentId.notnull()].ParentId.str.split(".").str[0] 158 | 159 | self.assertListEqual( 160 | orders.to_dict(orient="records"), 161 | [ 162 | { 163 | 'Account': 'U123', 164 | 'Action': 'SELL', 165 | 'Sid': "FI12345", 166 | 'Exchange': 'SMART', 167 | 'OrderId': '0', 168 | 'OrderRef': 'long-short-10', 169 | 'OrderType': 'MKT', 170 | 'ParentId': 'nan', 171 | 'Tif': 'Day', 172 | 'TotalQuantity': 1012 173 | }, 174 | { 175 | 'Account': 'U123', 176 | 'Action': 'BUY', 177 | 'Sid': "FI23456", 178 | 'Exchange': 'SMART', 179 | 'OrderId': '1', 180 | 'OrderRef': 'long-short-10', 181 | 'OrderType': 'MKT', 182 | 'ParentId': 'nan', 183 | 'Tif': 'Day', 184 | 'TotalQuantity': 1250 185 | }, 186 | { 187 | 'Account': 'U123', 188 | 'Action': 'BUY', 189 | 'Sid': "FI12345", 190 | 'Exchange': 'SMART', 191 | 'OrderId': 'nan', 192 | 'OrderRef': 'long-short-10', 193 | 'OrderType': 'MOC', 194 | 'ParentId': '0', 195 | 'Tif': 'Day', 196 | 'TotalQuantity': 1012 197 | }, 198 | { 199 | 'Account': 'U123', 200 | 'Action': 'SELL', 201 | 'Sid': "FI23456", 202 | 'Exchange': 'SMART', 203 | 'OrderId': 'nan', 204 | 'OrderRef': 'long-short-10', 205 | 'OrderType': 'MOC', 206 | 'ParentId': '1', 207 | 'Tif': 'Day', 208 | 'TotalQuantity': 1250 209 | } 210 | ] 211 | ) 212 | 213 | def test_complain_if_reindex_like_orders_with_time_index_on_once_a_day_intraday_strategy(self): 214 | """ 215 | Tests error handling when using reindex_like_orders on a once-a-day 216 | intraday strategy and passing Time in the index. 217 | """ 218 | 219 | class ShortAbove10Intraday(Moonshot): 220 | 221 | CODE = "short-10" 222 | 223 | def prices_to_signals(self, prices): 224 | morning_prices = prices.loc["Open"].xs("09:30:00", level="Time") 225 | short_signals = morning_prices > 10 226 | return -short_signals.astype(int) 227 | 228 | def signals_to_target_weights(self, signals, prices): 229 | weights = self.allocate_fixed_weights(signals, 0.25) 230 | return weights 231 | 232 | def target_weights_to_positions(self, weights, prices): 233 | # enter on same day 234 | positions = weights.copy() 235 | return positions 236 | 237 | def positions_to_gross_returns(self, positions, prices): 238 | # hold from 10:00-16:00 239 | closes = prices.loc["Close"] 240 | entry_prices = closes.xs("09:30:00", level="Time") 241 | exit_prices = closes.xs("15:30:00", level="Time") 242 | pct_changes = (exit_prices - entry_prices) / entry_prices 243 | gross_returns = pct_changes * positions 244 | return gross_returns 245 | 246 | def order_stubs_to_orders(self, orders, prices): 247 | closes = prices.loc["Close"] 248 | prior_closes = closes.shift() 249 | prior_closes = self.reindex_like_orders(prior_closes, orders) 250 | 251 | orders["Exchange"] = "SMART" 252 | orders["OrderType"] = 'LMT' 253 | orders["LmtPrice"] = prior_closes 254 | orders["Tif"] = "Day" 255 | return orders 256 | 257 | def mock_get_prices(*args, **kwargs): 258 | 259 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 260 | fields = ["Close","Open"] 261 | times = ["09:30:00", "15:30:00"] 262 | idx = pd.MultiIndex.from_product( 263 | [fields, dt_idx, times], names=["Field", "Date", "Time"]) 264 | 265 | prices = pd.DataFrame( 266 | { 267 | "FI12345": [ 268 | # Close 269 | 9.6, 270 | 10.45, 271 | 10.12, 272 | 15.45, 273 | 8.67, 274 | 12.30, 275 | # Open 276 | 9.88, 277 | 10.34, 278 | 10.23, 279 | 16.45, 280 | 8.90, 281 | 11.30, 282 | ], 283 | "FI23456": [ 284 | # Close 285 | 10.56, 286 | 12.01, 287 | 10.50, 288 | 9.80, 289 | 13.40, 290 | 14.50, 291 | # Open 292 | 9.89, 293 | 11, 294 | 8.50, 295 | 10.50, 296 | 14.10, 297 | 15.60 298 | ], 299 | }, 300 | index=idx 301 | ) 302 | return prices 303 | 304 | def mock_download_master_file(f, *args, **kwargs): 305 | 306 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 307 | securities = pd.DataFrame( 308 | { 309 | "FI12345": [ 310 | "America/New_York", 311 | "STK", 312 | "USD", 313 | None, 314 | None 315 | ], 316 | "FI23456": [ 317 | "America/New_York", 318 | "STK", 319 | "USD", 320 | None, 321 | None, 322 | ] 323 | }, 324 | index=master_fields 325 | ) 326 | securities.columns.name = "Sid" 327 | securities.T.to_csv(f, index=True, header=True) 328 | f.seek(0) 329 | 330 | def mock_download_account_balances(f, **kwargs): 331 | balances = pd.DataFrame(dict(Account=["U123"], 332 | NetLiquidation=[85000], 333 | Currency=["USD"])) 334 | balances.to_csv(f, index=False) 335 | f.seek(0) 336 | 337 | def mock_download_exchange_rates(f, **kwargs): 338 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 339 | QuoteCurrency=["USD"], 340 | Rate=[1.0])) 341 | rates.to_csv(f, index=False) 342 | f.seek(0) 343 | 344 | def mock_list_positions(**kwargs): 345 | return [] 346 | 347 | def mock_download_order_statuses(f, **kwargs): 348 | pass 349 | 350 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 351 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 352 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 353 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 354 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 355 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 356 | with self.assertRaises(MoonshotError) as cm: 357 | ShortAbove10Intraday().trade({"U123": 0.5}) 358 | 359 | self.assertIn("cannot reindex DataFrame like orders because DataFrame contains " 360 | "'Time' in index, please take a cross-section first", repr(cm.exception)) 361 | 362 | def test_reindex_like_orders(self): 363 | """ 364 | Tests that the orders DataFrame is correct when using reindex_like_orders. 365 | """ 366 | 367 | class BuyBelow10ShortAbove10Overnight(Moonshot): 368 | """ 369 | A basic test strategy that buys below 10 and shorts above 10. 370 | """ 371 | CODE = "long-short-10" 372 | 373 | def prices_to_signals(self, prices): 374 | long_signals = prices.loc["Close"] <= 10 375 | short_signals = prices.loc["Close"] > 10 376 | signals = long_signals.astype(int).where(long_signals, -short_signals.astype(int)) 377 | return signals 378 | 379 | def signals_to_target_weights(self, signals, prices): 380 | weights = self.allocate_fixed_weights(signals, 0.25) 381 | return weights 382 | 383 | def order_stubs_to_orders(self, orders, prices): 384 | closes = prices.loc["Close"] 385 | prior_closes = closes.shift() 386 | prior_closes = self.reindex_like_orders(prior_closes, orders) 387 | 388 | orders["Exchange"] = "SMART" 389 | orders["OrderType"] = 'LMT' 390 | orders["LmtPrice"] = prior_closes 391 | orders["Tif"] = "Day" 392 | return orders 393 | 394 | def mock_get_prices(*args, **kwargs): 395 | 396 | dt_idx = pd.date_range(end=pd.Timestamp.today(tz="America/New_York"), periods=3, normalize=True).tz_localize(None) 397 | fields = ["Close"] 398 | idx = pd.MultiIndex.from_product([fields, dt_idx], names=["Field", "Date"]) 399 | 400 | prices = pd.DataFrame( 401 | { 402 | "FI12345": [ 403 | # Close 404 | 9, 405 | 11, 406 | 10.50 407 | ], 408 | "FI23456": [ 409 | # Close 410 | 9.89, 411 | 11.25, 412 | 8.50, 413 | ], 414 | }, 415 | index=idx 416 | ) 417 | return prices 418 | 419 | def mock_download_master_file(f, *args, **kwargs): 420 | 421 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 422 | securities = pd.DataFrame( 423 | { 424 | "FI12345": [ 425 | "America/New_York", 426 | "STK", 427 | "USD", 428 | None, 429 | None 430 | ], 431 | "FI23456": [ 432 | "America/New_York", 433 | "STK", 434 | "USD", 435 | None, 436 | None, 437 | ] 438 | }, 439 | index=master_fields 440 | ) 441 | securities.columns.name = "Sid" 442 | securities.T.to_csv(f, index=True, header=True) 443 | f.seek(0) 444 | 445 | def mock_download_account_balances(f, **kwargs): 446 | balances = pd.DataFrame(dict(Account=["U123"], 447 | NetLiquidation=[85000], 448 | Currency=["USD"])) 449 | balances.to_csv(f, index=False) 450 | f.seek(0) 451 | 452 | def mock_download_exchange_rates(f, **kwargs): 453 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 454 | QuoteCurrency=["USD"], 455 | Rate=[1.0])) 456 | rates.to_csv(f, index=False) 457 | f.seek(0) 458 | 459 | def mock_list_positions(**kwargs): 460 | return [] 461 | 462 | def mock_download_order_statuses(f, **kwargs): 463 | pass 464 | 465 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 466 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 467 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 468 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 469 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 470 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 471 | orders = BuyBelow10ShortAbove10Overnight().trade({"U123": 0.5}) 472 | 473 | self.assertSetEqual( 474 | set(orders.columns), 475 | {'Sid', 476 | 'Account', 477 | 'Action', 478 | 'OrderRef', 479 | 'TotalQuantity', 480 | 'Exchange', 481 | 'LmtPrice', 482 | 'OrderType', 483 | 'Tif'} 484 | ) 485 | 486 | self.assertListEqual( 487 | orders.to_dict(orient="records"), 488 | [ 489 | { 490 | 'Sid': "FI12345", 491 | 'Account': 'U123', 492 | 'Action': 'SELL', 493 | 'OrderRef': 'long-short-10', 494 | 'TotalQuantity': 1012, 495 | 'Exchange': 'SMART', 496 | 'OrderType': 'LMT', 497 | 'LmtPrice': 11.0, 498 | 'Tif': 'Day' 499 | }, 500 | { 501 | 'Sid': "FI23456", 502 | 'Account': 'U123', 503 | 'Action': 'BUY', 504 | 'OrderRef': 'long-short-10', 505 | 'TotalQuantity': 1250, 506 | 'Exchange': 'SMART', 507 | 'OrderType': 'LMT', 508 | 'LmtPrice': 11.25, 509 | 'Tif': 'Day' 510 | } 511 | ] 512 | ) 513 | 514 | def test_reindex_like_orders_continuous_intraday(self): 515 | """ 516 | Tests that the orders DataFrame is correct when using 517 | reindex_like_orders on a continuous intraday strategy. 518 | """ 519 | class BuyBelow10ShortAbove10ContIntraday(Moonshot): 520 | """ 521 | A basic test strategy that buys below 10 and shorts above 10. 522 | """ 523 | CODE = "c-intraday-pivot-10" 524 | 525 | def prices_to_signals(self, prices): 526 | long_signals = prices.loc["Close"] <= 10 527 | short_signals = prices.loc["Close"] > 10 528 | signals = long_signals.astype(int).where(long_signals, -short_signals.astype(int)) 529 | return signals 530 | 531 | def order_stubs_to_orders(self, orders, prices): 532 | closes = prices.loc["Close"] 533 | prior_closes = closes.shift() 534 | prior_closes = self.reindex_like_orders(prior_closes, orders) 535 | 536 | orders["Exchange"] = "SMART" 537 | orders["OrderType"] = 'LMT' 538 | orders["LmtPrice"] = prior_closes 539 | orders["Tif"] = "Day" 540 | return orders 541 | 542 | def mock_get_prices(*args, **kwargs): 543 | 544 | dt_idx = pd.DatetimeIndex(["2018-05-01","2018-05-02"]) 545 | fields = ["Close"] 546 | times = ["10:00:00", "11:00:00", "12:00:00"] 547 | idx = pd.MultiIndex.from_product( 548 | [fields, dt_idx, times], names=["Field", "Date", "Time"]) 549 | 550 | prices = pd.DataFrame( 551 | { 552 | "FI12345": [ 553 | # Close 554 | 9.6, 555 | 10.45, 556 | 10.12, 557 | 15.45, 558 | 8.67, 559 | 12.30, 560 | ], 561 | "FI23456": [ 562 | # Close 563 | 10.56, 564 | 12.01, 565 | 10.50, 566 | 9.80, 567 | 13.40, 568 | 7.50, 569 | ], 570 | }, 571 | index=idx 572 | ) 573 | return prices 574 | 575 | def mock_download_master_file(f, *args, **kwargs): 576 | 577 | master_fields = ["Timezone", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 578 | securities = pd.DataFrame( 579 | { 580 | "FI12345": [ 581 | "America/New_York", 582 | "STK", 583 | "USD", 584 | None, 585 | None 586 | ], 587 | "FI23456": [ 588 | "America/New_York", 589 | "STK", 590 | "USD", 591 | None, 592 | None, 593 | ] 594 | }, 595 | index=master_fields 596 | ) 597 | securities.columns.name = "Sid" 598 | securities.T.to_csv(f, index=True, header=True) 599 | f.seek(0) 600 | 601 | def mock_download_account_balances(f, **kwargs): 602 | balances = pd.DataFrame(dict(Account=["U123"], 603 | NetLiquidation=[60000], 604 | Currency=["USD"])) 605 | balances.to_csv(f, index=False) 606 | f.seek(0) 607 | 608 | def mock_download_exchange_rates(f, **kwargs): 609 | rates = pd.DataFrame(dict(BaseCurrency=["USD"], 610 | QuoteCurrency=["USD"], 611 | Rate=[1.0])) 612 | rates.to_csv(f, index=False) 613 | f.seek(0) 614 | 615 | def mock_list_positions(**kwargs): 616 | return [] 617 | 618 | def mock_download_order_statuses(f, **kwargs): 619 | pass 620 | 621 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 622 | with patch("moonshot.strategies.base.download_account_balances", new=mock_download_account_balances): 623 | with patch("moonshot.strategies.base.download_exchange_rates", new=mock_download_exchange_rates): 624 | with patch("moonshot.strategies.base.list_positions", new=mock_list_positions): 625 | with patch("moonshot.strategies.base.download_order_statuses", new=mock_download_order_statuses): 626 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 627 | orders = BuyBelow10ShortAbove10ContIntraday().trade( 628 | {"U123": 1.0}, review_date="2018-05-02 12:05:00") 629 | 630 | self.assertSetEqual( 631 | set(orders.columns), 632 | {'Sid', 633 | 'Account', 634 | 'Action', 635 | 'OrderRef', 636 | 'TotalQuantity', 637 | 'Exchange', 638 | 'LmtPrice', 639 | 'OrderType', 640 | 'Tif'} 641 | ) 642 | 643 | self.assertListEqual( 644 | orders.to_dict(orient="records"), 645 | [ 646 | { 647 | 'Sid': "FI12345", 648 | 'Account': 'U123', 649 | 'Action': 'SELL', 650 | 'OrderRef': 'c-intraday-pivot-10', 651 | 'TotalQuantity': 2439, 652 | 'Exchange': 'SMART', 653 | 'OrderType': 'LMT', 654 | 'LmtPrice': 8.67, 655 | 'Tif': 'Day' 656 | }, 657 | { 658 | 'Sid': "FI23456", 659 | 'Account': 'U123', 660 | 'Action': 'BUY', 661 | 'OrderRef': 'c-intraday-pivot-10', 662 | 'TotalQuantity': 4000, 663 | 'Exchange': 'SMART', 664 | 'OrderType': 'LMT', 665 | 'LmtPrice': 13.4, 666 | 'Tif': 'Day' 667 | } 668 | ] 669 | ) -------------------------------------------------------------------------------- /moonshot/_tests/test_positions_closed_daily.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # To run: python3 -m unittest discover -s _tests/ -p test_*.py -t . -v 16 | 17 | import os 18 | import unittest 19 | from unittest.mock import patch 20 | import glob 21 | import pandas as pd 22 | from moonshot import Moonshot 23 | from moonshot._cache import TMP_DIR 24 | 25 | class PositionsClosedDailyTestCase(unittest.TestCase): 26 | 27 | def tearDown(self): 28 | """ 29 | Remove cached files. 30 | """ 31 | for file in glob.glob("{0}/moonshot*.pkl".format(TMP_DIR)): 32 | os.remove(file) 33 | 34 | def test_positions_closed_daily(self): 35 | """ 36 | Tests that the resulting DataFrames are correct after running a 37 | short-only intraday strategy with POSITIONS_CLOSED_DAILY = True. 38 | """ 39 | 40 | class ShortAbove10Intraday(Moonshot): 41 | """ 42 | A basic test strategy that shorts above 10 and holds intraday. 43 | """ 44 | POSITIONS_CLOSED_DAILY = True 45 | SLIPPAGE_BPS = 10 46 | 47 | def prices_to_signals(self, prices): 48 | morning_prices = prices.loc["Open"].xs("09:30:00", level="Time") 49 | short_signals = morning_prices > 10 50 | return -short_signals.astype(int) 51 | 52 | def signals_to_target_weights(self, signals, prices): 53 | weights = self.allocate_fixed_weights(signals, 0.25) 54 | return weights 55 | 56 | def target_weights_to_positions(self, weights, prices): 57 | # enter on same day 58 | positions = weights.copy() 59 | return positions 60 | 61 | def positions_to_gross_returns(self, positions, prices): 62 | # hold from 10:00-16:00 63 | closes = prices.loc["Close"] 64 | entry_prices = closes.xs("09:30:00", level="Time") 65 | exit_prices = closes.xs("15:30:00", level="Time") 66 | pct_changes = (exit_prices - entry_prices) / entry_prices 67 | gross_returns = pct_changes * positions 68 | return gross_returns 69 | 70 | def mock_get_prices(*args, **kwargs): 71 | 72 | dt_idx = pd.DatetimeIndex(["2018-05-01","2018-05-02","2018-05-03"]) 73 | fields = ["Close","Open"] 74 | times = ["09:30:00", "15:30:00"] 75 | idx = pd.MultiIndex.from_product( 76 | [fields, dt_idx, times], names=["Field", "Date", "Time"]) 77 | 78 | prices = pd.DataFrame( 79 | { 80 | "FI12345": [ 81 | # Close 82 | 9.6, 83 | 10.45, 84 | 10.12, 85 | 15.45, 86 | 8.67, 87 | 12.30, 88 | # Open 89 | 9.88, 90 | 10.34, 91 | 10.23, 92 | 16.45, 93 | 8.90, 94 | 11.30, 95 | ], 96 | "FI23456": [ 97 | # Close 98 | 10.56, 99 | 12.01, 100 | 10.50, 101 | 9.80, 102 | 13.40, 103 | 14.50, 104 | # Open 105 | 9.89, 106 | 11, 107 | 8.50, 108 | 10.50, 109 | 14.10, 110 | 15.60 111 | ], 112 | }, 113 | index=idx 114 | ) 115 | 116 | return prices 117 | 118 | def mock_download_master_file(f, *args, **kwargs): 119 | 120 | master_fields = ["Timezone", "Symbol", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 121 | securities = pd.DataFrame( 122 | { 123 | "FI12345": [ 124 | "America/New_York", 125 | "ABC", 126 | "STK", 127 | "USD", 128 | None, 129 | None 130 | ], 131 | "FI23456": [ 132 | "America/New_York", 133 | "DEF", 134 | "STK", 135 | "USD", 136 | None, 137 | None, 138 | ] 139 | }, 140 | index=master_fields 141 | ) 142 | securities.columns.name = "Sid" 143 | securities.T.to_csv(f, index=True, header=True) 144 | f.seek(0) 145 | 146 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 147 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 148 | results = ShortAbove10Intraday().backtest() 149 | 150 | self.assertSetEqual( 151 | set(results.index.get_level_values("Field")), 152 | {'Commission', 153 | 'AbsExposure', 154 | 'Signal', 155 | 'Return', 156 | 'Slippage', 157 | 'NetExposure', 158 | 'TotalHoldings', 159 | 'Turnover', 160 | 'AbsWeight', 161 | 'Weight'} 162 | ) 163 | 164 | # replace nan with "nan" to allow equality comparisons 165 | results = results.round(7) 166 | results = results.where(results.notnull(), "nan") 167 | 168 | signals = results.loc["Signal"].reset_index() 169 | signals["Date"] = signals.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 170 | self.assertDictEqual( 171 | signals.to_dict(orient="list"), 172 | {'Date': [ 173 | '2018-05-01T00:00:00', 174 | '2018-05-02T00:00:00', 175 | '2018-05-03T00:00:00'], 176 | "FI12345": [0.0, 177 | -1.0, 178 | 0.0], 179 | "FI23456": [0.0, 180 | 0.0, 181 | -1.0]} 182 | ) 183 | 184 | weights = results.loc["Weight"].reset_index() 185 | weights["Date"] = weights.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 186 | self.assertDictEqual( 187 | weights.to_dict(orient="list"), 188 | {'Date': [ 189 | '2018-05-01T00:00:00', 190 | '2018-05-02T00:00:00', 191 | '2018-05-03T00:00:00'], 192 | "FI12345": [0.0, 193 | -0.25, 194 | 0.0], 195 | "FI23456": [0.0, 196 | 0.0, 197 | -0.25]} 198 | ) 199 | 200 | net_positions = results.loc["NetExposure"].reset_index() 201 | net_positions["Date"] = net_positions.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 202 | self.assertDictEqual( 203 | net_positions.to_dict(orient="list"), 204 | {'Date': [ 205 | '2018-05-01T00:00:00', 206 | '2018-05-02T00:00:00', 207 | '2018-05-03T00:00:00'], 208 | "FI12345": [0.0, 209 | -0.25, 210 | 0.0], 211 | "FI23456": [0.0, 212 | 0.0, 213 | -0.25]} 214 | ) 215 | 216 | turnover = results.loc["Turnover"].reset_index() 217 | turnover["Date"] = turnover.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 218 | self.assertDictEqual( 219 | turnover.to_dict(orient="list"), 220 | {'Date': [ 221 | '2018-05-01T00:00:00', 222 | '2018-05-02T00:00:00', 223 | '2018-05-03T00:00:00'], 224 | "FI12345": [0.0, 225 | 0.5, 226 | 0.0], 227 | "FI23456": [0.0, 228 | 0.0, 229 | 0.5]} 230 | ) 231 | 232 | slippage = results.loc["Slippage"].reset_index() 233 | slippage["Date"] = slippage.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 234 | self.assertDictEqual( 235 | slippage.to_dict(orient="list"), 236 | {'Date': [ 237 | '2018-05-01T00:00:00', 238 | '2018-05-02T00:00:00', 239 | '2018-05-03T00:00:00'], 240 | "FI12345": [0.0, 241 | 0.0005, 242 | 0.0], 243 | "FI23456": [0.0, 244 | 0.0, 245 | 0.0005]} 246 | ) 247 | 248 | def test_positions_not_closed_daily(self): 249 | """ 250 | Tests that the resulting DataFrames are correct after running a 251 | short-only intraday strategy with POSITIONS_CLOSED_DAILY = False. 252 | """ 253 | 254 | class ShortAbove10Intraday(Moonshot): 255 | """ 256 | A basic test strategy that shorts above 10 and holds intraday. 257 | """ 258 | # POSITIONS_CLOSED_DAILY defaults to False 259 | SLIPPAGE_BPS = 10 260 | 261 | def prices_to_signals(self, prices): 262 | morning_prices = prices.loc["Open"].xs("09:30:00", level="Time") 263 | short_signals = morning_prices > 10 264 | return -short_signals.astype(int) 265 | 266 | def signals_to_target_weights(self, signals, prices): 267 | weights = self.allocate_fixed_weights(signals, 0.25) 268 | return weights 269 | 270 | def target_weights_to_positions(self, weights, prices): 271 | # enter on same day 272 | positions = weights.copy() 273 | return positions 274 | 275 | def positions_to_gross_returns(self, positions, prices): 276 | # hold from 10:00-16:00 277 | closes = prices.loc["Close"] 278 | entry_prices = closes.xs("09:30:00", level="Time") 279 | exit_prices = closes.xs("15:30:00", level="Time") 280 | pct_changes = (exit_prices - entry_prices) / entry_prices 281 | gross_returns = pct_changes * positions 282 | return gross_returns 283 | 284 | def mock_get_prices(*args, **kwargs): 285 | 286 | dt_idx = pd.DatetimeIndex(["2018-05-01","2018-05-02","2018-05-03"]) 287 | fields = ["Close","Open"] 288 | times = ["09:30:00", "15:30:00"] 289 | idx = pd.MultiIndex.from_product( 290 | [fields, dt_idx, times], names=["Field", "Date", "Time"]) 291 | 292 | prices = pd.DataFrame( 293 | { 294 | "FI12345": [ 295 | # Close 296 | 9.6, 297 | 10.45, 298 | 10.12, 299 | 15.45, 300 | 8.67, 301 | 12.30, 302 | # Open 303 | 9.88, 304 | 10.34, 305 | 10.23, 306 | 16.45, 307 | 8.90, 308 | 11.30, 309 | ], 310 | "FI23456": [ 311 | # Close 312 | 10.56, 313 | 12.01, 314 | 10.50, 315 | 9.80, 316 | 13.40, 317 | 14.50, 318 | # Open 319 | 9.89, 320 | 11, 321 | 8.50, 322 | 10.50, 323 | 14.10, 324 | 15.60 325 | ], 326 | }, 327 | index=idx 328 | ) 329 | 330 | return prices 331 | 332 | def mock_download_master_file(f, *args, **kwargs): 333 | 334 | master_fields = ["Timezone", "Symbol", "SecType", "Currency", "PriceMagnifier", "Multiplier"] 335 | securities = pd.DataFrame( 336 | { 337 | "FI12345": [ 338 | "America/New_York", 339 | "ABC", 340 | "STK", 341 | "USD", 342 | None, 343 | None 344 | ], 345 | "FI23456": [ 346 | "America/New_York", 347 | "DEF", 348 | "STK", 349 | "USD", 350 | None, 351 | None, 352 | ] 353 | }, 354 | index=master_fields 355 | ) 356 | securities.columns.name = "Sid" 357 | securities.T.to_csv(f, index=True, header=True) 358 | f.seek(0) 359 | 360 | with patch("moonshot.strategies.base.get_prices", new=mock_get_prices): 361 | with patch("moonshot.strategies.base.download_master_file", new=mock_download_master_file): 362 | results = ShortAbove10Intraday().backtest() 363 | 364 | self.assertSetEqual( 365 | set(results.index.get_level_values("Field")), 366 | {'Commission', 367 | 'AbsExposure', 368 | 'Signal', 369 | 'Return', 370 | 'Slippage', 371 | 'NetExposure', 372 | 'TotalHoldings', 373 | 'Turnover', 374 | 'AbsWeight', 375 | 'Weight'} 376 | ) 377 | 378 | # replace nan with "nan" to allow equality comparisons 379 | results = results.round(7) 380 | results = results.where(results.notnull(), "nan") 381 | 382 | signals = results.loc["Signal"].reset_index() 383 | signals["Date"] = signals.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 384 | self.assertDictEqual( 385 | signals.to_dict(orient="list"), 386 | {'Date': [ 387 | '2018-05-01T00:00:00', 388 | '2018-05-02T00:00:00', 389 | '2018-05-03T00:00:00'], 390 | "FI12345": [0.0, 391 | -1.0, 392 | 0.0], 393 | "FI23456": [0.0, 394 | 0.0, 395 | -1.0]} 396 | ) 397 | 398 | weights = results.loc["Weight"].reset_index() 399 | weights["Date"] = weights.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 400 | self.assertDictEqual( 401 | weights.to_dict(orient="list"), 402 | {'Date': [ 403 | '2018-05-01T00:00:00', 404 | '2018-05-02T00:00:00', 405 | '2018-05-03T00:00:00'], 406 | "FI12345": [0.0, 407 | -0.25, 408 | 0.0], 409 | "FI23456": [0.0, 410 | 0.0, 411 | -0.25]} 412 | ) 413 | 414 | net_positions = results.loc["NetExposure"].reset_index() 415 | net_positions["Date"] = net_positions.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 416 | self.assertDictEqual( 417 | net_positions.to_dict(orient="list"), 418 | {'Date': [ 419 | '2018-05-01T00:00:00', 420 | '2018-05-02T00:00:00', 421 | '2018-05-03T00:00:00'], 422 | "FI12345": [0.0, 423 | -0.25, 424 | 0.0], 425 | "FI23456": [0.0, 426 | 0.0, 427 | -0.25]} 428 | ) 429 | 430 | turnover = results.loc["Turnover"].reset_index() 431 | turnover["Date"] = turnover.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 432 | self.assertDictEqual( 433 | turnover.to_dict(orient="list"), 434 | {'Date': [ 435 | '2018-05-01T00:00:00', 436 | '2018-05-02T00:00:00', 437 | '2018-05-03T00:00:00'], 438 | "FI12345": ["nan", 439 | 0.25, 440 | 0.25], 441 | "FI23456": ["nan", 442 | 0.0, 443 | 0.25]} 444 | ) 445 | 446 | slippage = results.loc["Slippage"].reset_index() 447 | slippage["Date"] = slippage.Date.dt.strftime("%Y-%m-%dT%H:%M:%S%z") 448 | self.assertDictEqual( 449 | slippage.to_dict(orient="list"), 450 | {'Date': [ 451 | '2018-05-01T00:00:00', 452 | '2018-05-02T00:00:00', 453 | '2018-05-03T00:00:00'], 454 | "FI12345": [0.0, 455 | 0.00025, 456 | 0.00025], 457 | "FI23456": [0.0, 458 | 0.0, 459 | 0.00025]} 460 | ) 461 | 462 | -------------------------------------------------------------------------------- /moonshot/_tests/test_weight_allocations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # To run: python3 -m unittest discover -s _tests/ -p test_*.py -t . -v 16 | 17 | import os 18 | import unittest 19 | from unittest.mock import patch 20 | import glob 21 | import pandas as pd 22 | from moonshot import Moonshot 23 | from moonshot._cache import TMP_DIR 24 | 25 | class WeightAllocationsTestCase(unittest.TestCase): 26 | 27 | def test_allocate_equal_weights(self): 28 | """ 29 | Tests that the allocate_equal_weights returns the expected 30 | DataFrames. 31 | """ 32 | signals = pd.DataFrame( 33 | data={ 34 | "FI12345": [1, 1, 1, 0, 0], 35 | "FI23456": [0, -1, 1, 0, -1], 36 | } 37 | ) 38 | 39 | target_weights = Moonshot().allocate_equal_weights(signals, cap=1.0) 40 | 41 | self.assertDictEqual( 42 | target_weights.to_dict(orient="list"), 43 | {"FI12345": [1.0, 0.5, 0.5, 0.0, 0.0], 44 | "FI23456": [0.0, -0.5, 0.5, 0.0, -1.0]} 45 | ) 46 | 47 | target_weights = Moonshot().allocate_equal_weights(signals, cap=0.5) 48 | 49 | self.assertDictEqual( 50 | target_weights.to_dict(orient="list"), 51 | {"FI12345": [0.5, 0.25, 0.25, 0.0, 0.0], 52 | "FI23456": [0.0, -0.25, 0.25, 0.0, -0.5]} 53 | ) 54 | 55 | def test_allocate_fixed_weights(self): 56 | """ 57 | Tests that the allocate_fixed_weights returns the expected 58 | DataFrames. 59 | """ 60 | signals = pd.DataFrame( 61 | data={ 62 | "FI12345": [1, 1, 1, 0, 0], 63 | "FI23456": [0, -1, 1, 0, -1], 64 | "FI34567": [1, 1, 1, -1, -1] 65 | } 66 | ) 67 | 68 | target_weights = Moonshot().allocate_fixed_weights(signals, 0.34) 69 | 70 | self.assertDictEqual( 71 | target_weights.to_dict(orient="list"), 72 | {"FI12345": [0.34, 0.34, 0.34, 0.0, 0.0], 73 | "FI23456": [0.0, -0.34, 0.34, 0.0, -0.34], 74 | "FI34567": [0.34, 0.34, 0.34, -0.34, -0.34]} 75 | ) 76 | 77 | def test_allocate_fixed_weights_capped(self): 78 | """ 79 | Tests that the allocate_fixed_weights_capped returns the expected 80 | DataFrames. 81 | """ 82 | signals = pd.DataFrame( 83 | data={ 84 | "FI12345": [1, 1, 1, 0, 0], 85 | "FI23456": [0, -1, 1, 0, -1], 86 | "FI34567": [1, 1, 1, -1, -1] 87 | } 88 | ) 89 | 90 | target_weights = Moonshot().allocate_fixed_weights_capped(signals, 0.34, cap=1.5) 91 | 92 | self.assertDictEqual( 93 | target_weights.to_dict(orient="list"), 94 | {"FI12345": [0.34, 0.34, 0.34, 0.0, 0.0], 95 | "FI23456": [0.0, -0.34, 0.34, 0.0, -0.34], 96 | "FI34567": [0.34, 0.34, 0.34, -0.34, -0.34]} 97 | ) 98 | 99 | target_weights = Moonshot().allocate_fixed_weights_capped(signals, 0.34, cap=0.81) 100 | 101 | self.assertDictEqual( 102 | target_weights.to_dict(orient="list"), 103 | {"FI12345": [0.34, 0.27, 0.27, 0.0, 0.0], 104 | "FI23456": [0.0, -0.27, 0.27, 0.0, -0.34], 105 | "FI34567": [0.34, 0.27, 0.27, -0.34, -0.34]} 106 | ) 107 | 108 | def test_allocate_market_neutral_fixed_weights_capped(self): 109 | """ 110 | Tests that the allocate_market_neutral_fixed_weights_capped returns 111 | the expected DataFrames. 112 | """ 113 | signals = pd.DataFrame( 114 | data={ 115 | "FI12345": [1, 1, 1, 0, 0], 116 | "FI23456": [0, -1, 1, 1, -1], 117 | "FI34567": [1, 1, -1, -1, -1] 118 | } 119 | ) 120 | 121 | target_weights = Moonshot().allocate_market_neutral_fixed_weights_capped( 122 | signals, 0.34, cap=1.2, neutralize_weights=False) 123 | 124 | self.assertDictEqual( 125 | target_weights.to_dict(orient="list"), 126 | {"FI12345": [0.3, 0.3, 0.3, 0.0, 0.0], 127 | "FI23456": [0.0, -0.34, 0.3, 0.34, -0.3], 128 | "FI34567": [0.3, 0.3, -0.34, -0.34, -0.3]} 129 | ) 130 | 131 | target_weights = Moonshot().allocate_market_neutral_fixed_weights_capped( 132 | signals, 0.34, cap=1.2, neutralize_weights=True) 133 | 134 | self.assertDictEqual( 135 | target_weights.to_dict(orient="list"), 136 | {"FI12345": [0.0, 0.17, 0.17, 0.0, 0.0], 137 | "FI23456": [0.0, -0.34, 0.17, 0.34, -0.0], 138 | "FI34567": [0.0, 0.17, -0.34, -0.34, -0.0]} 139 | ) 140 | -------------------------------------------------------------------------------- /moonshot/_tests/utils.py: -------------------------------------------------------------------------------- 1 | def round_results(results_dict_or_list, n=6): 2 | """ 3 | Rounds the values in results_dict, which can be scalars or 4 | lists. 5 | """ 6 | def round_if_can(value): 7 | try: 8 | return round(value, n) 9 | except TypeError: 10 | return value 11 | 12 | if isinstance(results_dict_or_list, dict): 13 | for key, value in results_dict_or_list.items(): 14 | if isinstance(value, list): 15 | results_dict_or_list[key] = [round_if_can(v) for v in value] 16 | else: 17 | results_dict_or_list[key] = round_if_can(value) 18 | return results_dict_or_list 19 | else: 20 | return [round_if_can(value) for value in results_dict_or_list] 21 | -------------------------------------------------------------------------------- /moonshot/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = "$Format:%d$" 27 | git_full = "$Format:%H$" 28 | git_date = "$Format:%ci$" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "" 46 | cfg.versionfile_source = "moonshot/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Exceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | -------------------------------------------------------------------------------- /moonshot/commission/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | """ 15 | Moonshot commission classes. Except as noted below, all commission classes 16 | must be subclassed to be used, filling in specific parameters for your 17 | commission structure. 18 | 19 | Classes 20 | ------- 21 | Commission 22 | Base class for all commission classes. 23 | 24 | FuturesCommission 25 | Base class for futures commissions. 26 | 27 | PercentageCommission 28 | Base class for commissions which are a fixed percentage of the trade 29 | value. 30 | 31 | NoCommission 32 | Commission class for strategies that don't pay commissions. 33 | This class can be used as-is. 34 | 35 | PerShareCommission 36 | Base class for commissions which are primarily based on the number of 37 | shares. 38 | 39 | SpotFXCommission 40 | Commission class for spot FX. This class can be used as-is. 41 | 42 | Notes 43 | ----- 44 | Usage Guide: 45 | 46 | * Moonshot commissions and slippage: https://qrok.it/dl/ms/moonshot-commissions-slippage 47 | """ 48 | from .fut import FuturesCommission 49 | from .base import Commission, PercentageCommission, NoCommission 50 | from .stk import PerShareCommission 51 | from .fx import SpotFXCommission 52 | 53 | # alias 54 | SpotForexCommission = SpotFXCommission 55 | 56 | 57 | __all__ = [ 58 | 'Commission', 59 | 'FuturesCommission', 60 | 'PercentageCommission', 61 | 'NoCommission', 62 | 'PerShareCommission', 63 | 'SpotFXCommission', 64 | ] 65 | -------------------------------------------------------------------------------- /moonshot/commission/base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import pandas as pd 15 | 16 | class Commission(object): 17 | """ 18 | Base class for all commission classes. 19 | """ 20 | MIN_COMMISSION: float = 0 21 | """the minimum commission charged by the broker. Only enforced if NLVs are passed 22 | by the backtest""" 23 | 24 | @classmethod 25 | def get_commissions( 26 | cls, 27 | contract_values: pd.DataFrame, 28 | turnover: pd.DataFrame, 29 | nlvs: pd.DataFrame = None 30 | ) -> pd.DataFrame: 31 | """ 32 | Returns a DataFrame of commissions. 33 | 34 | 35 | Parameters 36 | ---------- 37 | contract_values : DataFrame, required 38 | a DataFrame of contract values (price * multipler / price_magnifier) 39 | 40 | turnover : DataFrame of floats, required 41 | a DataFrame of turnover, expressing the percentage of account equity that 42 | is turning over 43 | 44 | nlvs : DataFrame of nlvs, optional 45 | a DataFrame of NLVs (account balance), which is used to calculate and 46 | enforce min commissions. NLVs should be expressed in the currency of the 47 | contract, which should also be the currency of the commission class. If 48 | not provided, min commissions won't be calculated or enforced. 49 | 50 | Returns 51 | ------- 52 | DataFrame 53 | a DataFrame of commissions, expressed as percentages of account equity 54 | """ 55 | raise NotImplementedError() 56 | 57 | @classmethod 58 | def _enforce_min_commissions(cls, commissions, nlvs): 59 | """ 60 | Return a DataFrame of commissions after enforcing the min commission. 61 | """ 62 | # Express the min commission as a percentage of NLV 63 | min_commissions = cls.MIN_COMMISSION / nlvs 64 | must_pay_min_commissions = (commissions > 0) & (commissions < min_commissions) 65 | commissions = commissions.where(must_pay_min_commissions == False, min_commissions) 66 | return commissions 67 | 68 | class PercentageCommission(Commission): 69 | """ 70 | Base class for commissions which are a fixed percentage of the trade 71 | value. These commissions consist of a broker commission percentage rate 72 | (which might vary based on monthly trade volume) plus a fixed exchange 73 | fee percentage rate. 74 | 75 | This class can't be used directly but should be subclassed with the 76 | appropriate parameters. 77 | 78 | Parameters 79 | ---------- 80 | BROKER_COMMISSION_RATE : float, required 81 | the commission rate (as a percentage of trade value) charged by the broker 82 | 83 | BROKER_COMMISSION_RATE_TIER_2 : float, optional 84 | the commission rate (as a percentage of trade value) charged by the broker 85 | at monthly volume tier 2 86 | 87 | TIER_2_RATIO : float, optional 88 | the ratio of monthly trades at volume tier 2 (default 0) 89 | 90 | EXCHANGE_FEE_RATE : float, required 91 | the exchange fee as a percentage of trade value 92 | 93 | MIN_COMMISSION : float, optional 94 | the minimum commission charged by the broker. Only enforced if NLVs are passed 95 | by the backtest. 96 | 97 | Examples 98 | -------- 99 | Example commission subclass for Tokyo Stock Exchange: 100 | 101 | >>> class JapanStockCommission(PercentageCommission): 102 | >>> BROKER_COMMISSION_RATE = 0.0005 103 | >>> EXCHANGE_FEE_RATE = 0.000004 104 | >>> MIN_COMMISSION = 80.00 # JPY 105 | >>> 106 | >>> # then, use this on your strategy: 107 | >>> class MyJapanStrategy(Moonshot): 108 | >>> COMMISSION_CLASS = JapanStockCommission 109 | """ 110 | BROKER_COMMISSION_RATE: float = 0 111 | """the commission rate (as a percentage of trade value) charged by the broker""" 112 | BROKER_COMMISSION_RATE_TIER_2: float = None 113 | """the commission rate (as a percentage of trade value) charged by the broker 114 | at monthly volume tier 2""" 115 | TIER_2_RATIO: float = None 116 | """the ratio of monthly trades at volume tier 2 (default 0)""" 117 | EXCHANGE_FEE_RATE: float = 0 118 | """the exchange fee as a percentage of trade value""" 119 | MIN_COMMISSION: float = 0 120 | """the minimum commission charged by the broker. Only enforced if NLVs are passed 121 | by the backtest.""" 122 | 123 | @classmethod 124 | def get_commissions( 125 | cls, 126 | contract_values: pd.DataFrame, 127 | turnover: pd.DataFrame, 128 | nlvs: pd.DataFrame = None 129 | ) -> pd.DataFrame: 130 | """ 131 | Returns a DataFrame of commissions. 132 | 133 | 134 | Parameters 135 | ---------- 136 | contract_values : DataFrame, required 137 | a DataFrame of contract values (price * multipler / price_magnifier) 138 | 139 | turnover : DataFrame of floats, required 140 | a DataFrame of turnover, expressing the percentage of account equity that 141 | is turning over 142 | 143 | nlvs : DataFrame of nlvs, optional 144 | a DataFrame of NLVs (account balance), which is used to calculate and 145 | enforce min commissions. NLVs should be expressed in the currency of the 146 | contract, which should also be the currency of the commission class. If 147 | not provided, min commissions won't be calculated or enforced. 148 | 149 | Returns 150 | ------- 151 | DataFrame 152 | a DataFrame of commissions, expressed as percentages of account equity 153 | """ 154 | if cls.TIER_2_RATIO: 155 | broker_commission_rate = ( 156 | ((1 - cls.TIER_2_RATIO) * cls.BROKER_COMMISSION_RATE) 157 | + (cls.TIER_2_RATIO * cls.BROKER_COMMISSION_RATE_TIER_2) 158 | ) 159 | else: 160 | broker_commission_rate = cls.BROKER_COMMISSION_RATE 161 | 162 | broker_commissions = turnover * broker_commission_rate 163 | 164 | if nlvs is not None and cls.MIN_COMMISSION: 165 | broker_commissions = cls._enforce_min_commissions(broker_commissions, nlvs=nlvs) 166 | 167 | exchange_commissions = turnover * cls.EXCHANGE_FEE_RATE 168 | 169 | commissions = broker_commissions + exchange_commissions 170 | 171 | return commissions 172 | 173 | class NoCommission(PercentageCommission): 174 | """ 175 | Commission class for strategies that don't pay commissions. 176 | """ 177 | 178 | BROKER_COMMISSION_RATE: float = 0 179 | """the commission rate (as a percentage of trade value) charged by the broker""" 180 | EXCHANGE_FEE_RATE: float = 0 181 | """the exchange fee as a percentage of trade value""" 182 | MIN_COMMISSION: float = 0 183 | """the minimum commission charged by the broker.""" 184 | -------------------------------------------------------------------------------- /moonshot/commission/fut.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from typing import Any 16 | import pandas as pd 17 | from moonshot.commission.base import Commission, PercentageCommission 18 | 19 | class FuturesCommission(Commission): 20 | """ 21 | Base class for futures commissions. 22 | 23 | Parameters 24 | ---------- 25 | BROKER_COMMISSION_PER_CONTRACT : float 26 | the commission per contract 27 | 28 | EXCHANGE_FEE_PER_CONTRACT : float 29 | the exchange and regulatory fees per contract 30 | 31 | CARRYING_FEE_PER_CONTRACT : float 32 | the overnight carrying fee per contract (depends on equity in excess of 33 | margin requirement) 34 | 35 | Notes 36 | ----- 37 | Usage Guide: 38 | 39 | * Moonshot commissions and slippage: https://qrok.it/dl/ms/moonshot-commissions-slippage 40 | 41 | Examples 42 | -------- 43 | Example subclass for CME E-Mini commissions: 44 | 45 | >>> class CMEEquityEMiniFixedCommission(FuturesCommission): 46 | >>> BROKER_COMMISSION_PER_CONTRACT = 0.85 47 | >>> EXCHANGE_FEE_PER_CONTRACT = 1.18 48 | >>> CARRYING_FEE_PER_CONTRACT = 0 # Depends on equity in excess of margin requirement 49 | >>> 50 | >>> # then, use this on your strategy: 51 | >>> class MyEminiStrategy(Moonshot): 52 | >>> COMMISSION_CLASS = CMEEquityEMiniFixedCommission 53 | """ 54 | BROKER_COMMISSION_PER_CONTRACT: float = 0 55 | """the commission per contract""" 56 | EXCHANGE_FEE_PER_CONTRACT: float = 0 57 | """the exchange and regulatory fees per contract""" 58 | CARRYING_FEE_PER_CONTRACT: float = 0 59 | """the overnight carrying fee per contract (depends on equity in excess of 60 | margin requirement)""" 61 | 62 | @classmethod 63 | def get_commissions( 64 | cls, 65 | contract_values: pd.DataFrame, 66 | turnover: pd.DataFrame, 67 | **kwargs: Any 68 | ) -> pd.DataFrame: 69 | """ 70 | Return a DataFrame of commissions as percentages of account equity. 71 | """ 72 | cost_per_contract = cls.BROKER_COMMISSION_PER_CONTRACT + cls.EXCHANGE_FEE_PER_CONTRACT + cls.CARRYING_FEE_PER_CONTRACT 73 | 74 | # Express the commission as a percent of contract value 75 | commission_rates = float(cost_per_contract)/contract_values 76 | 77 | # Multipy the commission rates by the turnover 78 | commissions = commission_rates * turnover 79 | 80 | return commissions 81 | 82 | class DemoCMEEquityEMiniFixedCommission(FuturesCommission): 83 | """ 84 | Fixed commission for CME Equity E-Minis. 85 | """ 86 | BROKER_COMMISSION_PER_CONTRACT: float = 0.85 87 | EXCHANGE_FEE_PER_CONTRACT: float = 1.18 88 | CARRYING_FEE_PER_CONTRACT: float = 0 89 | 90 | class DemoCanadaCADFuturesTieredCommission(FuturesCommission): 91 | """ 92 | Tiered/Cost-Plus commission for Canada futures denominated in CAD, for US 93 | customers. 94 | """ 95 | 96 | BROKER_COMMISSION_PER_CONTRACT: float = 0.85 97 | EXCHANGE_FEE_PER_CONTRACT: float = ( 98 | 1.12 # exchange fee 99 | + 0.03 # regulatory fee 100 | + 0.01 # NFA assessment fee 101 | ) 102 | CARRYING_FEE_PER_CONTRACT: float = 0 103 | 104 | class DemoKoreaFuturesCommission(PercentageCommission): 105 | """ 106 | Fixed rate commission for Korea futures excluding stock futures. 107 | """ 108 | # 0.4 bps fixed rate, excludes stock futures and KWY futures (US dollar) 109 | 110 | BROKER_COMMISSION_RATE: float = 0.00004 111 | EXCHANGE_FEE_RATE: float = 0 112 | MIN_COMMISSION: float = 0 113 | 114 | class DemoKoreaStockFuturesCommission(PercentageCommission): 115 | """ 116 | Fixed rate commission for Korea stock futures. 117 | """ 118 | # 4 bps fixed rate for stock futures 119 | 120 | BROKER_COMMISSION_RATE: float = 0.0004 121 | EXCHANGE_FEE_RATE: float = 0 122 | MIN_COMMISSION: float = 0 123 | -------------------------------------------------------------------------------- /moonshot/commission/fx.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from moonshot.commission.base import PercentageCommission 16 | 17 | class SpotFXCommission(PercentageCommission): 18 | """ 19 | Commission class for spot FX. This class can be used as-is. 20 | 21 | Notes 22 | ----- 23 | Min commissions are not modeled for spot FX. This is because min 24 | commissions for spot FX are in USD ($2), regardless of the quote 25 | currency. The Moonshot class passes NLVs in the quote currency (the 26 | Currency field). To accurately model min commissions, these NLVs would need 27 | to be converted to USD. 28 | 29 | Usage Guide: 30 | 31 | * Moonshot commissions and slippage: https://qrok.it/dl/ms/moonshot-commissions-slippage 32 | 33 | Examples 34 | -------- 35 | Use this on your strategy: 36 | 37 | >>> class MyFXStrategy(Moonshot): 38 | >>> COMMISSION_CLASS = SpotFXCommission 39 | 40 | """ 41 | 42 | BROKER_COMMISSION_RATE: float = 0.00002 # 0.2 bps 43 | """the commission rate (as a percentage of trade value) charged by the broker""" 44 | EXCHANGE_FEE_RATE: float = 0 45 | """the exchange fee as a percentage of trade value""" 46 | MIN_COMMISSION: float = 0 47 | """NOTE: min commissions are not modeled for spot FX. This is because min 48 | commissions for spot FX are in USD ($2), regardless of the quote 49 | currency. The Moonshot class passes NLVs in the quote currency (the 50 | Currency field). To accurately model min commissions, these NLVs would need 51 | to be converted to USD.""" 52 | -------------------------------------------------------------------------------- /moonshot/commission/stk.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pandas as pd 16 | from moonshot.commission.base import Commission, PercentageCommission 17 | 18 | class PerShareCommission(Commission): 19 | """ 20 | Base class for commissions which are primarily based on the number of 21 | shares. 22 | 23 | This class can't be used directly but should be subclassed with the 24 | appropriate parameters. 25 | 26 | Parameters 27 | ---------- 28 | BROKER_COMMISSION_PER_SHARE : float, required 29 | the broker commission per share at the lowest volume tier 30 | 31 | BROKER_COMMISSION_PER_SHARE_TIER_2 : float, optional 32 | the broker commission per share at volume tier 2 33 | 34 | TIER_2_RATIO : float, optional 35 | ratio of monthly trades at volume tier 2 36 | 37 | EXCHANGE_FEE_PER_SHARE : float, optional 38 | the sum of all exchange fees which are assessed per share (excluding maker-taker 39 | fees, if defined separately) 40 | 41 | MAKER_FEE_PER_SHARE : float, optional 42 | the "maker" fee from the exchange for adding liquidity. Use a negative value 43 | to indicate a rebate 44 | 45 | TAKER_FEE_PER_SHARE : float, optional 46 | the "taker" fee paid to the exchange for removing liquidity 47 | 48 | MAKER_RATIO : float, optional 49 | the ratio of trades that earn the maker fee (for example if 75% of trades add 50 | liquidity and 25% remove liquidity, this value should be 0.75) 51 | 52 | PERCENTAGE_FEE_RATE : float, optional 53 | the sum of all fees which are assessed as a percentage of trade value 54 | 55 | COMMISSION_PERCENTAGE_FEE_RATE : float, optional 56 | the sum of all fees which are assessed as a percentage of the broker commission 57 | 58 | MIN_COMMISSION : float, optional 59 | the minimum commission charged by the broker. Only enforced if NLVs are passed 60 | by the backtest. 61 | 62 | Notes 63 | ----- 64 | Usage Guide: 65 | 66 | * Moonshot commissions and slippage: https://qrok.it/dl/ms/moonshot-commissions-slippage 67 | 68 | Examples 69 | -------- 70 | Example subclass for US stock comission with fixed pricing: 71 | 72 | >>> class USStockCommission(PerShareCommission): 73 | >>> BROKER_COMMISSION_PER_SHARE = 0.005 74 | >>> MIN_COMMISSION = 1.00 75 | >>> 76 | >>> # then, use this on your strategy: 77 | >>> class MyUSAStrategy(Moonshot): 78 | >>> COMMISSION_CLASS = USStockCommission 79 | 80 | Example subclass for US Cost-Plus stock comissions: 81 | 82 | >>> class CostPlusUSStockCommission(PerShareCommission): 83 | >>> BROKER_COMMISSION_PER_SHARE = 0.0035 84 | >>> EXCHANGE_FEE_PER_SHARE = (0.0002 # clearing fee per share 85 | >>> + (0.000119/2)) # FINRA activity fee (per share sold) 86 | >>> MAKER_FEE_PER_SHARE = -0.002 # exchange rebate (varies) 87 | >>> TAKER_FEE_PER_SHARE = 0.00118 # exchange fee (varies) 88 | >>> MAKER_RATIO = 0.25 # assume 25% of our trades add liquidity, 75% take liquidity 89 | >>> COMMISSION_PERCENTAGE_FEE_RATE = (0.000175 # NYSE pass-through (% of broker commission) 90 | >>> + 0.00056) # FINRA pass-through (% of broker commission) 91 | >>> PERCENTAGE_FEE_RATE = 0.0000231 # Transaction fees 92 | >>> MIN_COMMISSION = 0.35 93 | >>> 94 | >>> # then, use this on your strategy: 95 | >>> class MyUSAStrategy(Moonshot): 96 | >>> COMMISSION_CLASS = CostPlusUSStockCommission 97 | """ 98 | 99 | BROKER_COMMISSION_PER_SHARE: float = None 100 | """the broker commission per share at the lowest volume tier""" 101 | BROKER_COMMISSION_PER_SHARE_TIER_2: float = None 102 | """the broker commission per share at volume tier 2""" 103 | TIER_2_RATIO: float = 0 104 | """ratio of monthly trades at volume tier 2""" 105 | EXCHANGE_FEE_PER_SHARE: float = 0 106 | """the sum of all exchange fees which are assessed per share (excluding maker-taker 107 | fees, if defined separately)""" 108 | MAKER_FEE_PER_SHARE: float = 0 109 | """the "maker" fee from the exchange for adding liquidity. Use a negative value 110 | to indicate a rebate""" 111 | TAKER_FEE_PER_SHARE: float = 0 112 | """the "taker" fee paid to the exchange for removing liquidity""" 113 | MAKER_RATIO: float = 0 114 | """the ratio of trades that earn the maker fee (for example if 75% of trades add 115 | liquidity and 25% remove liquidity, this value should be 0.75)""" 116 | PERCENTAGE_FEE_RATE: float = 0 117 | """the sum of all fees which are assessed as a percentage of trade value""" 118 | COMMISSION_PERCENTAGE_FEE_RATE: float = 0 119 | """the sum of all fees which are assessed as a percentage of the broker commission""" 120 | MIN_COMMISSION: float = 0 121 | """the minimum commission charged by the broker. Only enforced if NLVs are passed 122 | by the backtest.""" 123 | 124 | @classmethod 125 | def get_commissions( 126 | cls, 127 | contract_values: pd.DataFrame, 128 | turnover: pd.DataFrame, 129 | nlvs: pd.DataFrame = None 130 | ) -> pd.DataFrame: 131 | """ 132 | Returns a DataFrame of commissions. 133 | 134 | 135 | Parameters 136 | ---------- 137 | contract_values : DataFrame, required 138 | a DataFrame of contract values (price * multipler / price_magnifier) 139 | 140 | turnover : DataFrame of floats, required 141 | a DataFrame of turnover, expressing the percentage of account equity that 142 | is turning over 143 | 144 | nlvs : DataFrame of nlvs, optional 145 | a DataFrame of NLVs (account balance), which is used to calculate and 146 | enforce min commissions. NLVs should be expressed in the currency of the 147 | contract, which should also be the currency of the commission class. If 148 | not provided, min commissions won't be calculated or enforced. 149 | 150 | Returns 151 | ------- 152 | DataFrame 153 | a DataFrame of commissions, expressed as percentages of account equity 154 | """ 155 | taker_ratio = 1 - cls.MAKER_RATIO 156 | exchange_fee_per_share = cls.EXCHANGE_FEE_PER_SHARE + (cls.MAKER_RATIO * cls.MAKER_FEE_PER_SHARE) + (taker_ratio * cls.TAKER_FEE_PER_SHARE) 157 | 158 | # Calculate commissions as a percent of the share price. 159 | if cls.TIER_2_RATIO: 160 | broker_commission_per_share = ( 161 | ((1 - cls.TIER_2_RATIO) * cls.BROKER_COMMISSION_PER_SHARE) 162 | + (cls.TIER_2_RATIO * cls.BROKER_COMMISSION_PER_SHARE_TIER_2) 163 | ) 164 | else: 165 | broker_commission_per_share = cls.BROKER_COMMISSION_PER_SHARE 166 | 167 | commission_per_share_with_fees = broker_commission_per_share * (1 + cls.COMMISSION_PERCENTAGE_FEE_RATE) 168 | 169 | # Note: we take abs() of contract_values because combos can have 170 | # negative prices which would cause a negative commission rate 171 | broker_commission_rates = float(broker_commission_per_share)/contract_values.where(contract_values != 0).abs() 172 | 173 | # Multiply the commissions by the turnover. 174 | broker_commissions = broker_commission_rates * turnover 175 | 176 | if nlvs is not None and cls.MIN_COMMISSION: 177 | broker_commissions = cls._enforce_min_commissions(broker_commissions, nlvs=nlvs) 178 | 179 | share_based_exchange_fee_rates = exchange_fee_per_share/contract_values.where(contract_values != 0).abs() 180 | share_based_exchange_fees = share_based_exchange_fee_rates * turnover 181 | 182 | value_based_fees = cls.PERCENTAGE_FEE_RATE * turnover 183 | 184 | commission_based_fees = cls.COMMISSION_PERCENTAGE_FEE_RATE * broker_commissions 185 | 186 | commissions = broker_commissions + share_based_exchange_fees + value_based_fees + commission_based_fees 187 | 188 | return commissions 189 | 190 | class DemoUSStockCommission(PerShareCommission): 191 | 192 | BROKER_COMMISSION_PER_SHARE: float = 0.005 193 | MIN_COMMISSION: float = 1.00 194 | 195 | class DemoCostPlusUSStockCommission(PerShareCommission): 196 | 197 | BROKER_COMMISSION_PER_SHARE: float = 0.0035 198 | EXCHANGE_FEE_PER_SHARE: float = (0.0002 # clearing fee per share 199 | + (0.000119/2)) # FINRA activity fee (per share sold) 200 | MAKER_FEE_PER_SHARE: float = -0.002 # exchange rebate (varies) 201 | TAKER_FEE_PER_SHARE: float = 0.00118 # exchange fee (varies) 202 | MAKER_RATIO: float = 0 203 | COMMISSION_PERCENTAGE_FEE_RATE: float = (0.000175 # NYSE pass-through (% of broker commission) 204 | + 0.00056) # FINRA pass-through (% of broker commission) 205 | PERCENTAGE_FEE_RATE: float = 0.0000231 # Transaction fees 206 | MIN_COMMISSION: float = 0.35 207 | 208 | 209 | class DemoCostPlusCanadaStockCommission(PerShareCommission): 210 | 211 | BROKER_COMMISSION_PER_SHARE: float = 0.008 212 | EXCHANGE_FEE_PER_SHARE: float = ( 213 | 0.00017 # clearing fee per share 214 | + 0.00011 # transaction fee per share 215 | ) 216 | MAKER_FEE_PER_SHARE: float = -0.0019 # varies 217 | TAKER_FEE_PER_SHARE: float = 0.003 # varies 218 | MAKER_RATIO: float = 0 219 | MIN_COMMISSION: float = 1.00 220 | TRANSACTION_FEE_RATE: float = 0 221 | 222 | class DemoAustraliaStockCommission(PercentageCommission): 223 | 224 | BROKER_COMMISSION_RATE: float = 0.0008 225 | EXCHANGE_FEE_RATE: float = 0 226 | MIN_COMMISSION: float = 5.00 227 | 228 | class DemoFranceStockCommission(PercentageCommission): 229 | 230 | BROKER_COMMISSION_RATE: float = 0.0008 231 | EXCHANGE_FEE_RATE: float = 0.000095 # 0.95 bps exchange fee 232 | MIN_COMMISSION: float = 1.25 # EUR 233 | 234 | class DemoGermanyStockCommission(PercentageCommission): 235 | 236 | BROKER_COMMISSION_RATE: float = 0.0008 237 | EXCHANGE_FEE_RATE: float = 0.000048 + 0.00001 # 0.48 bps exchange fee + 0.1 bps clearing fee 238 | MIN_COMMISSION: float = 1.25 # EUR 239 | 240 | class DemoHongKongStockCommission(PercentageCommission): 241 | 242 | BROKER_COMMISSION_RATE: float = 0.0008 243 | EXCHANGE_FEE_RATE: float = ( 244 | 0.00005 # exchange fee 245 | + 0.00002 # clearing fee (2 HKD min) 246 | + 0.001 # Stamp duty 247 | + 0.000027 # SFC Transaction Levy 248 | ) 249 | MIN_COMMISSION: float = 18.00 # HKD 250 | 251 | class DemoJapanStockCommission(PercentageCommission): 252 | 253 | BROKER_COMMISSION_RATE: float = 0.0005 254 | EXCHANGE_FEE_RATE: float = 0.000004 255 | MIN_COMMISSION: float = 80.00 # JPY 256 | 257 | class DemoMexicoStockCommission(PercentageCommission): 258 | 259 | BROKER_COMMISSION_RATE: float = 0.0010 260 | EXCHANGE_FEE_RATE: float = 0 261 | MIN_COMMISSION: float = 60.00 # MXN 262 | 263 | class DemoSingaporeStockCommission(PercentageCommission): 264 | 265 | BROKER_COMMISSION_RATE: float = 0.0008 266 | EXCHANGE_FEE_RATE: float = 0.00034775 + 0.00008025 # transaction fee + access fee 267 | MIN_COMMISSION: float = 2.50 # SGD 268 | 269 | class DemoUKStockCommission(PercentageCommission): 270 | 271 | BROKER_COMMISSION_RATE: float = 0.0008 272 | EXCHANGE_FEE_RATE: float = 0.000045 + 0.0025 # 0.45 bps + 0.5% stamp tax on purchases > 1000 GBP 273 | MIN_COMMISSION: float = 1.00 # GBP 274 | -------------------------------------------------------------------------------- /moonshot/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | class MoonshotError(Exception): 16 | pass 17 | 18 | class MoonshotParameterError(MoonshotError): 19 | pass -------------------------------------------------------------------------------- /moonshot/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .weight import WeightAllocationMixin 16 | 17 | __all__ = ["WeightAllocationMixin"] 18 | -------------------------------------------------------------------------------- /moonshot/mixins/weight.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pandas as pd 16 | import numpy as np 17 | 18 | class WeightAllocationMixin(object): 19 | """ 20 | Mixin class with utilities for turning signals into weights. 21 | """ 22 | def allocate_equal_weights( 23 | self, 24 | signals: pd.DataFrame, 25 | cap: float = 1.0 26 | ) -> pd.DataFrame: 27 | """ 28 | For multi-security strategies. Given a dataframe of whole number 29 | signals (-1, 0, 1), reduces the position size so that the absolute 30 | sum of all weights is never greater than the cap. 31 | """ 32 | # Count active signals for the day 33 | signals_count = signals.abs().sum(axis=1) 34 | # If no signals, divide by 1 to leave the signal as-is (can't divide by 0) 35 | divisor = np.where(signals_count != 0, signals_count, 1) 36 | return signals.div(divisor, axis=0) * cap / 1.0 37 | 38 | def allocate_fixed_weights( 39 | self, 40 | signals: pd.DataFrame, 41 | weight: float 42 | ) -> pd.DataFrame: 43 | """ 44 | Applies the specified fixed weight to the signals. 45 | """ 46 | return signals * weight 47 | 48 | def allocate_fixed_weights_capped( 49 | self, 50 | signals: pd.DataFrame, 51 | weight: float, 52 | cap: float = 1.0 53 | ) -> pd.DataFrame: 54 | """ 55 | Applies fixed weights, but if the sum of the weights exceeds the cap, 56 | applies equal weights. 57 | """ 58 | equal_weighted = self.allocate_equal_weights(signals, cap=cap) 59 | fixed_weighted = self.allocate_fixed_weights(signals, weight) 60 | fixed_sum = fixed_weighted.abs().sum(axis=1) 61 | fixed_sum = pd.DataFrame(dict( 62 | [(column, fixed_sum.copy()) for column in signals.columns]), 63 | columns=signals.columns, index=signals.index) 64 | return pd.DataFrame( 65 | np.where(fixed_sum > cap, equal_weighted, fixed_weighted), 66 | index=signals.index, columns=signals.columns) 67 | 68 | def allocate_market_neutral_fixed_weights_capped( 69 | self, 70 | signals: pd.DataFrame, 71 | weight: float, 72 | cap: float = 1.0, 73 | neutralize_weights: bool = True 74 | ) -> pd.DataFrame: 75 | """ 76 | Applies fixed capped weights to the long and short side separately to 77 | ensure the strategy is hedged. 78 | """ 79 | long_signals = signals.where(signals > 0, 0) 80 | short_signals = signals.where(signals < 0, 0) 81 | cap_per_side = cap * 0.5 82 | long_weights = self.allocate_fixed_weights_capped(long_signals, weight, cap=cap_per_side) 83 | short_weights = self.allocate_fixed_weights_capped(short_signals, weight, cap=cap_per_side) 84 | weights = long_weights.where(long_weights > 0, short_weights) 85 | if neutralize_weights: 86 | weights = self.neutralize_weights(weights) 87 | return weights 88 | 89 | def neutralize_weights(self, weights: pd.DataFrame) -> pd.DataFrame: 90 | """ 91 | If the long or short side has a greater total weight than the 92 | opposite side, proportionately reduces the overweight side. 93 | """ 94 | long_weights = weights.where(weights > 0, 0) 95 | short_weights = weights.where(weights < 0, 0) 96 | 97 | total_long_weights = long_weights.sum(axis=1) 98 | total_long_weights = pd.DataFrame(dict((column, total_long_weights.copy()) for column in weights.columns), 99 | index=weights.index, columns=weights.columns) 100 | total_short_weights = short_weights.abs().sum(axis=1) 101 | total_short_weights = pd.DataFrame(dict((column, total_short_weights.copy()) for column in weights.columns), 102 | index=weights.index, columns=weights.columns) 103 | 104 | long_weights = long_weights.where( 105 | total_long_weights <= total_short_weights, 106 | long_weights * total_short_weights / total_long_weights.replace(0, 1)) 107 | 108 | short_weights = short_weights.where( 109 | total_short_weights <= total_long_weights, 110 | short_weights * total_long_weights / total_short_weights.replace(0, 1)) 111 | 112 | weights = long_weights.where(long_weights > 0, short_weights) 113 | return weights 114 | -------------------------------------------------------------------------------- /moonshot/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantrocket-llc/moonshot/2c0fef496ff94f275c85da1da49154a6c42fcfb2/moonshot/py.typed -------------------------------------------------------------------------------- /moonshot/slippage/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Moonshot slippage classes. 3 | 4 | Classes 5 | ------- 6 | Slippage 7 | Base class for slippage classes. This class must be subclassed 8 | to be used. 9 | 10 | FixedSlippage 11 | Apply a fixed pct slippage to each trade. 12 | 13 | IBKRBorrowFees 14 | Apply borrow fees to each short position. 15 | 16 | Notes 17 | ----- 18 | Usage Guide: 19 | 20 | * Moonshot commissions and slippage: https://qrok.it/dl/ms/moonshot-commissions-slippage 21 | 22 | """ 23 | 24 | from .base import Slippage 25 | from .fixed import FixedSlippage 26 | from .borrowfee import IBKRBorrowFees 27 | 28 | __all__ = [ 29 | 'Slippage', 30 | 'FixedSlippage', 31 | 'IBKRBorrowFees', 32 | ] -------------------------------------------------------------------------------- /moonshot/slippage/base.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pandas as pd 16 | 17 | class Slippage: 18 | """ 19 | Base class for slippage classes. 20 | 21 | A slippage class must implement a method called `get_slippage` that 22 | accepts a DataFrame of turnover, a DataFrame of positions, and a 23 | DataFrame of prices, and returns a DataFrame of slippage. 24 | """ 25 | def get_slippage( 26 | self, 27 | turnover: pd.DataFrame, 28 | positions: pd.DataFrame, 29 | prices: pd.DataFrame 30 | ) -> pd.DataFrame: 31 | """ 32 | Apply slippage to each trade. 33 | 34 | Parameters 35 | ---------- 36 | turnover : DataFrame, required 37 | a DataFrame of turnover 38 | 39 | positions : DataFrame, required 40 | a DataFrame of positions 41 | 42 | prices : DataFrame, required 43 | a DataFrame of prices 44 | 45 | Returns 46 | ------- 47 | DataFrame 48 | a DataFrame of slippages 49 | """ 50 | raise NotImplementedError() 51 | -------------------------------------------------------------------------------- /moonshot/slippage/borrowfee.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .base import Slippage 16 | import pandas as pd 17 | from quantrocket.fundamental import get_ibkr_borrow_fees_reindexed_like 18 | 19 | class IBKRBorrowFees(Slippage): 20 | """ 21 | Apply borrow fees to each short position. 22 | 23 | Notes 24 | ----- 25 | Usage Guide: 26 | 27 | * Moonshot borrow fees: https://qrok.it/dl/ms/moonshot-borrow-fees 28 | 29 | Examples 30 | -------- 31 | Use this on your strategy: 32 | 33 | >>> class MyStrategy(Moonshot): 34 | >>> SLIPPAGE_CLASSES = IBKRBorrowFees 35 | """ 36 | 37 | def get_slippage( 38 | self, 39 | turnover: pd.DataFrame, 40 | positions: pd.DataFrame, 41 | prices: pd.DataFrame 42 | ) -> pd.DataFrame: 43 | 44 | borrow_fees = get_ibkr_borrow_fees_reindexed_like(positions) 45 | 46 | # convert to decimals 47 | borrow_fees = borrow_fees / 100 48 | # convert to daily rates 49 | daily_borrow_fees = borrow_fees / 360 # industry convention is to divide annual fee by 360, not 365 50 | 51 | # account for weekends, which are assessed the borrow fee x 3 days 52 | dates = borrow_fees.apply(lambda x: borrow_fees.index) 53 | days_held = (dates - dates.shift()).fillna(pd.Timedelta('1d')).apply(lambda x: x.dt.days) 54 | daily_borrow_fees *= days_held 55 | 56 | # by industry convention, collateral amount is 102% of borrow amount 57 | assessed_fees = positions.where(positions < 0, 0).abs() * 1.02 * daily_borrow_fees 58 | 59 | return assessed_fees 60 | -------------------------------------------------------------------------------- /moonshot/slippage/fixed.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .base import Slippage 16 | import pandas as pd 17 | 18 | class FixedSlippage(Slippage): 19 | """ 20 | Apply a fixed pct slippage to each trade. 21 | 22 | This slippage class can be used on strategies indirectly (and more 23 | easily) by simply specifying SLIPPAGE_BPS on the strategy. 24 | 25 | Parameters 26 | ---------- 27 | ONE_WAY_SLIPPAGE : float 28 | the slippage to apply to each trade (default 0.0005 = 5 basis points); 29 | overridden if `one_way_slippage` is passed to __init__ 30 | 31 | Notes 32 | ----- 33 | Usage Guide: 34 | 35 | * Moonshot commissions and slippage: https://qrok.it/dl/ms/moonshot-commissions-slippage 36 | """ 37 | ONE_WAY_SLIPPAGE = 0.0005 38 | 39 | def __init__(self, one_way_slippage: float = None): 40 | if one_way_slippage is not None: 41 | self.one_way_slippage = one_way_slippage 42 | else: 43 | self.one_way_slippage = self.ONE_WAY_SLIPPAGE 44 | 45 | def get_slippage( 46 | self, 47 | turnover: pd.DataFrame, 48 | *args, 49 | **kwargs 50 | ) -> pd.DataFrame: 51 | """ 52 | Apply the fix pct slippage to each trade. 53 | 54 | Parameters 55 | ---------- 56 | turnover : DataFrame, required 57 | a DataFrame of turnover 58 | 59 | Returns 60 | ------- 61 | DataFrame 62 | slippages 63 | """ 64 | return turnover * self.one_way_slippage 65 | -------------------------------------------------------------------------------- /moonshot/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .base import Moonshot 16 | from .ml import MoonshotML 17 | -------------------------------------------------------------------------------- /moonshot/strategies/ml.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket LLC - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pickle 16 | from typing import Union, Any 17 | try: 18 | import joblib 19 | except ImportError: 20 | pass 21 | import pandas as pd 22 | import numpy as np 23 | from moonshot.strategies.base import Moonshot 24 | from moonshot.exceptions import MoonshotError, MoonshotParameterError 25 | from moonshot._cache import Cache 26 | 27 | class MoonshotML(Moonshot): 28 | """ 29 | Base class for Moonshot machine learning strategies. 30 | 31 | To create a strategy, subclass this class. Implement your trading logic in the class 32 | methods, and store your strategy parameters as class attributes. 33 | 34 | Class attributes include built-in Moonshot parameters which you can override, as well 35 | as your own custom parameters. 36 | 37 | To run a backtest, at minimum you must implement `prices_to_features` and 38 | `predictions_to_signals`, but in general you will want to implement the 39 | following methods (which are called in the order shown): 40 | 41 | `prices_to_features` -> `predictions_to_signals` -> `signals_to_target_weights` -> `target_weights_to_positions` -> `positions_to_gross_returns` 42 | 43 | To trade (i.e. generate orders intended to be placed, but actually placed by other services 44 | than Moonshot), you must also implement `order_stubs_to_orders`. Order generation for trading 45 | follows the path shown below: 46 | 47 | `prices_to_features` -> `predictions_to_signals` -> `signals_to_target_weights` -> `order_stubs_to_orders` 48 | 49 | Parameters 50 | ---------- 51 | CODE : str, required 52 | the strategy code 53 | 54 | MODEL : str, optional 55 | path of machine learning model to load (for scikit-learn models, a joblib or 56 | pickle file); alternatively model can be passed as a parameter to backtest 57 | method, in which case the MODEL parameter is ignored 58 | 59 | DB : str, required 60 | code of db to pull data from 61 | 62 | DB_FIELDS : str or list of str, optional 63 | fields to retrieve from db (defaults to ["Open", "Close", "Volume"]) 64 | 65 | DB_TIMES : str or list of str (HH:MM:SS), optional 66 | for intraday databases, only retrieve these times 67 | 68 | DB_DATA_FREQUENCY : str, optional 69 | Only applicable when DB specifies a Zipline bundle. Whether to query minute or 70 | daily data. If omitted, defaults to minute data for minute bundles and to daily 71 | data for daily bundles. This parameter only needs to be set to request daily data 72 | from a minute bundle. Possible choices: daily, minute (or aliases d, m). 73 | 74 | SIDS : str or list of str, optional 75 | limit db query to these sids 76 | 77 | UNIVERSES : str or list of str, optional 78 | limit db query to these universes 79 | 80 | EXCLUDE_SIDS : str or list of str, optional 81 | exclude these sids from db query 82 | 83 | EXCLUDE_UNIVERSES : str or list of str, optional 84 | exclude these universes from db query 85 | 86 | CONT_FUT : str, optional 87 | pass this cont_fut option to db query (default None) 88 | 89 | LOOKBACK_WINDOW : int, optional 90 | get this many days additional data prior to the backtest start date or 91 | trade date to account for rolling windows. If set to None (the default), 92 | will use the largest value of any attributes ending with `*_WINDOW`, or 93 | 252 if no such attributes, and will further pad window based on any 94 | `*_INTERVAL` attributes, which are interpreted as pandas offset aliases 95 | (for example `REBALANCE_INTERVAL = 'Q'`). Set to 0 to disable. 96 | 97 | NLV : dict, optional 98 | dict of currency:NLV for each currency represented in the strategy. Can 99 | alternatively be passed directly to backtest method. 100 | 101 | COMMISSION_CLASS : Class or dict of (sectype,exchange,currency):Class, optional 102 | the commission class to use. If strategy includes a mix of security types, 103 | exchanges, or currencies, you can pass a dict mapping tuples of 104 | (sectype,exchange,currency) to the different commission classes. By default 105 | no commission is applied. 106 | 107 | SLIPPAGE_CLASSES : iterable of slippage classes, optional 108 | one or more slippage classes. By default no slippage is applied. 109 | 110 | SLIPPAGE_BPS : float, optional 111 | amount on one-slippage to apply to each trade in BPS (for example, enter 5 to deduct 112 | 5 BPS) 113 | 114 | BENCHMARK : str, optional 115 | the sid of a security in the historical data to use as the benchmark 116 | 117 | BENCHMARK_DB : str, optional 118 | the database containing the benchmark, if different from DB. BENCHMARK_DB 119 | should contain end-of-day data, not intraday (but can be used with intraday 120 | backtests). 121 | 122 | BENCHMARK_TIME : str (HH:MM:SS), optional 123 | use prices from this time of day as benchmark prices. Only applicable if 124 | benchmark prices originate in DB (not BENCHMARK_DB), DB contains intraday 125 | data, and backtest results are daily. 126 | 127 | TIMEZONE : str, optional 128 | convert timestamps to this timezone (if not provided, will be inferred 129 | from securities universe if possible) 130 | 131 | CALENDAR : str, optional 132 | use this exchange's trading calendar to determine which date's signals 133 | should be used for live trading. If the exchange is currently open, 134 | today's signals will be used. If currently closed, the signals corresponding 135 | to the last date the exchange was open will be used. If no calendar is specified, 136 | today's signals will be used. 137 | 138 | POSITIONS_CLOSED_DAILY : bool 139 | if True, positions in backtests that fall on adjacent days are assumed to 140 | be closed out and reopened each day rather than held continuously; this 141 | impacts commission and slippage calculations (default is False, meaning 142 | adjacent positions are assumed to be held continuously) 143 | 144 | ALLOW_REBALANCE: bool or float 145 | in live trading, whether to allow rebalancing of existing positions that 146 | are already on the correct side. If True (the default), allow rebalancing. 147 | If False, no rebalancing. If set to a positive decimal, allow rebalancing 148 | only when the existing position differs from the target position by at least 149 | this percentage. For example 0.5 means don't rebalance a position unless 150 | the position will change by +/-50%. 151 | 152 | CONTRACT_VALUE_REFERENCE_FIELD : str, optional 153 | the price field to use for determining contract values for the purpose of 154 | applying commissions and constraining weights in backtests and calculating 155 | order quantities in trading. Defaults to the first available of Close, Open, 156 | MinuteCloseClose, SecondCloseClose, LastPriceClose, BidPriceClose, AskPriceClose, 157 | TimeSalesLastPriceClose, TimeSalesFilteredLastPriceClose, LastPriceMean, 158 | BidPriceMean, AskPriceMean, TimeSalesLastPriceMean, TimeSalesFilteredLastPriceMean, 159 | MinuteOpenOpen, SecondOpenOpen, LastPriceOpen, BidPriceOpen, AskPriceOpen, 160 | TimeSalesLastPriceOpen, TimeSalesFilteredLastPriceOpen. 161 | 162 | ACCOUNT_BALANCE_FIELD : str or list of str, optional 163 | the account field to use for calculating order quantities as a percentage of 164 | account equity. Applies to trading only, not backtesting. Default is 165 | NetLiquidation. If a list of fields is provided, the minimum value is used. 166 | For example, ['NetLiquidation', 'PreviousEquity'] means to use the lesser of 167 | NetLiquidation or PreviousEquity to determine order quantities. 168 | 169 | Notes 170 | ----- 171 | Usage Guide: 172 | 173 | * MoonshotML: https://qrok.it/dl/ms/moonshot-ml 174 | 175 | Examples 176 | -------- 177 | Example of a minimal strategy that runs on a history db called "usa-stk-1d", trains 178 | the model using the 1-day and 2-day returns, and buys when the machine learning 179 | model predicts a positive 1-day forward return:: 180 | 181 | import pandas as pd 182 | 183 | class DemoMLStrategy(MoonshotML): 184 | 185 | CODE = "demo-ml" 186 | DB = "usa-stk-1d" 187 | MODEL = "my_ml_model.pkl" 188 | 189 | def prices_to_features(self, prices: pd.DataFrame): 190 | closes = prices.loc["Close"] 191 | features = {} 192 | features["returns_1d"]= closes.pct_change() 193 | features["returns_2d"] = (closes - closes.shift(2)) / closes.shift(2) 194 | targets = closes.pct_change().shift(-1) 195 | return features, targets 196 | 197 | def predictions_to_signals(self, predictions: pd.DataFrame, prices: pd.DataFrame): 198 | signals = predictions > 0 199 | return signals.astype(int) 200 | """ 201 | 202 | MODEL: str = None 203 | """path of machine learning model to load (for scikit-learn models, a joblib or 204 | pickle file); alternatively model can be passed as a parameter to backtest 205 | method, in which case the MODEL parameter is ignored""" 206 | 207 | def __init__(self, *args, **kwargs): 208 | super(MoonshotML, self).__init__(*args, **kwargs) 209 | self.model = None 210 | 211 | def _load_model(self): 212 | """ 213 | Loads a model from file, either using joblib or pickle or keras. 214 | """ 215 | if not self.MODEL: 216 | raise MoonshotParameterError("please specify a model file") 217 | 218 | if "joblib" in self.MODEL: 219 | self.model = joblib.load(self.MODEL) 220 | elif "keras.h5" in self.MODEL: 221 | from keras.models import load_model 222 | self.model = load_model(self.MODEL) 223 | else: 224 | with open(self.MODEL, "rb") as f: 225 | self.model = pickle.load(f) 226 | 227 | def prices_to_features( 228 | self, 229 | prices: pd.DataFrame 230 | ) -> tuple[ 231 | Union[ 232 | list[Union[pd.DataFrame, 'pd.Series[float]']], 233 | dict[str, Union[pd.DataFrame, 'pd.Series[float]']] 234 | ], 235 | Union[pd.DataFrame, 'pd.Series[float]']]: 236 | """ 237 | From a DataFrame of prices, return a tuple of features and targets to be 238 | provided to the machine learning model. 239 | 240 | The returned features can be a list or dict of DataFrames, where each 241 | DataFrame is a feature and should have the same shape, with a Date or 242 | (Date, Time) index and sids as columns. (Moonshot will convert the 243 | DataFrames to the format expected by the machine learning model). 244 | 245 | Alternatively, a list or dict of Series can be provided, which is 246 | suitable if using multiple securities to make predictions for a 247 | single security (for example, an index). 248 | 249 | The returned targets should be a DataFrame or Series with an index 250 | matching the index of the features DataFrames or Series. Targets are 251 | used in training and are ignored for prediction. (Model training is 252 | not handled by the MoonshotML class.) Alternatively return None if 253 | using an already trained model. 254 | 255 | Must be implemented by strategy subclasses. 256 | 257 | Parameters 258 | ---------- 259 | prices : DataFrame, required 260 | multiindex (Field, Date) or (Field, Date, Time) DataFrame of 261 | price/market data 262 | 263 | Returns 264 | ------- 265 | tuple of (dict or list of DataFrames or Series, and DataFrame or Series) 266 | features and targets 267 | 268 | Notes 269 | ----- 270 | Usage Guide: 271 | 272 | * MoonshotML: https://qrok.it/dl/ms/moonshot-ml 273 | 274 | Examples 275 | -------- 276 | Predict next-day returns based on 1-day and 2-day returns:: 277 | 278 | def prices_to_features(self, prices: pd.DataFrame): 279 | closes = prices.loc["Close"] 280 | features = {} 281 | features["returns_1d"]= closes.pct_change() 282 | features["returns_2d"] = (closes - closes.shift(2)) / closes.shift(2) 283 | targets = closes.pct_change().shift(-1) 284 | return features, targets 285 | 286 | Predict next-day returns for a single security in the prices 287 | DataFrame using another security's returns:: 288 | 289 | def prices_to_features(self, prices: pd.DataFrame): 290 | closes = prices.loc["Close"] 291 | closes_to_predict = closes[12345] 292 | closes_to_predict_with = closes[23456] 293 | features = {} 294 | features["returns_1d"]= closes_to_predict_with.pct_change() 295 | features["returns_2d"] = (closes_to_predict_with - closes_to_predict_with.shift(2)) / closes_to_predict_with.shift(2) 296 | targets = closes_to_predict.pct_change().shift(-1) 297 | return features, targets 298 | """ 299 | raise NotImplementedError("strategies must implement prices_to_features") 300 | 301 | def predictions_to_signals( 302 | self, 303 | predictions: Union[pd.DataFrame, 'pd.Series[float]'], 304 | prices: pd.DataFrame 305 | ) -> pd.DataFrame: 306 | """ 307 | From a DataFrame of predictions produced by a machine learning model, 308 | return a DataFrame of signals. By convention, signals should be 309 | 1=long, 0=cash, -1=short. 310 | 311 | The index of predictions will match the index of the features 312 | DataFrames or Series returned in prices_to_features. 313 | 314 | Must be implemented by strategy subclasses. 315 | 316 | Parameters 317 | ---------- 318 | predictions : DataFrame or Series, required 319 | DataFrame of machine learning predictions 320 | 321 | prices : DataFrame, required 322 | multiindex (Field, Date) or (Field, Date, Time) DataFrame of 323 | price/market data 324 | 325 | Returns 326 | ------- 327 | DataFrame 328 | signals 329 | 330 | Notes 331 | ----- 332 | Usage Guide: 333 | 334 | * MoonshotML: https://qrok.it/dl/ms/moonshot-ml 335 | 336 | Examples 337 | -------- 338 | Buy when prediction (a DataFrame) is above zero:: 339 | 340 | def predictions_to_signals(self, predictions: pd.DataFrame, prices: pd.DataFrame): 341 | signals = predictions > 0 342 | return signals.astype(int) 343 | 344 | Buy a single security when the predictions (a Series) is above zero:: 345 | 346 | def predictions_to_signals(self, predictions: pd.Series, prices: pd.DataFrame): 347 | closes = prices.loc["Close"] 348 | signals = pd.DataFrame(False, index=closes.index, columns=closes.columns) 349 | signals[12345] = predictions > 0 350 | return signals.astype(int) 351 | """ 352 | raise NotImplementedError("strategies must implement predictions_to_signals") 353 | 354 | def backtest( 355 | self, 356 | model: Any = None, 357 | start_date: str = None, 358 | end_date: str = None, 359 | nlv: dict[str, float] = None, 360 | allocation: float = 1.0, 361 | label_sids: bool = False, 362 | no_cache: bool = False 363 | ) -> pd.DataFrame: 364 | """ 365 | Backtest a strategy and return a DataFrame of results. 366 | 367 | Parameters 368 | ---------- 369 | model : object, optional 370 | machine learning model to use for predictions; if not specified, 371 | model will be loaded from file based on MODEL class attribute 372 | 373 | start_date : str (YYYY-MM-DD), optional 374 | the backtest start date (default is to include all history in db) 375 | 376 | end_date : str (YYYY-MM-DD), optional 377 | the backtest end date (default is to include all history in db) 378 | 379 | nlv : dict 380 | dict of currency:nlv. Should contain a currency:nlv pair for 381 | each currency represented in the strategy 382 | 383 | allocation : float 384 | how much to allocate to the strategy 385 | 386 | label_sids : bool 387 | replace with () in columns in output 388 | for better readability (default True) 389 | 390 | no_cache : bool 391 | don't use cached files even if available. Using cached files speeds 392 | up backtests but may be undesirable if underlying data has changed. 393 | See http://qrok.it/h/mcache to learn more about caching in Moonshot. 394 | 395 | Returns 396 | ------- 397 | DataFrame 398 | multiindex (Field, Date) or (Field, Date, Time) DataFrame of 399 | backtest results 400 | """ 401 | 402 | if model: 403 | self.model = model 404 | else: 405 | self._load_model() 406 | 407 | return super(MoonshotML, self).backtest( 408 | start_date=start_date, end_date=end_date, nlv=nlv, 409 | allocation=allocation, label_sids=label_sids, 410 | no_cache=no_cache) 411 | 412 | def _prices_to_signals(self, prices, no_cache=False): 413 | """ 414 | Converts a prices DataFrame to a DataFrame of signals, by: 415 | 416 | - converting prices to features 417 | - using the ML model to create predictions from the features 418 | - creating signals from the predictions 419 | """ 420 | features = None 421 | 422 | # serve features from cache in backtests if possible. The features are cached 423 | # based on the index and columns of prices. If this file has been 424 | # edited more recently than the features were cached, the cache is 425 | # not used. 426 | cache_key = [self.CODE, prices.index.tolist(), prices.columns.tolist()] 427 | if self.is_backtest and not no_cache: 428 | features = Cache.get(cache_key, prefix="_features", unless_file_modified=self) 429 | 430 | if features is None: 431 | features = self.prices_to_features(prices) 432 | if self.is_backtest: 433 | Cache.set(cache_key, features, prefix="_features") 434 | 435 | # validate features 436 | if not isinstance(features, tuple) or len(features) != 2: 437 | raise MoonshotError("prices_to_features should return a tuple of (features, targets)") 438 | 439 | features, targets = features 440 | 441 | # Don't use the targets/labels for predictions 442 | del targets 443 | 444 | if not isinstance(features, (dict, list, tuple, pd.DataFrame)): 445 | raise MoonshotError("features should either be a DataFrame or a dict, list, or tuple of DataFrames or Series") 446 | 447 | predictions_series_idx = None 448 | unstack_predictions_series = False 449 | 450 | # a single DataFrame is interpreted as a ready-made DataFrame of features 451 | if isinstance(features, pd.DataFrame): 452 | predictions_series_idx = features.index 453 | features = features.values 454 | 455 | # Convert iteratable of DataFrames or Series to np array 456 | else: 457 | 458 | if isinstance(features, dict): 459 | features = features.values() 460 | 461 | all_features = [] 462 | 463 | has_df = False 464 | has_series = False 465 | 466 | for i, feature in enumerate(features): 467 | 468 | if isinstance(feature, pd.DataFrame): 469 | has_df = True 470 | unstack_predictions_series = True 471 | if has_series: 472 | raise MoonshotError("features should be either all DataFrames or all Series, not a mix of both") 473 | # stack DataFrame to Series 474 | feature = feature.stack(dropna=False) 475 | else: 476 | has_series = True 477 | if has_df: 478 | raise MoonshotError("features should be either all DataFrames or all Series, not a mix of both") 479 | 480 | feature = feature.fillna(0) 481 | if i == 0: 482 | # save stacked index for predictions output 483 | predictions_series_idx = feature.index 484 | all_features.append(feature.values) 485 | del feature 486 | 487 | features = np.stack(all_features, axis=-1) 488 | del all_features 489 | 490 | # get predictions 491 | predictions = self.model.predict(features) 492 | del features 493 | 494 | if len(predictions.shape) == 2: 495 | # Keras output has (n_samples,1) shape and needs to be squeezed 496 | if predictions.shape[-1] == 1: 497 | predictions = predictions.squeeze(axis=-1) 498 | 499 | # predict_proba has (n_samples,2) shape where first col is probablity of 500 | # 0 (False) and second col is probability of 1 (True); we just want the 501 | # second col (https://datascience.stackexchange.com/a/22821) 502 | elif ( 503 | hasattr(self.model, "classes_") 504 | and len(self.model.classes_) == 2 505 | and list(self.model.classes_) == [0,1]): 506 | predictions = predictions[:,-1] 507 | 508 | else: 509 | raise NotImplementedError("Don't know what to do with predictions having shape {}".format(predictions.shape)) 510 | 511 | predictions = pd.Series(predictions, index=predictions_series_idx) 512 | if unstack_predictions_series: 513 | predictions = predictions.unstack(level="Sid") 514 | 515 | # predictions to signals 516 | signals = self.predictions_to_signals(predictions, prices) 517 | return signals 518 | 519 | def trade( 520 | self, 521 | allocations: dict[str, float], 522 | review_date: str = None 523 | ) -> pd.DataFrame: 524 | """ 525 | Run the strategy and create orders. 526 | 527 | Parameters 528 | ---------- 529 | allocations : dict, required 530 | dict of account:allocation to strategy (expressed as a percentage of NLV) 531 | 532 | review_date : str (YYYY-MM-DD [HH:MM:SS]), optional 533 | generate orders as if it were this date, rather than using the latest date. 534 | For end-of-day strategies, provide a date; for intraday strategies a date 535 | and time 536 | 537 | Returns 538 | ------- 539 | DataFrame 540 | orders 541 | """ 542 | self._load_model() 543 | return super(MoonshotML, self).trade(allocations, review_date=review_date) 544 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [versioneer] 2 | VCS = git 3 | style = pep440 4 | versionfile_source = moonshot/_version.py 5 | versionfile_build = moonshot/_version.py 6 | tag_prefix = 7 | parentdir_prefix = 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2024 QuantRocket - All Rights Reserved 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from setuptools import setup, find_packages 16 | import versioneer 17 | 18 | setup(name='quantrocket-moonshot', 19 | version=versioneer.get_version(), 20 | cmdclass=versioneer.get_cmdclass(), 21 | description='Moonshot', 22 | long_description='Vectorized backtesting and trading engine', 23 | url='https://www.quantrocket.com', 24 | author='QuantRocket LLC', 25 | author_email='support@quantrocket.com', 26 | license='Apache-2.0', 27 | packages=find_packages(), 28 | package_data={ 29 | "moonshot._tests.fixtures": ['*.h5'], 30 | "moonshot": ["py.typed"], 31 | }, 32 | install_requires=[ 33 | "quantrocket-client", 34 | "pandas", 35 | "filelock", 36 | ] 37 | ) 38 | --------------------------------------------------------------------------------