├── .qrignore ├── README.md ├── vmot ├── Introduction.ipynb ├── vmot_trend.py ├── Part1-Data-Collection.ipynb └── vmot_combined.py └── LICENSE.txt /.qrignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vmot 2 | 3 | Value/Momentum/Trend strategy modeled on Alpha Architect's VMOT ETF. This repository provides the trend strategy and walks through backtesting the value, momentum, and trend strategies in tandem. For the value and momentum strategies, see the `qval` and `qmom` repositories. 4 | 5 | ## Clone in QuantRocket 6 | 7 | CLI: 8 | 9 | ```shell 10 | quantrocket codeload clone 'vmot' 11 | ``` 12 | 13 | Python: 14 | 15 | ```python 16 | from quantrocket.codeload import clone 17 | clone("vmot") 18 | ``` 19 | 20 | ## Browse in GitHub 21 | 22 | Start here: [vmot/Introduction.ipynb](vmot/Introduction.ipynb) 23 | 24 | *** 25 | 26 | Find more code in QuantRocket's [Codeload Library](https://www.quantrocket.com/code/) 27 | -------------------------------------------------------------------------------- /vmot/Introduction.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "\"QuantRocket
\n", 8 | "Disclaimer" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "metadata": {}, 14 | "source": [ 15 | "# VMOT: Value Momentum Trend\n", 16 | "\n", 17 | "This tutorial backtests a portfolio of value, momentum, and trend strategies modeled on [Alpha Architect's VMOT ETF](https://etfsite.alphaarchitect.com/vmot/). VMOT can be run as three separate strategies deployed in tandem, or as a single combined strategy. Both approaches are demonstrated.\n", 18 | "\n", 19 | "## VMOT as 3 separate strategies\n", 20 | "In this variant, the value and momentum strategies are provided by separate tutorials (see prerequisites). This tutorial provides the trend strategy and runs the value, momentum, and trend strategies in tandem to replicate VMOT. The momentum and value portfolios are computed separately from each other, and hedging is achieved by shorting a broad-based index ETF (SPY) rather than by selling the momentum and value positions. This approach most closely mirrors the Alpha Architect VMOT white paper. \n", 21 | "\n", 22 | "## VMOT as 1 combined strategy\n", 23 | "In this variant, the value, momentum, and trend components are combined into a single strategy. The value screen is computed first, then the momemtum screen is applied to the results of the value screen. When the trend of SPY is down, the portfolio is partially or fully liquidated. " 24 | ] 25 | }, 26 | { 27 | "cell_type": "markdown", 28 | "metadata": {}, 29 | "source": [ 30 | "## Prerequisites\n", 31 | "\n", 32 | "This tutorial assumes you have already completed the QVAL (value) and QMOM (momentum) tutorials:\n", 33 | "\n", 34 | "* QVAL: `quantrocket codeload clone 'qval'`\n", 35 | "* QMOM: `quantrocket codeload clone 'qmom'`" 36 | ] 37 | }, 38 | { 39 | "cell_type": "markdown", 40 | "metadata": {}, 41 | "source": [ 42 | "## Contents\n", 43 | "\n", 44 | "* Part 1: [Data Collection](Part1-Data-Collection.ipynb)\n", 45 | "* Part 2: [Trend Strategy](Part2-Trend-Strategy.ipynb)\n", 46 | "* Part 3: [VMOT Backtest](Part3-VMOT-Backtest.ipynb)\n", 47 | "* Part 4: [VMOT Single Strategy Backtest](Part4-VMOT-Single-Strategy-Backtest.ipynb)" 48 | ] 49 | } 50 | ], 51 | "metadata": { 52 | "kernelspec": { 53 | "display_name": "Python 3.9", 54 | "language": "python", 55 | "name": "python3" 56 | }, 57 | "language_info": { 58 | "codemirror_mode": { 59 | "name": "ipython", 60 | "version": 3 61 | }, 62 | "file_extension": ".py", 63 | "mimetype": "text/x-python", 64 | "name": "python", 65 | "nbconvert_exporter": "python", 66 | "pygments_lexer": "ipython3", 67 | "version": "3.9.7" 68 | } 69 | }, 70 | "nbformat": 4, 71 | "nbformat_minor": 4 72 | } 73 | -------------------------------------------------------------------------------- /vmot/vmot_trend.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 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 import Moonshot 17 | from moonshot.commission import PerShareCommission 18 | 19 | class USStockCommission(PerShareCommission): 20 | BROKER_COMMISSION_PER_SHARE = 0.005 21 | 22 | class VMOTTrend(Moonshot): 23 | """ 24 | Hedging strategy that sells the market based on 2 trend rules: 25 | 26 | 1. Sell 50% if market price is below 12-month moving average 27 | 2. Sell 50% if market 12-month return is below 0 28 | 29 | This strategy constitutes the "Trend" portion of the Alpha Architect 30 | Value/Momentum/Trend (VMOT) ETF. 31 | """ 32 | 33 | CODE = "vmot-trend" 34 | DB = "sharadar-us-etf-1d" 35 | SIDS = "FIBBG000BDTBL9" 36 | REBALANCE_INTERVAL = "W" 37 | COMMISSION_CLASS = USStockCommission 38 | 39 | def prices_to_signals(self, prices: pd.DataFrame): 40 | 41 | closes = prices.loc["Close"] 42 | 43 | one_year_returns = (closes - closes.shift(252))/closes.shift(252) 44 | market_below_zero = one_year_returns < 0 45 | 46 | mavgs = closes.rolling(window=252).mean() 47 | market_below_mavg = closes < mavgs 48 | 49 | hedge_signals = market_below_zero.astype(int) + market_below_mavg.astype(int) 50 | hedge_signals = -hedge_signals 51 | 52 | return hedge_signals 53 | 54 | def signals_to_target_weights(self, signals: pd.DataFrame, prices: pd.DataFrame): 55 | # Resample using the rebalancing interval. 56 | # Keep only the last signal of the period, then fill it forward 57 | signals = signals.resample(self.REBALANCE_INTERVAL).last() 58 | signals = signals.reindex(prices.loc["Close"].index, method="ffill") 59 | 60 | # Divide signal counts by 2 to get the target weights 61 | weights = signals / 2 62 | return weights 63 | 64 | def target_weights_to_positions(self, weights: pd.DataFrame, prices: pd.DataFrame): 65 | # Enter the position the day after the signal 66 | return weights.shift() 67 | 68 | def positions_to_gross_returns(self, positions: pd.DataFrame, prices: pd.DataFrame): 69 | # Enter on the close 70 | closes = prices.loc["Close"] 71 | # The return is the security's percent change over the period, 72 | # multiplied by the position. 73 | gross_returns = closes.pct_change() * positions.shift() 74 | return gross_returns 75 | 76 | def order_stubs_to_orders(self, orders: pd.DataFrame, prices: pd.DataFrame): 77 | orders["Exchange"] = "SMART" 78 | orders["OrderType"] = "MOC" 79 | orders["Tif"] = "DAY" 80 | return orders -------------------------------------------------------------------------------- /vmot/Part1-Data-Collection.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "\"QuantRocket
\n", 8 | "Disclaimer" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "metadata": {}, 14 | "source": [ 15 | "***\n", 16 | "[VMOT: Value Momentum Trend](Introduction.ipynb) › Part 1: Data Collection\n", 17 | "***" 18 | ] 19 | }, 20 | { 21 | "cell_type": "markdown", 22 | "metadata": {}, 23 | "source": [ 24 | "# Data Collection\n", 25 | "\n", 26 | "The VMOT trend strategy hedges market risk by shorting the US market under certain conditions. We'll use SPY for this purpose, from the Sharadar ETF dataset." 27 | ] 28 | }, 29 | { 30 | "cell_type": "markdown", 31 | "metadata": {}, 32 | "source": [ 33 | "## Collect Sharadar ETF prices\n", 34 | "\n", 35 | "First, create a database for Sharadar ETF prices:" 36 | ] 37 | }, 38 | { 39 | "cell_type": "code", 40 | "execution_count": 1, 41 | "metadata": {}, 42 | "outputs": [ 43 | { 44 | "data": { 45 | "text/plain": [ 46 | "{'status': 'successfully created quantrocket.v2.history.sharadar-us-etf-1d.sqlite'}" 47 | ] 48 | }, 49 | "execution_count": 1, 50 | "metadata": {}, 51 | "output_type": "execute_result" 52 | } 53 | ], 54 | "source": [ 55 | "from quantrocket.history import create_sharadar_db\n", 56 | "create_sharadar_db(\"sharadar-us-etf-1d\", sec_type=\"ETF\", country=\"US\")" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "metadata": {}, 62 | "source": [ 63 | "Then collect the data:" 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": 2, 69 | "metadata": {}, 70 | "outputs": [ 71 | { 72 | "data": { 73 | "text/plain": [ 74 | "{'status': 'the historical data will be collected asynchronously'}" 75 | ] 76 | }, 77 | "execution_count": 2, 78 | "metadata": {}, 79 | "output_type": "execute_result" 80 | } 81 | ], 82 | "source": [ 83 | "from quantrocket.history import collect_history\n", 84 | "collect_history(\"sharadar-us-etf-1d\")" 85 | ] 86 | }, 87 | { 88 | "cell_type": "markdown", 89 | "metadata": {}, 90 | "source": [ 91 | "This runs in the background, monitor flightlog for a completion message:\n", 92 | "\n", 93 | "```\n", 94 | "quantrocket.history: INFO [sharadar-us-etf-1d] Collecting Sharadar US ETF prices\n", 95 | "quantrocket.history: INFO [sharadar-us-etf-1d] Collecting updated Sharadar US securities listings\n", 96 | "quantrocket.history: INFO [sharadar-us-etf-1d] Finished collecting Sharadar US ETF prices\n", 97 | "```" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "## Lookup SPY Sid\n", 105 | "\n", 106 | "Next, look up the sid for SPY. This will be used in the trend strategy. Looking up the sid requires a bit less typing with the CLI: " 107 | ] 108 | }, 109 | { 110 | "cell_type": "code", 111 | "execution_count": 3, 112 | "metadata": {}, 113 | "outputs": [ 114 | { 115 | "name": "stdout", 116 | "output_type": "stream", 117 | "text": [ 118 | "| Sid | Symbol | Exchange |\n", 119 | "| -------------- | ------ | -------- |\n", 120 | "| FIBBG000BDTBL9 | SPY | ARCX |\n" 121 | ] 122 | } 123 | ], 124 | "source": [ 125 | "!quantrocket master get -s SPY -t ETF --fields Sid Symbol Exchange | csvlook " 126 | ] 127 | }, 128 | { 129 | "cell_type": "markdown", 130 | "metadata": {}, 131 | "source": [ 132 | "***\n", 133 | "\n", 134 | "## *Next Up*\n", 135 | "\n", 136 | "Part 2: [Trend Strategy](Part2-Trend-Strategy.ipynb)" 137 | ] 138 | } 139 | ], 140 | "metadata": { 141 | "kernelspec": { 142 | "display_name": "Python 3.9", 143 | "language": "python", 144 | "name": "python3" 145 | }, 146 | "language_info": { 147 | "codemirror_mode": { 148 | "name": "ipython", 149 | "version": 3 150 | }, 151 | "file_extension": ".py", 152 | "mimetype": "text/x-python", 153 | "name": "python", 154 | "nbconvert_exporter": "python", 155 | "pygments_lexer": "ipython3", 156 | "version": "3.9.7" 157 | } 158 | }, 159 | "nbformat": 4, 160 | "nbformat_minor": 4 161 | } 162 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vmot/vmot_combined.py: -------------------------------------------------------------------------------- 1 | # Copyright 2020 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 import Moonshot 17 | from moonshot.commission import PerShareCommission 18 | from quantrocket.fundamental import get_sharadar_fundamentals_reindexed_like 19 | from quantrocket import get_prices 20 | 21 | class USStockCommission(PerShareCommission): 22 | BROKER_COMMISSION_PER_SHARE = 0.005 23 | 24 | class ValueMomentumTrendCombined(Moonshot): 25 | """ 26 | Value/Momentum/Trend strategy modeled on Alpha Architect's VMOT ETF. 27 | 28 | Intended to be run with Sharadar fundamentals and prices. 29 | 30 | Strategy rules: 31 | 32 | 1. Universe selection 33 | a. Starting universe: all NYSE stocks 34 | b. Exclude financials, ADRs, REITs 35 | c. Liquidity screen: select top N percent of stocks by dollar 36 | volume (N=60) 37 | [Value] 38 | 2. Apply value screen: select cheapest N percent of stocks by 39 | enterprise multiple (EV/EBIT) (N=10) 40 | 3. Rank by quality: of the value stocks, select the N percent 41 | with the highest quality, as ranked by Piotroski F-Score (N=50) 42 | [Momentum] 43 | 4. Apply momentum screen: calculate 12-month returns, excluding 44 | most recent month, and select N percent of stocks with best 45 | return (N=10) 46 | 5. Filter by smoothness of momentum: of the momentum stocks, select 47 | the N percent with the smoothest momentum, as measured by the number 48 | of positive days in the last 12 months (N=50) 49 | 6. Apply equal weights 50 | 7. Rebalance portfolio before quarter-end to capture window-dressing seasonality effect 51 | [Trend] 52 | 8. Sell 50% if market price is below 12-month moving average 53 | 9. Sell 50% if market 12-month return is below 0 54 | 10. Rebalance trend component weekly 55 | """ 56 | 57 | CODE = "vmot" 58 | DB = "sharadar-us-stk-1d" 59 | DB_FIELDS = ["Close", "Volume"] 60 | DOLLAR_VOLUME_TOP_N_PCT = 60 61 | DOLLAR_VOLUME_WINDOW = 90 62 | UNIVERSES = "nyse-stk" 63 | EXCLUDE_UNIVERSES = ["nyse-financials", "nyse-adrs", "nyse-reits"] 64 | TREND_DB = "sharadar-us-etf-1d" 65 | TREND_SID = "FIBBG000BDTBL9" 66 | VALUE_TOP_N_PCT = 20 67 | QUALITY_TOP_N_PCT = 50 68 | MOMENTUM_WINDOW = 252 69 | MOMENTUM_EXCLUDE_MOST_RECENT_WINDOW = 22 70 | MOMENTUM_TOP_N_PCT = 20 71 | SMOOTHEST_TOP_N_PCT = 50 72 | REBALANCE_INTERVAL = "Q-NOV" 73 | TREND_REBALANCE_INTERVAL = "W" 74 | COMMISSION_CLASS = USStockCommission 75 | 76 | def prices_to_signals(self, prices: pd.DataFrame): 77 | 78 | # Step 1.c: get a mask of stocks with adequate dollar volume 79 | closes = prices.loc["Close"] 80 | volumes = prices.loc["Volume"] 81 | avg_dollar_volumes = (closes * volumes).rolling(self.DOLLAR_VOLUME_WINDOW).mean() 82 | dollar_volume_ranks = avg_dollar_volumes.rank(axis=1, ascending=False, pct=True) 83 | have_adequate_dollar_volumes = dollar_volume_ranks <= (self.DOLLAR_VOLUME_TOP_N_PCT/100) 84 | 85 | # Step 2. Apply value screen: select cheapest N percent of stocks by 86 | # enterprise multiple (EV/EBITDA) (N=10) 87 | fundamentals = get_sharadar_fundamentals_reindexed_like( 88 | closes, 89 | fields=["EVEBIT", "EBIT"], 90 | dimension="ART") 91 | enterprise_multiples = fundamentals.loc["EVEBIT"] 92 | ebits = fundamentals.loc["EBIT"] 93 | # Ignore negative earnings 94 | enterprise_multiples = enterprise_multiples.where(ebits > 0) 95 | # Only apply rankings to stocks with adequate dollar volume 96 | value_ranks = enterprise_multiples.where(have_adequate_dollar_volumes).rank(axis=1, ascending=True, pct=True) 97 | are_value_stocks = value_ranks <= (self.VALUE_TOP_N_PCT/100) 98 | 99 | # Step 3: Rank by quality: of the value stocks, select the N percent 100 | # with the highest quality, as ranked by Piotroski F-Score (N=50) 101 | f_scores = self.get_f_scores(closes) 102 | # Rank the value stocks by F-Score 103 | quality_ranks = f_scores.where(are_value_stocks).rank(axis=1, ascending=False, pct=True) 104 | are_quality_value_stocks = quality_ranks <= (self.QUALITY_TOP_N_PCT/100) 105 | 106 | # Step 4: apply momentum screen 107 | year_ago_closes = closes.shift(self.MOMENTUM_WINDOW) 108 | month_ago_closes = closes.shift(self.MOMENTUM_EXCLUDE_MOST_RECENT_WINDOW) 109 | returns = (month_ago_closes - year_ago_closes) / year_ago_closes.where(year_ago_closes != 0) # avoid DivisionByZero errors 110 | # Rank only among high quality value stocks 111 | returns_ranks = returns.where(are_quality_value_stocks).rank(axis=1, ascending=False, pct=True) 112 | have_momentum = returns_ranks <= (self.MOMENTUM_TOP_N_PCT / 100) 113 | 114 | # Step 5: Filter by smoothness of momentum 115 | are_positive_days = closes.pct_change() > 0 116 | positive_days_last_twelve_months = are_positive_days.astype(int).rolling(self.MOMENTUM_WINDOW).sum() 117 | positive_days_last_twelve_months_ranks = positive_days_last_twelve_months.where(have_momentum).rank(axis=1, ascending=False, pct=True) 118 | have_smooth_momentum = positive_days_last_twelve_months_ranks <= (self.SMOOTHEST_TOP_N_PCT/100) 119 | 120 | signals = have_smooth_momentum.astype(int) 121 | 122 | return signals 123 | 124 | def get_f_scores(self, closes: pd.DataFrame): 125 | 126 | # Step 1: query relevant indicators 127 | fundamentals = get_sharadar_fundamentals_reindexed_like( 128 | closes, 129 | dimension="ART", # As-reported trailing twelve month reports 130 | fields=[ 131 | "ROA", # Return on assets 132 | "ASSETS", # Total Assets 133 | "NCFO", # Net Cash Flow from Operations 134 | "DE", # Debt to Equity Ratio 135 | "CURRENTRATIO", # Current ratio 136 | "SHARESWA", # Outstanding shares 137 | "GROSSMARGIN", # Gross margin 138 | "ASSETTURNOVER", # Asset turnover 139 | ]) 140 | return_on_assets = fundamentals.loc["ROA"] 141 | total_assets = fundamentals.loc["ASSETS"] 142 | operating_cash_flows = fundamentals.loc["NCFO"] 143 | leverages = fundamentals.loc["DE"] 144 | current_ratios = fundamentals.loc["CURRENTRATIO"] 145 | shares_out = fundamentals.loc["SHARESWA"] 146 | gross_margins = fundamentals.loc["GROSSMARGIN"] 147 | asset_turnovers = fundamentals.loc["ASSETTURNOVER"] 148 | 149 | # Step 2: many Piotroski F-score components compare current to previous 150 | # values, so get DataFrames of previous values 151 | 152 | # Step 2.a: get a boolean mask of the first day of each newly reported fiscal 153 | # period 154 | fundamentals = get_sharadar_fundamentals_reindexed_like( 155 | closes, 156 | dimension="ART", # As-reported trailing twelve month reports 157 | fields=["REPORTPERIOD"]) 158 | fiscal_periods = fundamentals.loc["REPORTPERIOD"] 159 | are_new_fiscal_periods = fiscal_periods != fiscal_periods.shift() 160 | 161 | # Step 2.b: shift the ROAs forward one fiscal period by (1) shifting the ratios one day, 162 | # (2) keeping only the ones that fall on the first day of the newly reported 163 | # fiscal period, and (3) forward-filling 164 | previous_return_on_assets = return_on_assets.shift().where(are_new_fiscal_periods).fillna(method="ffill") 165 | 166 | # Step 2.c: Repeat for other indicators 167 | previous_leverages = leverages.shift().where(are_new_fiscal_periods).fillna(method="ffill") 168 | previous_current_ratios = current_ratios.shift().where(are_new_fiscal_periods).fillna(method="ffill") 169 | previous_shares_out = shares_out.shift().where(are_new_fiscal_periods).fillna(method="ffill") 170 | previous_gross_margins = gross_margins.shift().where(are_new_fiscal_periods).fillna(method="ffill") 171 | previous_asset_turnovers = asset_turnovers.shift().where(are_new_fiscal_periods).fillna(method="ffill") 172 | 173 | # Step 3: calculate F-Score components; each resulting component is a DataFrame 174 | # of booleans 175 | have_positive_return_on_assets = return_on_assets > 0 176 | have_positive_operating_cash_flows = operating_cash_flows > 0 177 | have_increasing_return_on_assets = return_on_assets > previous_return_on_assets 178 | have_more_cash_flow_than_incomes = operating_cash_flows / total_assets > return_on_assets 179 | have_decreasing_leverages = leverages < previous_leverages 180 | have_increasing_current_ratios = current_ratios > previous_current_ratios 181 | have_no_new_shares = shares_out <= previous_shares_out 182 | have_increasing_gross_margins = gross_margins > previous_gross_margins 183 | have_increasing_asset_turnovers = asset_turnovers > previous_asset_turnovers 184 | 185 | # Step 4: convert the booleans to integers and sum to get F-Score (0-9) 186 | f_scores = ( 187 | have_positive_return_on_assets.astype(int) 188 | + have_positive_operating_cash_flows.astype(int) 189 | + have_increasing_return_on_assets.astype(int) 190 | + have_more_cash_flow_than_incomes.astype(int) 191 | + have_decreasing_leverages.astype(int) 192 | + have_increasing_current_ratios.astype(int) 193 | + have_no_new_shares.astype(int) 194 | + have_increasing_gross_margins.astype(int) 195 | + have_increasing_asset_turnovers.astype(int) 196 | ) 197 | 198 | self.save_to_results("FScore", f_scores) 199 | return f_scores 200 | 201 | def signals_to_target_weights(self, signals: pd.DataFrame, prices: pd.DataFrame): 202 | # Step 6: equal weights 203 | daily_signal_counts = signals.abs().sum(axis=1) 204 | weights = signals.div(daily_signal_counts, axis=0).fillna(0) 205 | 206 | # Step 7: Rebalance portfolio before quarter-end to capture window-dressing seasonality effect 207 | # Resample daily to REBALANCE_INTERVAL, taking the last day's signal 208 | # For pandas offset aliases, see https://pandas.pydata.org/pandas-docs/stable/timeseries.html#offset-aliases 209 | weights = weights.resample(self.REBALANCE_INTERVAL).last() 210 | # Reindex back to daily and fill forward 211 | weights = weights.reindex(prices.loc["Close"].index, method="ffill") 212 | 213 | # Step 8-9: Sell when trend is down 214 | # Get the market prices 215 | market_prices = get_prices(self.TREND_DB, sids=self.TREND_SID, fields="Close", start_date=weights.index.min(), end_date=weights.index.max()) 216 | market_closes = market_prices.loc["Close"] 217 | 218 | # Convert 1-column DataFrame to Series 219 | market_closes = market_closes.squeeze() 220 | 221 | # Calcuate trend rule 1 222 | one_year_returns = (market_closes - market_closes.shift(252))/market_closes.shift(252) 223 | market_below_zero = one_year_returns < 0 224 | 225 | # Calcuate trend rule 2 226 | mavgs = market_closes.rolling(window=252).mean() 227 | market_below_mavg = market_closes < mavgs 228 | 229 | # Reshape trend rule Series like weights 230 | market_below_mavg = weights.apply(lambda x: market_below_mavg) 231 | market_below_zero = weights.apply(lambda x: market_below_zero) 232 | 233 | # Sum trend signals and resample to weekly 234 | num_trend_signals = market_below_zero.astype(int) + market_below_mavg.astype(int) 235 | num_trend_signals = num_trend_signals.resample(self.TREND_REBALANCE_INTERVAL).last() 236 | num_trend_signals = num_trend_signals.reindex(weights.index, method="ffill") 237 | 238 | # Reduce weights based on trend signals 239 | half_weights = weights/2 240 | weights = weights.where(num_trend_signals == 0, half_weights.where(num_trend_signals == 1, 0)) 241 | 242 | return weights 243 | 244 | def target_weights_to_positions(self, weights: pd.DataFrame, prices: pd.DataFrame): 245 | # Enter the position the day after the signal 246 | return weights.shift() 247 | 248 | def positions_to_gross_returns(self, positions: pd.DataFrame, prices: pd.DataFrame): 249 | 250 | closes = prices.loc["Close"] 251 | position_ends = positions.shift() 252 | 253 | # The return is the security's percent change over the period, 254 | # multiplied by the position. 255 | gross_returns = closes.pct_change() * position_ends 256 | 257 | return gross_returns 258 | --------------------------------------------------------------------------------