├── tests ├── __init__.py ├── portfolio │ └── __init__.py ├── guide_examples │ ├── __init__.py │ ├── rank_long_short_test.py │ ├── spo_test.py │ ├── spo_tranches_distr_test.py │ ├── spo_tranches_test.py │ └── spo_weights_test.py ├── integration │ └── __init__.py └── investos_test.py ├── .git-blame-ignore-revs ├── investos ├── utils │ ├── __init__.py │ └── hydrate.py ├── portfolio │ ├── result │ │ ├── __init__.py │ │ └── weights_result.py │ ├── cost_model │ │ ├── __init__.py │ │ ├── short_holding_cost.py │ │ ├── base_cost.py │ │ └── trading_cost.py │ ├── risk_model │ │ ├── __init__.py │ │ ├── base_risk.py │ │ ├── risk_util.py │ │ └── stat_factor_risk.py │ ├── __init__.py │ ├── strategy │ │ ├── __init__.py │ │ ├── base_strategy.py │ │ ├── spo_weights.py │ │ ├── spo.py │ │ ├── rank_long_short.py │ │ └── spo_tranches.py │ ├── constraint_model │ │ ├── __init__.py │ │ ├── trade_constraint.py │ │ ├── base_constraint.py │ │ ├── long_constraint.py │ │ ├── return_constraint.py │ │ ├── leverage_constraint.py │ │ ├── weight_constraint.py │ │ └── factor_constraint.py │ └── backtest_controller.py ├── __init__.py └── util.py ├── package.json ├── assets └── investos-banner.png ├── docs ├── source │ ├── _static │ │ ├── tailwind_input.css │ │ ├── theme_overrides.css │ │ └── tailwind_output.css │ ├── _templates │ │ └── fulltoc.html │ ├── modules.rst │ ├── investos.portfolio.risk_model.rst │ ├── index.rst │ ├── investos.rst │ ├── investos.portfolio.result.rst │ ├── investos.portfolio.rst │ ├── investos.portfolio.strategy.rst │ ├── investos.portfolio.cost_model.rst │ ├── investos.portfolio.constraint_model.rst │ └── conf.py ├── Makefile ├── make.bat └── requirements.txt ├── tailwind.config.js ├── .pre-commit-config.yaml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── CONTRIBUTING.md ├── guides ├── introduction │ ├── getting_started.md │ └── how_investos_works.md ├── off_the_shelf │ ├── cost_models.md │ ├── risk_models.md │ ├── backtest_controller.md │ └── investment_strategies.md ├── bespoke │ ├── custom_constraint_models.md │ ├── custom_risk_models.md │ ├── custom_cost_models.md │ └── custom_investment_strategies.md ├── reporting │ ├── analyzing_backtest_results.md │ └── external_portfolios.md └── simple_examples │ ├── rank_long_short.md │ └── spo.md ├── pyproject.toml ├── README.md └── examples ├── rank_long_short.ipynb └── spo.ipynb /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/portfolio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/guide_examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 5d1c33b9fe51b0241adfe8c502d99321dc5343df 2 | -------------------------------------------------------------------------------- /investos/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from investos.utils.hydrate import * 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "tailwindcss": "^3.3.2" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/investos-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForecastOS/investos/HEAD/assets/investos-banner.png -------------------------------------------------------------------------------- /docs/source/_static/tailwind_input.css: -------------------------------------------------------------------------------- 1 | /*@tailwind base;*/ 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /docs/source/_templates/fulltoc.html: -------------------------------------------------------------------------------- 1 |

{{ _('Navigation') }}

2 | 3 | {{ toctree(collapse=False) }} 4 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | investos 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 4 8 | 9 | investos 10 | -------------------------------------------------------------------------------- /investos/portfolio/result/__init__.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.result.base_result import * 2 | from investos.portfolio.result.save_result import * 3 | from investos.portfolio.result.weights_result import * 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./docs/source/**/*.{html,js}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /investos/portfolio/cost_model/__init__.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.cost_model.base_cost import * 2 | from investos.portfolio.cost_model.short_holding_cost import * 3 | from investos.portfolio.cost_model.trading_cost import * 4 | -------------------------------------------------------------------------------- /investos/portfolio/risk_model/__init__.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.risk_model.base_risk import * 2 | from investos.portfolio.risk_model.factor_risk import * 3 | from investos.portfolio.risk_model.stat_factor_risk import * 4 | -------------------------------------------------------------------------------- /investos/__init__.py: -------------------------------------------------------------------------------- 1 | import investos.portfolio 2 | 3 | __version__ = "0.9.2" 4 | 5 | import os 6 | 7 | api_key = os.environ.get("FORECASTOS_API_KEY", "") 8 | api_endpoint = os.environ.get( 9 | "FORECASTOS_API_ENDPOINT", "https://app.forecastos.com/api/v1" 10 | ) 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/charliermarsh/ruff-pre-commit 5 | rev: 'v0.3.0' 6 | hooks: 7 | - id: ruff 8 | - id: ruff-format 9 | -------------------------------------------------------------------------------- /docs/source/investos.portfolio.risk_model.rst: -------------------------------------------------------------------------------- 1 | investos.portfolio.risk\_model package 2 | ====================================== 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: investos.portfolio.risk_model 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /investos/portfolio/__init__.py: -------------------------------------------------------------------------------- 1 | import investos.portfolio.constraint_model 2 | import investos.portfolio.cost_model 3 | import investos.portfolio.result 4 | import investos.portfolio.risk_model 5 | import investos.portfolio.strategy 6 | from investos.portfolio.backtest_controller import BacktestController 7 | -------------------------------------------------------------------------------- /tests/investos_test.py: -------------------------------------------------------------------------------- 1 | import investos as inv 2 | 3 | 4 | def test_version(): 5 | assert inv.__version__ == "0.9.2" 6 | 7 | 8 | def test_key_and_endpoint(): 9 | assert isinstance(inv.api_key, str) 10 | assert isinstance(inv.api_endpoint, str) and inv.api_endpoint.startswith("http") 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | examples/data 2 | **/__pycache__ 3 | .personal_README.md 4 | **/.doctrees 5 | .docstring_examples.py 6 | .pypi_token 7 | dist/ 8 | node_modules 9 | **/playground 10 | **/playground_*.ipynb 11 | **/*checkpoint.ipynb 12 | **/tmp 13 | guides_tmp 14 | .DS_Store 15 | false/* 16 | docs/build/* 17 | benchmarks/ -------------------------------------------------------------------------------- /investos/portfolio/strategy/__init__.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.strategy.base_strategy import * 2 | from investos.portfolio.strategy.rank_long_short import * 3 | from investos.portfolio.strategy.spo import * 4 | from investos.portfolio.strategy.spo_tranches import * 5 | from investos.portfolio.strategy.spo_weights import * 6 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. InvestOS documentation master file, created by 2 | sphinx-quickstart on Thu May 11 10:59:16 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to InvestOS's documentation! 7 | ==================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 4 11 | 12 | investos 13 | -------------------------------------------------------------------------------- /docs/source/investos.rst: -------------------------------------------------------------------------------- 1 | investos 2 | ======== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | investos.portfolio 11 | 12 | Submodules 13 | ---------- 14 | 15 | investos.util module 16 | -------------------- 17 | 18 | .. automodule:: investos.util 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | .. Module contents 24 | .. --------------- 25 | 26 | .. .. automodule:: investos 27 | .. :members: 28 | .. :undoc-members: 29 | .. :show-inheritance: 30 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/__init__.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.constraint_model.base_constraint import * 2 | from investos.portfolio.constraint_model.factor_constraint import * 3 | from investos.portfolio.constraint_model.leverage_constraint import * 4 | from investos.portfolio.constraint_model.long_constraint import * 5 | from investos.portfolio.constraint_model.return_constraint import * 6 | from investos.portfolio.constraint_model.trade_constraint import * 7 | from investos.portfolio.constraint_model.weight_constraint import * 8 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/trade_constraint.py: -------------------------------------------------------------------------------- 1 | import cvxpy as cvx 2 | 3 | from investos.portfolio.constraint_model.base_constraint import BaseConstraint 4 | 5 | 6 | class MaxAbsTurnoverConstraint(BaseConstraint): 7 | def __init__(self, limit=0.05, **kwargs): 8 | self.limit = limit 9 | super().__init__(**kwargs) 10 | 11 | def _cvxpy_expression( 12 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 13 | ): 14 | return cvx.sum(cvx.abs(weights_trades)) <= self.limit 15 | -------------------------------------------------------------------------------- /docs/source/investos.portfolio.result.rst: -------------------------------------------------------------------------------- 1 | investos.portfolio.result package 2 | ================================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | investos.portfolio.result.base_result module 8 | -------------------------------------------- 9 | 10 | .. automodule:: investos.portfolio.result.base_result 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | .. Module contents 16 | .. --------------- 17 | 18 | .. .. automodule:: investos.portfolio.result 19 | .. :members: 20 | .. :undoc-members: 21 | .. :show-inheritance: 22 | -------------------------------------------------------------------------------- /docs/source/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #5046e6; 3 | text-decoration: none; 4 | } 5 | 6 | .universal-footer ul { 7 | margin: 0; 8 | } 9 | 10 | .universal-footer li { 11 | list-style: none; 12 | } 13 | 14 | .universal-footer div.border-t { 15 | border-top-style: solid; 16 | } 17 | 18 | body { 19 | font-family: "Inter var", ui-sans-serif, system-ui, -apple-system, 20 | "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", 21 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 22 | "Noto Color Emoji" !important; 23 | } 24 | 25 | #searchbox { 26 | margin-bottom: 20px; 27 | } 28 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/investos.portfolio.rst: -------------------------------------------------------------------------------- 1 | investos.portfolio package 2 | ========================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | investos.portfolio.cost_model 11 | investos.portfolio.constraint_model 12 | investos.portfolio.result 13 | investos.portfolio.risk_model 14 | investos.portfolio.strategy 15 | 16 | Submodules 17 | ---------- 18 | 19 | investos.portfolio.backtest_controller module 20 | --------------------------------------------- 21 | 22 | .. automodule:: investos.portfolio.backtest_controller 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. Module contents 28 | .. --------------- 29 | 30 | .. .. automodule:: investos.portfolio 31 | .. :members: 32 | .. :undoc-members: 33 | .. :show-inheritance: 34 | -------------------------------------------------------------------------------- /docs/source/investos.portfolio.strategy.rst: -------------------------------------------------------------------------------- 1 | investos.portfolio.strategy package 2 | =================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | investos.portfolio.strategy.base\_strategy module 8 | ------------------------------------------------- 9 | 10 | .. automodule:: investos.portfolio.strategy.base_strategy 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | investos.portfolio.strategy.rank\_long\_short module 16 | ---------------------------------------------------- 17 | 18 | .. automodule:: investos.portfolio.strategy.rank_long_short 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | .. Module contents 24 | .. --------------- 25 | 26 | .. .. automodule:: investos.portfolio.strategy 27 | .. :members: 28 | .. :undoc-members: 29 | .. :show-inheritance: 30 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | # You can also specify other tool versions: 14 | # nodejs: "19" 15 | # rust: "1.64" 16 | # golang: "1.19" 17 | 18 | # Build documentation in the "docs/" directory with Sphinx 19 | sphinx: 20 | configuration: docs/source/conf.py 21 | # Optionally build your docs in additional formats such as PDF and ePub 22 | # formats: 23 | # - pdf 24 | # - epub 25 | 26 | # Optional but recommended, declare the Python requirements required 27 | # to build your documentation 28 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 29 | python: 30 | install: 31 | - requirements: docs/requirements.txt 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright 2025 ForecastOS Inc. / InvestOS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /docs/source/investos.portfolio.cost_model.rst: -------------------------------------------------------------------------------- 1 | investos.portfolio.cost\_model package 2 | ====================================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | investos.portfolio.cost\_model.base\_cost module 8 | ------------------------------------------------ 9 | 10 | .. automodule:: investos.portfolio.cost_model.base_cost 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | investos.portfolio.cost\_model.short\_holding\_cost module 16 | ------------------------------------------------------------- 17 | 18 | .. automodule:: investos.portfolio.cost_model.short_holding_cost 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | investos.portfolio.cost\_model.trading\_cost module 24 | --------------------------------------------------- 25 | 26 | .. automodule:: investos.portfolio.cost_model.trading_cost 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | .. Module contents 32 | .. --------------- 33 | 34 | .. .. automodule:: investos.portfolio.cost_model 35 | .. :members: 36 | .. :undoc-members: 37 | .. :show-inheritance: 38 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2023.7.22 ; python_version >= "3.8" and python_version < "4.0" 2 | charset-normalizer==3.3.2 ; python_version >= "3.8" and python_version < "4.0" 3 | clarabel==0.6.0 ; python_version >= "3.8" and python_version < "4.0" 4 | cvxpy==1.4.1 ; python_version >= "3.8" and python_version < "4.0" 5 | ecos==2.0.12 ; python_version >= "3.8" and python_version < "4.0" 6 | idna==3.4 ; python_version >= "3.8" and python_version < "4.0" 7 | numpy==1.24.4 ; python_version >= "3.8" and python_version < "4.0" 8 | osqp==0.6.3 ; python_version >= "3.8" and python_version < "4.0" 9 | pandas==2.0.3 ; python_version >= "3.8" and python_version < "4.0" 10 | pybind11==2.11.1 ; python_version >= "3.8" and python_version < "4.0" 11 | python-dateutil==2.8.2 ; python_version >= "3.8" and python_version < "4.0" 12 | pytz==2023.3.post1 ; python_version >= "3.8" and python_version < "4.0" 13 | qdldl==0.1.7.post0 ; python_version >= "3.8" and python_version < "4.0" 14 | requests==2.31.0 ; python_version >= "3.8" and python_version < "4.0" 15 | scipy==1.9.3 ; python_version >= "3.8" and python_version < "4.0" 16 | scs==3.2.3 ; python_version >= "3.8" and python_version < "4.0" 17 | six==1.16.0 ; python_version >= "3.8" and python_version < "4.0" 18 | tzdata==2023.3 ; python_version >= "3.8" and python_version < "4.0" 19 | urllib3==2.0.7 ; python_version >= "3.8" and python_version < "4.0" 20 | -------------------------------------------------------------------------------- /tests/guide_examples/rank_long_short_test.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | import investos as inv 4 | 5 | 6 | def test_rank_long_short(): 7 | actual_returns = pd.read_parquet( 8 | "https://investos.io/example_actual_returns.parquet" 9 | ) 10 | forecast_returns = pd.read_parquet( 11 | "https://investos.io/example_forecast_returns.parquet" 12 | ) 13 | 14 | strategy = inv.portfolio.strategy.RankLongShort( 15 | actual_returns=actual_returns, 16 | metric_to_rank=forecast_returns, 17 | leverage=1.6, 18 | ratio_long=130, 19 | ratio_short=30, 20 | percent_long=0.2, 21 | percent_short=0.2, 22 | n_periods_held=60, 23 | cash_column_name="cash", 24 | ) 25 | 26 | portfolio = inv.portfolio.BacktestController( 27 | strategy=strategy, 28 | start_date="2017-01-01", 29 | end_date="2018-01-01", 30 | aum=100_000_000, 31 | ) 32 | 33 | backtest_result = portfolio.generate_positions() 34 | summary = backtest_result._summary_string() 35 | 36 | assert isinstance(summary, str) 37 | assert round(backtest_result.annualized_return, 4) == 0.1749 38 | assert round(backtest_result.excess_risk_annualized, 4) == 0.0609 39 | assert round(backtest_result.information_ratio, 2) == 2.37 40 | assert round(backtest_result.annual_turnover, 2) == 9.97 41 | assert round(backtest_result.portfolio_hit_rate, 3) == 0.6000 42 | -------------------------------------------------------------------------------- /docs/source/investos.portfolio.constraint_model.rst: -------------------------------------------------------------------------------- 1 | investos.portfolio.constraint\_model package 2 | ============================================ 3 | 4 | Submodules 5 | ---------- 6 | 7 | investos.portfolio.constraint\_model.base\_constraint module 8 | ------------------------------------------------------------ 9 | 10 | .. automodule:: investos.portfolio.constraint_model.base_constraint 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | investos.portfolio.constraint\_model.leverage\_constraint module 16 | ---------------------------------------------------------------- 17 | 18 | .. automodule:: investos.portfolio.constraint_model.leverage_constraint 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | investos.portfolio.constraint\_model.weight\_constraint module 24 | -------------------------------------------------------------- 25 | 26 | .. automodule:: investos.portfolio.constraint_model.weight_constraint 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | investos.portfolio.constraint\_model.trade\_constraint module 32 | ------------------------------------------------------------- 33 | 34 | .. automodule:: investos.portfolio.constraint_model.long_constraint 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | .. Module contents 40 | .. --------------- 41 | 42 | .. .. automodule:: investos.portfolio.cost_model 43 | .. :members: 44 | .. :undoc-members: 45 | .. :show-inheritance: 46 | -------------------------------------------------------------------------------- /investos/utils/hydrate.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | 4 | import investos as inv 5 | 6 | 7 | class HydrateMixin: 8 | _excluded_attrs = {"strategy", "benchmark", "risk_free"} 9 | 10 | def _implied_attributes(self): 11 | props = { 12 | name 13 | for name in dir(type(self)) 14 | if isinstance(getattr(type(self), name), property) 15 | } 16 | return self._excluded_attrs.union(props) 17 | 18 | def __getstate__(self): 19 | clean_state = {} 20 | self.__version__ = inv.__version__ 21 | skip_keys = self._implied_attributes() 22 | 23 | for k, value in self.__dict__.items(): 24 | if k in skip_keys: 25 | continue 26 | try: 27 | pickle.dumps(value) 28 | clean_state[k] = value 29 | except Exception as e: 30 | print(f"⚠️ Skipping non-pickleable attribute: {k} ({type(value)}): {e}") 31 | 32 | return clean_state 33 | 34 | def __setstate__(self, state): 35 | self.__dict__.update(state) 36 | 37 | def dehydrate_to_disk(self, path: str, obj_name: str = "object.pkl"): 38 | os.makedirs(path, exist_ok=True) 39 | with open(os.path.join(path, obj_name), "wb") as f: 40 | pickle.dump(self, f) 41 | 42 | @classmethod 43 | def rehydrate_from_disk(cls, path: str, obj_name: str = "object.pkl"): 44 | with open(os.path.join(path, obj_name), "rb") as f: 45 | return pickle.load(f) 46 | -------------------------------------------------------------------------------- /investos/portfolio/risk_model/base_risk.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import pandas as pd 4 | 5 | from investos.portfolio.cost_model import BaseCost 6 | 7 | 8 | class BaseRisk(BaseCost): 9 | """Base risk model for InvestOS. 10 | 11 | The only requirement of custom risk models is that they implement a `_estimated_cost_for_optimization` method. 12 | 13 | Note: risk models are like cost models, except they return 0 for their `actual_cost` method (because they only influence optimization weights, not actual cash costs). 14 | """ 15 | 16 | def __init__(self, **kwargs): 17 | super().__init__(**kwargs) 18 | 19 | def actual_cost( 20 | self, 21 | t: dt.datetime, 22 | dollars_holdings_plus_trades: pd.Series, 23 | dollars_trades: pd.Series, 24 | ) -> pd.Series: 25 | """Method that calculates per-period costs given period `t` holdings and trades. 26 | 27 | ALWAYS 0 FOR RISK MODELS; DO NOT OVERRIDE. RISK DOESN'T HAVE A CASH COST, IT ONLY AFFECTS OPTIMIZED ASSET WEIGHTS GIVEN `_estimated_cost_for_optimization` RISK (UTILITY) PENALTY. 28 | """ 29 | return 0 30 | 31 | def _estimated_cost_for_optimization( 32 | self, t, weights_portfolio_plus_trades, weights_trades, value 33 | ): 34 | """Optimization (non-cash) cost penalty for assuming associated asset risk. 35 | 36 | Used by optimization strategy to determine trades. 37 | 38 | Not used to calculate simulated costs for backtest performance. 39 | """ 40 | raise NotImplementedError 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to InvestOS 2 | 3 | InvestOS welcomes community contributions! 4 | 5 | ## Developer environment 6 | 7 | 1. Install [Python 3](https://www.python.org/downloads/) 8 | - We support versions >= 3.8. 9 | 2. Install `poetry` following their [installation guide](https://python-poetry.org/docs/#installation). 10 | 3. Install the package with dev dependencies: 11 | 12 | ```sh 13 | poetry install --with dev 14 | ``` 15 | 16 | ## Running the tests 17 | 18 | ```sh 19 | poetry run pytest 20 | ``` 21 | 22 | ## Building the documentation 23 | 24 | TBD. 25 | 26 | ## Code formatting 27 | 28 | This project uses [Ruff](https://docs.astral.sh/ruff/) to lint and format its code. 29 | Both Python files (`*.py`) and IPython notebooks (`*.ipynb`) are reformatted. 30 | 31 | To reformat the codebase, run: 32 | 33 | ```sh 34 | poetry run ruff format . 35 | ``` 36 | 37 | To lint the codebase, run: 38 | 39 | ```sh 40 | poetry run ruff check . 41 | ``` 42 | 43 | You can also use [pre-commit hooks](#pre-commit-hooks) to ensure your changes are formatted properly. 44 | 45 | Ruff also has good editor support, so you should be able to integrate it to your workflow. 46 | 47 | ## Pre-commit hooks 48 | 49 | This repo uses [pre-commit](https://pre-commit.com/) for contributors to be able to quickly validate their changes. 50 | 51 | To enable this: 52 | 53 | 1. Install `pre-commit` following their [installation guide](https://pre-commit.com/#install) 54 | 2. Install the hooks for this repository: 55 | 56 | ```sh 57 | pre-commit install 58 | ``` 59 | 60 | You will now have sanity checks running before every commit, only on your changes. 61 | 62 | You can also run the hooks on all files with: 63 | 64 | ```sh 65 | pre-commit run --all-files 66 | ``` 67 | -------------------------------------------------------------------------------- /investos/portfolio/cost_model/short_holding_cost.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import cvxpy as cvx 4 | import numpy as np 5 | import pandas as pd 6 | 7 | from investos.portfolio.cost_model import BaseCost 8 | from investos.util import get_value_at_t, remove_excluded_columns_pd 9 | 10 | 11 | class ShortHoldingCost(BaseCost): 12 | """Calculates cost for holding short positions, given customizable short_rate.""" 13 | 14 | def __init__(self, short_rates, **kwargs): 15 | super().__init__(**kwargs) 16 | self.short_rates = remove_excluded_columns_pd( 17 | short_rates, 18 | exclude_assets=self.exclude_assets, 19 | include_assets=self.include_assets, 20 | ) 21 | 22 | def _estimated_cost_for_optimization( 23 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 24 | ): 25 | """Estimated holding costs. 26 | 27 | Used by optimization strategy to determine trades. 28 | 29 | Not used to calculate simulated holding costs for backtest performance. 30 | """ 31 | expression = cvx.multiply( 32 | self._get_short_rate(t), cvx.neg(weights_portfolio_plus_trades) 33 | ) 34 | 35 | return cvx.sum(expression), [] 36 | 37 | def get_actual_cost( 38 | self, 39 | t: dt.datetime, 40 | dollars_holdings_plus_trades: pd.Series, 41 | dollars_trades: pd.Series, 42 | ) -> pd.Series: 43 | """Method that calculates per-period (short position) holding costs given period `t` holdings and trades.""" 44 | return sum( 45 | -np.minimum(0, dollars_holdings_plus_trades) * self._get_short_rate(t) 46 | ) 47 | 48 | def _get_short_rate(self, t): 49 | return get_value_at_t(self.short_rates, t) 50 | -------------------------------------------------------------------------------- /guides/introduction/getting_started.md: -------------------------------------------------------------------------------- 1 |

Getting Started

2 | 3 | Welcome to the InvestOS portfolio engineering and backtesting framework! 4 | 5 | InvestOS is an opinionated framework for constructing and backtesting portfolios in a consistent, albeit flexible way. We built it to make institutional-grade backtesting and portfolio optimization simple, extensible, and open-source. 6 | 7 | This guide covers getting up and running with InvestOS. 8 | 9 | Let's jump in! 10 | 11 | ## Prerequisites 12 | 13 | **To run InvestOS you'll need**: 14 | 15 | - [Python +3.8](https://www.python.org/doc/) 16 | - You can [download it here](https://www.python.org/downloads/) 17 | - If you're working on MacOS, you may wish to [install it via Homebrew](https://docs.python-guide.org/starting/install3/osx/) 18 | - [pip](https://packaging.python.org/en/latest/key_projects/#pip) 19 | - For installing InvestOS (and any other Python packages) 20 | - [pip installation instructions here](https://packaging.python.org/en/latest/tutorials/installing-packages/) 21 | 22 | **Although not required, running InvestOS might be easier if you have**: 23 | 24 | - [Poetry](https://python-poetry.org/), a package and dependency manager 25 | - Familiarity with [pandas](https://pandas.pydata.org/) 26 | - The popular Python data analysis package (originally) released by AQR Capital Management 27 | 28 | ## Installation 29 | 30 | If you're using pip: 31 | 32 | ```bash 33 | $ pip install investos 34 | ``` 35 | 36 | If you're using poetry: 37 | 38 | ```bash 39 | $ poetry add investos 40 | ``` 41 | 42 | ## Importing InvestOS 43 | 44 | At the top of your python file or .ipynb, add: 45 | 46 | ```python 47 | import investos as inv 48 | ``` 49 | 50 | ## Next: How InvestOS Works 51 | 52 | Congratulations on setting up InvestOS! 53 | 54 | Let's move on to our next guide: [How InvestOS Works](/guides/introduction/how_investos_works). 55 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/base_constraint.py: -------------------------------------------------------------------------------- 1 | # http://web.cvxr.com/cvx/doc/basics.html#constraints 2 | from investos.util import remove_excluded_columns_np 3 | 4 | 5 | class BaseConstraint: 6 | """ 7 | Base class for constraint objects used in convex portfolio optimization strategies. 8 | 9 | Subclass `BaseConstraint`, and create your own `cvxpy_expression` method to create custom constraints. 10 | """ 11 | 12 | def __init__(self, **kwargs): 13 | # Can only have exclude or include. 14 | # Not sensible to have both. 15 | self.exclude_assets = kwargs.get("exclude_assets", []) 16 | self.include_assets = kwargs.get("include_assets", []) 17 | 18 | def cvxpy_expression( 19 | self, 20 | t, 21 | weights_portfolio_plus_trades, 22 | weights_trades, 23 | portfolio_value, 24 | asset_idx, 25 | ): 26 | weights_portfolio_plus_trades = remove_excluded_columns_np( 27 | weights_portfolio_plus_trades, 28 | asset_idx, 29 | include_assets=self.include_assets, 30 | exclude_assets=self.exclude_assets, 31 | ) 32 | weights_trades = remove_excluded_columns_np( 33 | weights_trades, 34 | asset_idx, 35 | include_assets=self.include_assets, 36 | exclude_assets=self.exclude_assets, 37 | ) 38 | 39 | return self._cvxpy_expression( 40 | t, weights_portfolio_plus_trades, weights_trades, portfolio_value 41 | ) 42 | 43 | def _cvxpy_expression( 44 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 45 | ): 46 | raise NotImplementedError 47 | 48 | def metadata_dict(self): 49 | metadata_dict = {} 50 | 51 | if getattr(self, "limit", None): 52 | metadata_dict["limit"] = self.limit 53 | 54 | return metadata_dict 55 | -------------------------------------------------------------------------------- /investos/portfolio/result/weights_result.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | from investos.portfolio.result.base_result import BaseResult 4 | 5 | 6 | class WeightsResult(BaseResult): 7 | """For generating backtest results from portfolio weights and historical returns. 8 | 9 | In this model, trades for each period happen after returns. 10 | """ 11 | 12 | def __init__( 13 | self, 14 | initial_weights, 15 | trade_weights, 16 | actual_returns, 17 | aum=100_000_000, 18 | *args, 19 | **kwargs, 20 | ): 21 | self.set_dollars_holdings_at_next_t( 22 | initial_weights, trade_weights, actual_returns, aum 23 | ) 24 | self.risk_free = kwargs.get( 25 | "risk_free", pd.Series(0.0, index=actual_returns.index) 26 | ) 27 | self.benchmark = kwargs.get( 28 | "benchmark", pd.Series(0.0, index=actual_returns.index) 29 | ) 30 | start_date = kwargs.get("start_date", trade_weights.index[0]) 31 | end_date = kwargs.get("end_date", trade_weights.index[-1]) 32 | super().__init__( 33 | start_date=start_date, 34 | end_date=end_date, 35 | actual_returns=actual_returns, 36 | **kwargs, 37 | ) 38 | 39 | def set_dollars_holdings_at_next_t( 40 | self, initial_weights, trade_weights, returns, aum 41 | ): 42 | print( 43 | "Calculating holding values from trades, returns, and initial weights and AUM..." 44 | ) 45 | 46 | dollars_holdings = initial_weights 47 | for t in trade_weights.index: 48 | r = returns.loc[t] 49 | dollars_trades = trade_weights.loc[t] 50 | dollars_holdings_at_next_t = (dollars_holdings + dollars_trades) * (1.0 + r) 51 | self.save_position(t, dollars_trades, dollars_holdings_at_next_t) 52 | dollars_holdings = dollars_holdings_at_next_t 53 | 54 | self.dollars_trades *= aum 55 | self.dollars_holdings_at_next_t *= aum 56 | 57 | print("Done calculations.") 58 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/long_constraint.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.constraint_model.base_constraint import BaseConstraint 2 | 3 | 4 | class LongOnlyConstraint(BaseConstraint): 5 | """ 6 | A constraint that enforces no short positions. Including no short cash position. 7 | 8 | Parameters 9 | ---------- 10 | **kwargs : 11 | Additional keyword arguments. 12 | """ 13 | 14 | def __init__(self, **kwargs): 15 | super().__init__(**kwargs) 16 | 17 | def _cvxpy_expression( 18 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 19 | ): 20 | return weights_portfolio_plus_trades >= 0.0 21 | 22 | 23 | class LongCashConstraint(BaseConstraint): 24 | """ 25 | A constraint that enforces no short cash positions. 26 | 27 | Parameters 28 | ---------- 29 | **kwargs : 30 | Additional keyword arguments. 31 | """ 32 | 33 | def __init__(self, include_assets=["cash"], **kwargs): 34 | super().__init__(include_assets=include_assets, **kwargs) 35 | 36 | def _cvxpy_expression( 37 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 38 | ): 39 | return weights_portfolio_plus_trades >= 0.0 40 | 41 | 42 | class EqualLongShortConstraint(BaseConstraint): 43 | """ 44 | A constraint that enforces equal long and short exposure. 45 | 46 | Parameters 47 | ---------- 48 | **kwargs : 49 | Additional keyword arguments. 50 | """ 51 | 52 | def __init__(self, exclude_assets=["cash"], **kwargs): 53 | super().__init__(exclude_assets=exclude_assets, **kwargs) 54 | 55 | def _cvxpy_expression( 56 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 57 | ): 58 | return sum(weights_portfolio_plus_trades) == 0.0 59 | 60 | 61 | class EqualLongShortTradeConstraint(BaseConstraint): 62 | def __init__(self, exclude_assets=["cash"], **kwargs): 63 | super().__init__(exclude_assets=exclude_assets, **kwargs) 64 | 65 | def _cvxpy_expression( 66 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 67 | ): 68 | return sum(weights_trades) == 0.0 69 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/return_constraint.py: -------------------------------------------------------------------------------- 1 | import cvxpy as cvx 2 | 3 | from investos.portfolio.constraint_model.base_constraint import BaseConstraint 4 | from investos.util import get_value_at_t, remove_excluded_columns_pd 5 | 6 | 7 | class TradeReturnConstraint(BaseConstraint): 8 | def __init__(self, forecast_returns, costs=[], limit=0.01, **kwargs): 9 | self.costs = costs 10 | self.limit = limit 11 | super().__init__(**kwargs) 12 | self.forecast_returns = remove_excluded_columns_pd( 13 | forecast_returns, self.exclude_assets 14 | ) 15 | self.forecast_returns -= self.limit 16 | 17 | def _cvxpy_expression( 18 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 19 | ): 20 | sum_forecast_alpha = cvx.sum( 21 | cvx.multiply( 22 | get_value_at_t(self.forecast_returns, t).values, weights_trades 23 | ) 24 | ) 25 | 26 | costs_li = [] 27 | for cost in self.costs: 28 | # Trade specific, not portfolio level (hence 2 weights_trades and no weights_portfolio_plus_trades) 29 | cost_expr, _ = cost.cvxpy_expression( 30 | t, 31 | weights_trades, 32 | weights_trades, 33 | portfolio_value, 34 | self.forecast_returns.index, 35 | ) 36 | costs_li.append(cost_expr) 37 | 38 | return (sum_forecast_alpha - cvx.sum(costs_li)) >= 0 39 | 40 | 41 | class TradeGrossReturnConstraint(BaseConstraint): 42 | def __init__(self, forecast_returns, limit=0.01, **kwargs): 43 | self.limit = limit 44 | super().__init__(**kwargs) 45 | self.forecast_returns = remove_excluded_columns_pd( 46 | forecast_returns, self.exclude_assets 47 | ) 48 | 49 | def _cvxpy_expression( 50 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 51 | ): 52 | sum_forecast_alpha = cvx.sum( 53 | cvx.multiply( 54 | get_value_at_t(self.forecast_returns, t).values, weights_trades 55 | ) 56 | ) 57 | 58 | return sum_forecast_alpha <= self.limit 59 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | # Configuration file for the Sphinx documentation builder. 5 | # 6 | # For the full list of built-in configuration values, see the documentation: 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 8 | 9 | # -- Project information ----------------------------------------------------- 10 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 11 | 12 | project = "InvestOS" 13 | copyright = "2025, InvestOS" 14 | author = "Charlie Reese, ForecastOS" 15 | release = "0.9.2" 16 | 17 | # -- General configuration --------------------------------------------------- 18 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 19 | 20 | extensions = ["sphinx.ext.napoleon"] # Custom 21 | 22 | templates_path = ["_templates"] 23 | exclude_patterns = [] 24 | 25 | 26 | # -- Options for HTML output ------------------------------------------------- 27 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 28 | 29 | html_theme = "alabaster" 30 | html_theme_options = { 31 | # sidebar_collapse: False, 32 | # sidebar_includehidden: True, 33 | } 34 | html_static_path = ["_static"] 35 | 36 | html_css_files = [ 37 | "theme_overrides.css", # In _static 38 | "tailwind_output.css", # In _static 39 | ] 40 | 41 | html_sidebars = { 42 | "**": [ 43 | "searchbox.html", 44 | "relations.html", 45 | # 'sourcelink.html', 46 | # 'globaltoc.html', 47 | # 'localtoc.html', 48 | "fulltoc.html", 49 | # 'navigation.html', 50 | ] 51 | } 52 | 53 | # -- Custom configuration ---------------------------------------------------- 54 | sys.path.insert(0, os.path.abspath("../..")) 55 | sys.path.insert(0, os.path.abspath("investos")) 56 | 57 | # For more information on below options: 58 | # --> Guide on getting set up (+ RTD dev env): https://samnicholls.net/2016/06/15/how-to-sphinx-readthedocs/ 59 | # --> https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#configuration 60 | # --> https://numpydoc.readthedocs.io/en/latest/format.html 61 | # Linking to python objects in documentation: 62 | # --> https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#cross-referencing-python-objects 63 | 64 | # napoleon_google_docstring = False 65 | # napoleon_use_param = False 66 | # napoleon_use_ivar = True 67 | -------------------------------------------------------------------------------- /investos/portfolio/risk_model/risk_util.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | from scipy.stats.mstats import winsorize 4 | from sklearn.impute import SimpleImputer 5 | from sklearn.preprocessing import StandardScaler 6 | 7 | fos_return_uuid = "ea4d2557-7f8f-476b-b4d3-55917a941bb5" 8 | fos_return_name = "return_1d" 9 | 10 | fos_risk_factor_uuids_dict = { 11 | # Beta 12 | "beta_24m": "79005629-e5a9-40ff-b677-b1278c6fa366", 13 | # Momentum 14 | "return_252d": "ed02d053-a0e1-4447-aff2-ad399f770f14", 15 | # Size 16 | "market_cap_open_dil": "dfa7e6a3-671d-41b2-89e3-10b7bdcf7af9", 17 | # Quality (margin) 18 | "net_income_div_sales_ltm": "7f1e058f-46b6-406f-81a1-d8a5b81371a2", 19 | # Growth 20 | "sales_ltm_growth_over_sales_ltm_lag_1a": "e6035b0a-65b9-409d-a02e-b3d47ca422e2", 21 | # Leverage 22 | "debt_total_prev_1q_to_ebit_ltm": "53a422bf-1dab-4d1e-a9a7-2478a226435b", 23 | # Value 24 | "market_cap_open_dil_to_operating_excl_wc_cf_ltm": "5f050fce-5099-4ce9-a737-5d8f536c5826", # ltm market_cap_open_dil to operating_excl_wc_cf multiple. ID is FactSet Sym ID. 25 | } 26 | fos_risk_factor_adj_dict = { 27 | "normalization": { 28 | "market_cap_open_dil": [np.log], 29 | }, 30 | "winsorization": { 31 | "return_1d": [0.01, 0.01], 32 | "net_income_div_sales_ltm": [0.2, 0.2], 33 | "sales_ltm_growth_over_sales_ltm_lag_1a": [0.2, 0.2], 34 | }, 35 | } 36 | winsorization_default = [0.10, 0.10] 37 | 38 | 39 | def drop_na_and_inf(df: pd.DataFrame): 40 | return df.replace([np.inf, -np.inf], np.nan).dropna() 41 | 42 | 43 | def wins_std_mean_fill(group, dont_std_cols, adj_dict, drop_cols=["id"]): 44 | cols = group.columns.drop(drop_cols) 45 | imputer = SimpleImputer(strategy="mean") 46 | 47 | for col in cols: 48 | # Winsorizing numeric columns within the group 49 | wins_limits = adj_dict.get("winsorization", {}).get(col, winsorization_default) 50 | group[col] = winsorize(group[col], limits=wins_limits) 51 | group[col] = imputer.fit_transform(group[[col]]) 52 | 53 | for col in cols: 54 | for func in adj_dict.get("normalization", {}).get(col, []): 55 | group[col] = func(group[col]) 56 | 57 | cols = [col for col in cols if col not in dont_std_cols] 58 | 59 | # Standardizing numeric columns within the group 60 | scaler = StandardScaler() 61 | group[cols] = scaler.fit_transform(group[cols]) 62 | 63 | return group 64 | -------------------------------------------------------------------------------- /guides/off_the_shelf/cost_models.md: -------------------------------------------------------------------------------- 1 |

Using Off-The-Shelf Cost Models

2 | 3 | ## Usage 4 | 5 | Using cost models is simple! 6 | 7 | You simply pass instantiated cost model instances into your desired investment strategy: 8 | 9 | ``` 10 | from investos.portfolio.strategy import YourDesiredStrategy 11 | 12 | strategy = YourDesiredStrategy( 13 | actual_returns = actual_returns, 14 | ..., 15 | costs=[ 16 | CostModelA(*args, **kwargs), 17 | CostModelB(*args, **kwargs) 18 | ], 19 | ) 20 | ``` 21 | 22 | and that's it! 23 | 24 | ## Optional Arguments 25 | 26 | All cost models take the following optional arguments: 27 | 28 | - exclude_assets: [str] 29 | - include_assets: [str] 30 | - Can't be used with exclude_assets 31 | - gamma: float = 1 32 | - Gamma doesn't impact actual costs 33 | - Gamma only (linearly) increases estimated costs during convex optimization trade list generation 34 | - If you aren't using a convex optimization investment strategy, gamma does nothing 35 | 36 | --- 37 | 38 | InvestOS provides the following cost models: 39 | 40 | ## ShortHoldingCost 41 | 42 | [ShortHoldingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/short_holding_cost.py) calculates per period cost for holding short positions, given customizable short_rate. 43 | 44 | To instantiate ShortHoldingCost you will need to set the following arguments: 45 | 46 | - short_rates: pd.DataFrame | pd.Series | float, 47 | 48 | ## TradingCost 49 | 50 | [TradingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/trading_cost.py) calculates per period cost for trades based on forecast spreads, standard deviations, volumes, and actual prices. 51 | 52 | To instantiate TradingCost you will need to set the following arguments: 53 | 54 | - forecast_volume: pd.DataFrame | pd.Series, 55 | - forecast_std_dev: pd.DataFrame | pd.Series, 56 | - actual_prices: pd.DataFrame, 57 | - sensitivity_coeff: float = 1 58 | - For scaling transaction cost from market impact 59 | - 1 assumes trading 1 day's volume moves asset price by 1 forecast standard deviation in returns 60 | - half_spread: pd.DataFrame | pd.Series | float 61 | - Half of forecast spread between bid and ask for each asset 62 | - This model assumes half_spread represents the cost of executing a trade 63 | 64 | ## Next: The Choice Is Yours 65 | 66 | Want to explore creating your own custom cost model? Check out [Custom Cost Models](/guides/bespoke/custom_cost_models). 67 | 68 | Want to learn more about using constraint models? Check out [Constraint Models](/guides/off_the_shelf/constraint_models). 69 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/leverage_constraint.py: -------------------------------------------------------------------------------- 1 | import cvxpy as cvx 2 | 3 | from investos.portfolio.constraint_model.base_constraint import BaseConstraint 4 | 5 | 6 | class MaxLeverageConstraint(BaseConstraint): 7 | """ 8 | A constraint that enforces a limit on the (absolute) leverage of the portfolio. 9 | 10 | E.g. For leverage of 2.0x, a portfolio with 100MM net value 11 | (i.e. the portfolio value if it were converted into cash, 12 | ignoring liquidation / trading costs) 13 | could have 200MM of (combined long and short) exposure. 14 | """ 15 | 16 | def __init__(self, limit: float = 1.0, exclude_assets=["cash"], **kwargs): 17 | super().__init__(exclude_assets=exclude_assets, **kwargs) 18 | self.limit = limit 19 | 20 | def _cvxpy_expression( 21 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 22 | ): 23 | return cvx.sum(cvx.abs(weights_portfolio_plus_trades)) <= self.limit 24 | 25 | 26 | class MaxShortLeverageConstraint(BaseConstraint): 27 | """ 28 | A constraint that enforces a limit on the short leverage of the portfolio. 29 | """ 30 | 31 | def __init__(self, limit: float = 1.0, exclude_assets=["cash"], **kwargs): 32 | super().__init__(exclude_assets=exclude_assets, **kwargs) 33 | self.limit = limit 34 | 35 | def _cvxpy_expression( 36 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 37 | ): 38 | return cvx.sum(cvx.abs(cvx.neg(weights_portfolio_plus_trades))) <= self.limit 39 | 40 | 41 | class MaxLongLeverageConstraint(BaseConstraint): 42 | """ 43 | A constraint that enforces a limit on the long leverage of the portfolio. 44 | """ 45 | 46 | def __init__(self, limit: float = 1.0, exclude_assets=["cash"], **kwargs): 47 | super().__init__(exclude_assets=exclude_assets, **kwargs) 48 | self.limit = limit 49 | 50 | def _cvxpy_expression( 51 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 52 | ): 53 | return cvx.sum(cvx.pos(weights_portfolio_plus_trades)) <= self.limit 54 | 55 | 56 | class MaxLongTradeLeverageConstraint(BaseConstraint): 57 | def __init__(self, limit=0.025, **kwargs): 58 | self.limit = limit 59 | super().__init__(**kwargs) 60 | 61 | def _cvxpy_expression( 62 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 63 | ): 64 | return cvx.sum(cvx.abs(cvx.pos(weights_trades))) <= self.limit 65 | 66 | 67 | class MaxShortTradeLeverageConstraint(BaseConstraint): 68 | def __init__(self, limit=0.025, **kwargs): 69 | self.limit = limit 70 | super().__init__(**kwargs) 71 | 72 | def _cvxpy_expression( 73 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 74 | ): 75 | return cvx.sum(cvx.abs(cvx.neg(weights_trades))) <= self.limit 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "investos" 3 | version = "0.9.2" 4 | description = "Reliable backtesting and portfolio optimization for investors who want to focus on generating alpha" 5 | authors = ["Charlie Reese", "ForecastOS"] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "investos"}] 9 | homepage = "https://investos.io/" 10 | repository = "https://github.com/forecastos/investos" 11 | documentation = "https://investos.readthedocs.io/en/latest/" 12 | keywords = ["investing", "alpha", "backtesting", "portfolio", "optimization", "forecastos"] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: Developers", 16 | "Intended Audience :: Financial and Insurance Industry", 17 | "License :: OSI Approved :: MIT License", 18 | "Natural Language :: English", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.8", 25 | "Topic :: Office/Business :: Financial :: Investment", 26 | "Topic :: Software Development :: Testing", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = "^3.9" 31 | cvxpy = "^1.5.4" 32 | requests = "^2.31.0" 33 | numpy = ">=1.24.3" 34 | pandas = "^2.0.1" 35 | forecastos = "^0.2.0" 36 | statsmodels = "^0.14.2" 37 | dask = "^2024.8.0" 38 | dask-cloudprovider = "^2022.10.0" 39 | aiobotocore = "^2.13.2" 40 | 41 | [tool.poetry.group.dev] 42 | optional = true 43 | 44 | [tool.poetry.group.dev.dependencies] 45 | jupyter = "^1.0.0" 46 | pandas-market-calendars = "^4.1.4" 47 | pytest = "^7.3.1" 48 | ruff = "^0.3.0" 49 | sphinx = "^7.0.0" 50 | jupyterlab = "^3" 51 | plotly = "^5.15.0" 52 | pyarrow = ">=17.0.0" 53 | xgboost = "^1.7.6" 54 | scikit-learn = "^1.3.0" 55 | matplotlib = "^3.9.1" 56 | 57 | [build-system] 58 | requires = ["poetry-core"] 59 | build-backend = "poetry.core.masonry.api" 60 | 61 | [tool.pytest.ini_options] 62 | python_files = [ 63 | "test_*.py", 64 | "*_test.py", 65 | ] 66 | norecursedirs = [ 67 | "__pycache__", 68 | ".git", 69 | "docs", 70 | "examples", 71 | ] 72 | 73 | [tool.ruff] 74 | target-version = "py38" 75 | extend-include = [ 76 | "*.ipynb", 77 | ] 78 | exclude = [ 79 | "__pycache__", 80 | ".git/", 81 | ".pytest_cache/", 82 | ] 83 | 84 | [tool.ruff.lint] 85 | exclude = ["*.ipynb"] # Only format Jupyter notebooks, for now 86 | select = [ 87 | "B", 88 | "E", 89 | "F", 90 | "I", 91 | "Q", 92 | "UP", 93 | "W", 94 | ] 95 | ignore = [ 96 | "B006", # Mutable argument default 97 | "B008", # Function call in default argument 98 | "E501", # Line too long 99 | ] 100 | 101 | [tool.ruff.lint.per-file-ignores] 102 | "__init__.py" = [ 103 | 'F401', # Unused import 104 | 'F403', # Wildcard import 105 | ] 106 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/weight_constraint.py: -------------------------------------------------------------------------------- 1 | from investos.portfolio.constraint_model.base_constraint import BaseConstraint 2 | 3 | 4 | class MaxWeightConstraint(BaseConstraint): 5 | """ 6 | A constraint that enforces a limit on the weight of each asset in a portfolio. 7 | 8 | Parameters 9 | ---------- 10 | limit : float, optional 11 | The maximum weight of each asset in the portfolio. Defaults to 0.025. 12 | 13 | **kwargs : 14 | Additional keyword arguments. 15 | """ 16 | 17 | def __init__(self, limit: float = 0.025, exclude_assets=["cash"], **kwargs): 18 | super().__init__(exclude_assets=exclude_assets, **kwargs) 19 | self.limit = limit 20 | 21 | def _cvxpy_expression( 22 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 23 | ): 24 | return weights_portfolio_plus_trades <= self.limit 25 | 26 | 27 | class MinWeightConstraint(BaseConstraint): 28 | """ 29 | A constraint that enforces a limit on the weight of each asset in a portfolio. 30 | 31 | Parameters 32 | ---------- 33 | limit : float, optional 34 | The minimum weight of each asset in the portfolio. Defaults to -0.025. 35 | 36 | **kwargs : 37 | Additional keyword arguments. 38 | """ 39 | 40 | def __init__(self, limit: float = -0.025, exclude_assets=["cash"], **kwargs): 41 | super().__init__(exclude_assets=exclude_assets, **kwargs) 42 | self.limit = limit 43 | 44 | def _cvxpy_expression( 45 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 46 | ): 47 | return weights_portfolio_plus_trades >= self.limit 48 | 49 | 50 | class ZeroWeightConstraint(BaseConstraint): 51 | def __init__(self, **kwargs): 52 | super().__init__(**kwargs) 53 | 54 | def _cvxpy_expression( 55 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 56 | ): 57 | return weights_portfolio_plus_trades == 0 58 | 59 | 60 | class ZeroTradeWeightConstraint(BaseConstraint): 61 | def __init__(self, **kwargs): 62 | super().__init__(**kwargs) 63 | 64 | def _cvxpy_expression( 65 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 66 | ): 67 | return weights_trades == 0 68 | 69 | 70 | class MaxTradeWeightConstraint(BaseConstraint): 71 | def __init__(self, limit=0.05, **kwargs): 72 | self.limit = limit 73 | super().__init__(**kwargs) 74 | 75 | def _cvxpy_expression( 76 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 77 | ): 78 | return weights_trades <= self.limit 79 | 80 | 81 | class MinTradeWeightConstraint(BaseConstraint): 82 | def __init__(self, limit=-0.03, **kwargs): 83 | self.limit = limit 84 | super().__init__(**kwargs) 85 | 86 | def _cvxpy_expression( 87 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 88 | ): 89 | return weights_trades >= self.limit 90 | -------------------------------------------------------------------------------- /guides/bespoke/custom_constraint_models.md: -------------------------------------------------------------------------------- 1 |

Creating Custom Constraint Models

2 | 3 | **A quick note:** if you aren't using a convex optimization based investment strategy (like SPO), constraint models don't do anything! 4 | 5 | ## Extending BaseConstraint 6 | 7 | The [BaseConstraint](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/constraint_model/base_constraint.py) class provides a foundational structure for creating custom constraint models. 8 | 9 | Below is a step-by-step guide for extending BaseConstraint. 10 | 11 | ### Import Required Modules: 12 | 13 | First, ensure you have the necessary modules imported: 14 | 15 | ```python 16 | import datetime as dt 17 | import pandas as pd 18 | import numpy as np 19 | import cvxpy as cvx 20 | 21 | from investos.portfolio.constraint_model import BaseConstraint 22 | from investos.util import get_value_at_t 23 | ``` 24 | 25 | ### Define the Custom Constraint Class: 26 | 27 | Subclass `BaseConstraint` to implement your desired constraint model. 28 | 29 | ```python 30 | class CustomConstraint(BaseConstraint): 31 | ``` 32 | 33 | ### Initialize Custom Attributes (Optional): 34 | 35 | You may want to add additional attributes specific to your constraint model. Override the `__init__` method: 36 | 37 | ```python 38 | def __init__(self, *args, custom_param=None, **kwargs): 39 | super().__init__(*args, **kwargs) 40 | self.custom_param = custom_param 41 | ``` 42 | 43 | ### Implement the `_cvxpy_expression` Method: 44 | 45 | **This is the core method** where your constraint logic resides. 46 | 47 | Given a datetime `t`, a numpy-like array of asset holding weights `weights_portfolio_plus_trades`, a numpy-like array of trade weights `weights_trades`, and a portfolio value `portfolio_value`, return a `CVXPY` constraint expression. 48 | 49 | See [MaxLeverageConstraint](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/constraint_model/leverage_constraint.py) for inspiration: 50 | 51 | ```python 52 | def _cvxpy_expression( 53 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 54 | ): 55 | return cvx.sum(cvx.abs(weights_portfolio_plus_trades)) <= self.limit 56 | 57 | ``` 58 | 59 | ### Test Your Constraint Model: 60 | 61 | You can test that your custom constraint model generates constraints as expected for a specific datetime period: 62 | 63 | ```python 64 | actual_returns = pd.DataFrame(...) # Add your data here. Each asset should be a column, and it should be indexed by datetime 65 | initial_holdings = pd.Series(...) # Holding values, indexed by asset 66 | 67 | strategy = SPO( 68 | actual_returns=actual_returns, 69 | constraints=[CustomConstraint] 70 | ) 71 | 72 | trade_list = strategy.generate_trade_list( 73 | initial_holdings, 74 | dt.datetime.now() 75 | ) 76 | ``` 77 | 78 | You can also plug your custom constraint model into BacktestController (through your investment strategy) to run a full backtest! 79 | 80 | ```python 81 | backtest_controller = inv.portfolio.BacktestController( 82 | strategy=strategy 83 | ) 84 | ``` 85 | -------------------------------------------------------------------------------- /investos/portfolio/cost_model/base_cost.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import datetime as dt 3 | 4 | import pandas as pd 5 | 6 | import investos.util as util 7 | 8 | 9 | class BaseCost: 10 | """Base cost model for InvestOS. 11 | Other cost models should subclass BaseCost. 12 | The only requirement of custom cost models is that they implement `_estimated_cost_for_optimization` and `get_actual_cost`. 13 | """ 14 | 15 | def __init__(self, **kwargs): 16 | self.gamma = 1 # Can change without setting directly as: gamma * BaseCost(). Note that gamma doesn't impact actual costs in backtester / simulated performance, just trades in optimization strategy. 17 | self.exclude_assets = kwargs.get("exclude_assets", ["cash"]) 18 | self.include_assets = kwargs.get("include_assets", []) 19 | 20 | def cvxpy_expression( 21 | self, 22 | t, 23 | weights_portfolio_plus_trades, 24 | weights_trades, 25 | portfolio_value, 26 | asset_idx, 27 | ): 28 | weights_portfolio_plus_trades = util.remove_excluded_columns_np( 29 | weights_portfolio_plus_trades, 30 | asset_idx, 31 | exclude_assets=self.exclude_assets, 32 | include_assets=self.include_assets, 33 | ) 34 | weights_trades = util.remove_excluded_columns_np( 35 | weights_trades, 36 | asset_idx, 37 | exclude_assets=self.exclude_assets, 38 | include_assets=self.include_assets, 39 | ) 40 | 41 | cost, constraints = self._estimated_cost_for_optimization( 42 | t, weights_portfolio_plus_trades, weights_trades, portfolio_value 43 | ) 44 | return self.gamma * cost, constraints 45 | 46 | def actual_cost( 47 | self, 48 | t: dt.datetime, 49 | dollars_holdings_plus_trades: pd.Series, 50 | dollars_trades: pd.Series, 51 | ) -> pd.Series: 52 | dollars_holdings_plus_trades = util.remove_excluded_columns_pd( 53 | dollars_holdings_plus_trades, 54 | exclude_assets=self.exclude_assets, 55 | include_assets=self.include_assets, 56 | ) 57 | dollars_trades = util.remove_excluded_columns_pd( 58 | dollars_trades, 59 | exclude_assets=self.exclude_assets, 60 | include_assets=self.include_assets, 61 | ) 62 | 63 | return self.get_actual_cost(t, dollars_holdings_plus_trades, dollars_trades) 64 | 65 | def __mul__(self, other): 66 | """Read the gamma parameter as a multiplication; so you can change self.gamma without setting it directly as: gamma * BaseCost()""" 67 | newobj = copy.copy(self) 68 | newobj.gamma *= other 69 | return newobj 70 | 71 | def __rmul__(self, other): 72 | """Read the gamma parameter as a multiplication; so you can change self.gamma without setting it directly as: gamma * BaseCost()""" 73 | return self.__mul__(other) 74 | 75 | def metadata_dict(self): 76 | metadata_dict = {} 77 | 78 | metadata_dict["gamma"] = self.gamma 79 | 80 | if getattr(self, "price_movement_sensitivity", None): 81 | metadata_dict["price_movement_sensitivity"] = self.limit 82 | 83 | return metadata_dict 84 | -------------------------------------------------------------------------------- /guides/off_the_shelf/risk_models.md: -------------------------------------------------------------------------------- 1 |

Using Off-The-Shelf Risk Models

2 | 3 | ## Usage 4 | 5 | Using risk models is simple! 6 | 7 | You simply pass instantiated risk model instances into your desired investment strategy: 8 | 9 | ``` 10 | import investos as inv 11 | from investos.portfolio.risk_model import * 12 | 13 | risk_model = FactorRisk( 14 | factor_covariance=df_factor_covar, 15 | factor_loadings=df_loadings, 16 | idiosyncratic_variance=df_idio, 17 | exclude_assets=["cash"] 18 | ) 19 | 20 | strategy = inv.portfolio.strategy.SPO( 21 | actual_returns=df_actual_returns, 22 | ... 23 | risk_model=risk_model, 24 | ) 25 | ``` 26 | 27 | and that's it! 28 | 29 | _Note: some simple investment strategies do not support risk models. If you pass a risk model to one of these strategies, it will have no effect._ 30 | 31 | ## Optional Arguments 32 | 33 | All risk models take the following optional arguments: 34 | 35 | - `exclude_assets`: [str] 36 | - `include_assets`: [str] 37 | - Can't be used with exclude_assets 38 | - `gamma`: float = 1 39 | - Linearly increases utility penalty during convex optimization trade list generation 40 | - Increase to penalize risk more, decrease to penalize risk less 41 | 42 | --- 43 | 44 | InvestOS provides the following risk models: 45 | 46 | ## FactorRisk 47 | 48 | [FactorRisk](https://github.com/ForecastOS/investos/tree/v0.3.10/investos/portfolio/risk_model/factor_risk.py) is a multi-factor risk model. 49 | 50 | To instantiate FactorRisk you will need to set the following arguments: 51 | 52 | - `factor_covariance`: pd.DataFrame 53 | - Columns and index keys should be risk factors 54 | - Values are covariances 55 | - Optionally: with date as the first key in multi-index dataframe; to allow risk estimates to change throughout time. By default, will look for risk date equal to or less than trade date 56 | - `factor_loadings`: pd.DataFrame 57 | - Columns should be unique asset IDs 58 | - Index keys should be risk factors 59 | - Values are loadings 60 | - Optionally: with date as the first key in multi-index dataframe; to allow risk estimates to change throughout time. By default, will look for risk date equal to or less than trade date 61 | - `idiosyncratic_variance`: pd.DataFrame | pd.Series 62 | - Columns should be unique asset IDs 63 | - Values are idiosyncratic risks (residuals to factor risk) 64 | - Optionally: with date as index in dataframe; to allow risk estimates to change throughout time. By default, will look for risk date equal to or less than trade date 65 | 66 | ## StatFactorRisk 67 | 68 | [StatFactorRisk](https://github.com/ForecastOS/investos/tree/v0.3.10/investos/portfolio/risk_model/stat_factor_risk.py) creates a PCA-factor based risk model from `actual_returns`. To use this model, there must be more periods in `actual_returns` than assets in your investment strategy. 69 | 70 | To instantiate StatFactorRisk you will need to set the following arguments: 71 | 72 | - `actual_returns`: pd.DataFrame 73 | 74 | You may optionally set the following arguments: 75 | 76 | - `n_factors`: integer = 5 77 | - `start_date`: datetime = actual_returns.index[0] 78 | - `end_date`: datetime = actual_returns.index[-1] 79 | - `recalc_each_i_periods`: integer|boolean = False 80 | - `timedelta`: pd.Timedelta = pd.Timedelta("730 days") 81 | - Lookback period for calculating risk from actual_returns 82 | 83 | ## Next: The Choice Is Yours 84 | 85 | Want to explore an end-to-end example? Check out [Single Period Optimization](/guides/simple_examples/spo). 86 | -------------------------------------------------------------------------------- /guides/reporting/analyzing_backtest_results.md: -------------------------------------------------------------------------------- 1 |

Analyzing Backtest Results

2 | 3 | The [BaseResult](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py) class captures portfolio data and calculates performance metrics for your investment strategy. 4 | 5 | An instance of BaseResult is returned by calling the method `generate_positions()` on an instance of BacktestController. 6 | 7 | ## Retrieving a Portfolio Summary 8 | 9 | To get a string summary of your backtest result: 10 | 11 | ```python 12 | result_instance.summary 13 | ``` 14 | 15 | ## Reporting Properties 16 | 17 | BaseResult instances have the following properties for performance reporting: 18 | 19 | 1. `actual_returns`: Dataframe of actual returns for assets. 20 | 2. `annualized_benchmark_return`: Annualized return for the benchmark over the entire period. 21 | 3. `annualized_excess_return`: Annualized excess return for the entire period. 22 | 4. `annualized_return`: Annualized return for the entire period under review. 23 | 5. `annualized_return_over_cash`: Annualized return over cash for the entire period. 24 | 6. `benchmark_returns`: Series of returns for the benchmark. 25 | 7. `benchmark_value`: Series of simulated portfolio values if invested 100% in the benchmark at time 0. 26 | 8. `cash_column_name`: String of cash column name in holdings and trades. 27 | 9. `dollars_holdings`: Dataframe of asset holdings at the beginning of each datetime period. 28 | 10. `excess_returns`: Series of returns in excess of the benchmark. 29 | 11. `excess_risk_annualized`: Risk in excess of the benchmark. 30 | 12. `information_ratio`: (Annualized) Information Ratio of the portfolio. 31 | 13. `num_periods`: Number of periods in the backtest. 32 | 14. `portfolio_hit_rate`: Proportion of periods in which the portfolio had positive returns. 33 | 15. `periods_per_year`: Float representing the number of periods per year in the backtest period. 34 | 16. `returns`: Series of the returns for each datetime period compared to the previous period. 35 | 17. `returns_over_cash`: Series of returns in excess of risk-free returns. 36 | 18. `risk_free_returns`: Series of risk-free returns. 37 | 19. `risk_over_cash_annualized`: Risk in excess of the risk-free rate. 38 | 20. `sharpe_ratio`: (Annualized) Sharpe Ratio of the portfolio. 39 | 21. `total_benchmark_return`: Return over the benchmark for the entire period under review. 40 | 22. `total_excess_return`: Excess return for the entire period (portfolio return minus benchmark return). 41 | 23. `total_return`: Total return for the entire period under review. 42 | 24. `total_return_over_cash`: Total returns over cash for the entire period under review. 43 | 25. `total_risk_free_return`: Total return over the risk-free rate for the entire period. 44 | 26. `trades`: Series of trades (also available as `dollars_trades`). 45 | 27. `portfolio_value`: Series of the value of the portfolio for each datetime period. 46 | 28. `portfolio_value_with_benchmark`: Dataframe with simulated portfolio and benchmark values. 47 | 29. `years_forecast`: Float representing the number of years in the backtest period. 48 | 49 | ## Extend BaseResult For Custom Reporting 50 | 51 | If the BaseResult class is missing a property or method you wish it had, you can easily extend the class (i.e. create a new class that inherits from BaseResult) and add your desired functionality! 52 | 53 | BacktestController accepts a `results_model` kwarg (note: if passed, it expects the model to be instantiated). Don't be afraid to roll out your own custom reporting functionality! 54 | 55 | ## Next: Analyzing External Portfolios 56 | 57 | It's possible to backtest a portfolio created outside of InvestOS. Let's review how that works: [Analyzing External Portfolios](/guides/reporting/external_portfolios). 58 | -------------------------------------------------------------------------------- /guides/simple_examples/rank_long_short.md: -------------------------------------------------------------------------------- 1 |

Rank Long Short

2 | 3 | ## What We Need 4 | 5 | In order to backtest a portfolio using [RankLongShort](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/rank_long_short.py), we'll need: 6 | 7 | - A metric to rank assets by over time: `metric_to_rank` 8 | - In this example, we'll use forecast returns for stocks, but we could also use LTM sales, age of CEO, etc. 9 | - Stock returns over the time periods we wish to backtest: `actual_returns` 10 | - Start and end dates: `start_date` and `end_date` 11 | 12 | In order to make this example as easy as possible, we've prepared, and will use, forecast and actual returns from 2017 - 2018 for a universe of 319 stocks. We will also exclude cost models. 13 | 14 | ## Sample Code For a RankLongShort Backtest 15 | 16 | ```python 17 | import pandas as pd 18 | import investos as inv 19 | 20 | actual_returns = pd.read_parquet("https://investos.io/example_actual_returns.parquet") 21 | forecast_returns = pd.read_parquet("https://investos.io/example_forecast_returns.parquet") 22 | 23 | strategy = inv.portfolio.strategy.RankLongShort( 24 | actual_returns = actual_returns, 25 | metric_to_rank = forecast_returns, 26 | leverage=1.6, 27 | ratio_long=130, 28 | ratio_short=30, 29 | percent_long=0.2, 30 | percent_short=0.2, 31 | n_periods_held=60, 32 | cash_column_name="cash" 33 | ) 34 | 35 | portfolio = inv.portfolio.BacktestController( 36 | strategy=strategy, 37 | start_date='2017-01-01', 38 | end_date='2018-01-01', 39 | aum=100_000_000 40 | ) 41 | 42 | backtest_result = portfolio.generate_positions() 43 | backtest_result.summary 44 | ``` 45 | 46 | That's all that's required to run your first (RankLongShort) backtest! 47 | 48 | When `backtest_result.summary` is executed, it will output summary backtest results similar to the below: 49 | 50 | ```python 51 | # Initial timestamp 2017-01-03 00:00:00 52 | # Final timestamp 2017-12-29 00:00:00 53 | # Total portfolio return (%) 17.22% 54 | # Annualized portfolio return (%) 17.49% 55 | # Annualized excess portfolio return (%) 14.42% 56 | # Annualized excess risk (%) 6.09% 57 | # Information ratio (x) 2.37x 58 | # Annualized risk over risk-free (%) 6.09% 59 | # Sharpe ratio (x) 2.37x 60 | # Max drawdown (%) 3.21% 61 | # Annual turnover (x) 9.97x 62 | # Portfolio hit rate (%) 60.0% 63 | ``` 64 | 65 | If you have a charting library installed, like matplotlib, check out [BaseResult](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py) for the many metrics you can plot, like: 66 | 67 | - portfolio value evolution (`backtest_result.portfolio_value`), 68 | - long and short leverage evolution (`backtest_result.leverage`), 69 | - trades in SBUX (`backtest_result.trades['SBUX']`), 70 | - holdings in AAPL (`backtest_result.dollars_holdings['AAPL']`), 71 | - etc. 72 | 73 | ## What Could Be Improved 74 | 75 | In the above example, for simplicity, we: 76 | 77 | - Didn't use any cost models 78 | - e.g. TradingCost, ShortHoldingCost 79 | - Assumed our initial portfolio was all cash 80 | - You can override this by setting the `initial_portfolio` kwarg equal to a (Pandas) series of asset values when initializing [BacktestController](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/backtest_controller.py#L19). 81 | 82 | ## Next: Single Period Optimization 83 | 84 | Next, let's explore adding cost and constraint models in our next guide: [Single Period Optimization](/guides/simple_examples/spo). 85 | -------------------------------------------------------------------------------- /tests/guide_examples/spo_test.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | import investos as inv 4 | from investos.portfolio.constraint_model import ( 5 | LongCashConstraint, 6 | MaxAbsTurnoverConstraint, 7 | MaxLongLeverageConstraint, 8 | MaxShortLeverageConstraint, 9 | MaxWeightConstraint, 10 | MinWeightConstraint, 11 | ) 12 | from investos.portfolio.cost_model import ShortHoldingCost, TradingCost 13 | 14 | 15 | def test_spo(): 16 | actual_returns = pd.read_parquet( 17 | "https://investos.io/example_actual_returns.parquet" 18 | ) 19 | forecast_returns = pd.read_parquet( 20 | "https://investos.io/example_forecast_returns.parquet" 21 | ) 22 | 23 | # For trading costs: 24 | actual_prices = pd.read_parquet( 25 | "https://investos.io/example_spo_actual_prices.parquet" 26 | ) 27 | forecast_volume = pd.Series( 28 | pd.read_csv( 29 | "https://investos.io/example_spo_forecast_volume.csv", index_col="asset" 30 | ).squeeze(), 31 | name="forecast_volume", 32 | ) 33 | forecast_std_dev = pd.Series( 34 | pd.read_csv( 35 | "https://investos.io/example_spo_forecast_std_dev.csv", index_col="asset" 36 | ).squeeze(), 37 | name="forecast_std_dev", 38 | ) 39 | half_spread_percent = 2.5 / 10_000 # 2.5 bps 40 | half_spread = pd.Series(index=forecast_returns.columns, data=half_spread_percent) 41 | 42 | # For short holding costs: 43 | short_cost_percent = 40 / 10_000 # 40 bps 44 | trading_days_per_year = 252 45 | short_rates = pd.Series( 46 | index=forecast_returns.columns, data=short_cost_percent / trading_days_per_year 47 | ) 48 | 49 | strategy = inv.portfolio.strategy.SPO( 50 | actual_returns=actual_returns, 51 | forecast_returns=forecast_returns, 52 | costs=[ 53 | ShortHoldingCost(short_rates=short_rates, exclude_assets=["cash"]), 54 | TradingCost( 55 | actual_prices=actual_prices, 56 | forecast_volume=forecast_volume, 57 | forecast_std_dev=forecast_std_dev, 58 | half_spread=half_spread, 59 | exclude_assets=["cash"], 60 | ), 61 | ], 62 | constraints=[ 63 | MaxShortLeverageConstraint(limit=0.3), 64 | MaxLongLeverageConstraint(limit=1.3), 65 | MinWeightConstraint(limit=-0.03), 66 | MaxWeightConstraint(limit=0.03), 67 | LongCashConstraint(), 68 | MaxAbsTurnoverConstraint(limit=0.05), 69 | ], 70 | cash_column_name="cash", 71 | solver_opts={ 72 | "eps_abs": 1.5e-6, 73 | "eps_rel": 1.5e-6, 74 | "adaptive_rho_interval": 50, 75 | "max_iter": 100_000, 76 | }, 77 | ) 78 | 79 | portfolio = inv.portfolio.BacktestController( 80 | strategy=strategy, 81 | start_date="2017-01-01", 82 | end_date="2018-01-01", 83 | hooks={ 84 | "after_trades": [ 85 | lambda backtest, t, u, h_next: print(".", end=""), 86 | ] 87 | }, 88 | ) 89 | 90 | backtest_result = portfolio.generate_positions() 91 | summary = backtest_result._summary_string() 92 | 93 | print(summary) 94 | 95 | assert isinstance(summary, str) 96 | assert ( 97 | round(backtest_result.annualized_return, 3) >= 0.055 98 | and round(backtest_result.annualized_return, 3) <= 0.065 99 | ) 100 | assert ( 101 | round(backtest_result.annual_turnover, 1) >= 11.5 102 | and round(backtest_result.annual_turnover, 1) <= 13.5 103 | ) 104 | assert ( 105 | round(backtest_result.portfolio_hit_rate, 2) >= 0.58 106 | and round(backtest_result.portfolio_hit_rate, 2) <= 0.62 107 | ) 108 | -------------------------------------------------------------------------------- /guides/off_the_shelf/backtest_controller.md: -------------------------------------------------------------------------------- 1 |

Using the BacktestController Class

2 | 3 | The [BacktestController](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/backtest_controller.py) class is responsible for running backtests on your chosen investment strategy and saving the results. 4 | 5 | A step-by-step guide follows for instantiating this class: 6 | 7 | ## Steps to Instantiate 8 | 9 | ### Prepare your Investment Strategy: 10 | 11 | Before instantiating `BacktestController`, you should already have an instance of your desired investment strategy: 12 | 13 | ```python 14 | import investos as inv 15 | 16 | strategy = inv.portfolio.strategy.SPO( 17 | actual_returns = actual_returns, 18 | forecast_returns = forecast_returns, 19 | costs = [ 20 | ShortHoldingCost( 21 | short_rates=short_rates, 22 | exclude_assets=["cash"] 23 | ) 24 | ], 25 | constraints = [ 26 | MaxShortLeverageConstraint(limit=0.3), 27 | MaxLongLeverageConstraint(limit=1.3), 28 | MinWeightConstraint(), 29 | MaxWeightConstraint(), 30 | LongCashConstraint() 31 | ], 32 | cash_column_name="cash" 33 | ) 34 | ``` 35 | 36 | ### Instantiate the `BacktestController` Class: 37 | 38 | Use the strategy instance from the previous step as the first argument. 39 | 40 | ```python 41 | backtest_controller = inv.portfolio.BacktestController( 42 | strategy=strategy 43 | ) 44 | ``` 45 | 46 | ### Provide Optional Arguments (if necessary): 47 | 48 | - `hooks`: A dictionary of hooks for specific events during the backtest. Currently, the only hook that exists is `after_trades`. You can use it to do things like increase / decrease leverage based on performance, change constraints on the fly, print output on each iteration, etc. 49 | 50 | ```python 51 | hooks = { 52 | "after_trades": [ 53 | lambda backtest, t, dollars_trades, dollars_holdings_at_next_t: print(".", end=''), 54 | ] 55 | } 56 | ``` 57 | 58 | - `initial_portfolio`: A `pandas.Series` indicating the initial portfolio allocation. If not provided, the controller will create a default initial portfolio with an AUM of 100,000,000 (allocated to cash) and no other initial allocations. 59 | 60 | - `results_model`: A custom result class to store backtest results. It defaults to `BaseResult` if not provided. 61 | 62 | - `time_periods`: A series of time periods you wish to backtest. By default, it uses `actual_returns.index` from your investment strategy instance. 63 | 64 | - `start_date` and `end_date`: Start and end dates for the backtest. It uses the first and last dates from `time_periods` if not set. 65 | 66 | - `aum`: The initial portfolio's asset under management. Defaults to $100MM. 67 | 68 | Example instantiation with optional arguments: 69 | 70 | ```python 71 | backtest = BacktestController( 72 | strategy=strategy, 73 | start_date='2020-01-01', 74 | end_date='2021-01-01', 75 | aum=50_000_000, 76 | hooks={ 77 | 'after_trades': [custom_hook_function] 78 | }, 79 | ) 80 | ``` 81 | 82 | ### Run the Backtest: 83 | 84 | Once you've instantiated `BacktestController`, you can run the backtest using `generate_positions`. 85 | 86 | This returns an instance of BaseResult for performance reporting. 87 | 88 | ```python 89 | results = backtest.generate_positions() 90 | # result.summary will print high-level results 91 | ``` 92 | 93 | ## Conclusion 94 | 95 | With the steps mentioned above, you can effectively instantiate the `BacktestController` class and execute a backtest with your desired strategy. 96 | 97 | Adjust the optional parameters according to your needs to fine-tune your backtesting process! 98 | 99 | ## Next: Investment Strategies 100 | 101 | Next, let's explore the off-the-shelf investment strategies available to you: [Off-The-Shelf Investment Strategies](/guides/off_the_shelf/investment_strategies). 102 | -------------------------------------------------------------------------------- /guides/bespoke/custom_risk_models.md: -------------------------------------------------------------------------------- 1 |

Creating Custom Risk Models

2 | 3 | ## Extending BaseRisk 4 | 5 | The [BaseRisk](https://github.com/ForecastOS/investos/tree/v0.3.10/investos/portfolio/risk_model/base_risk.py) class provides a foundational structure for creating custom risk models. 6 | 7 | Below is a step-by-step guide for extending BaseRisk. 8 | 9 | ### Import Required Modules: 10 | 11 | First, ensure you have the necessary modules imported: 12 | 13 | ```python 14 | import cvxpy as cvx 15 | import numpy as np 16 | 17 | import investos.util as util 18 | from investos.portfolio.risk_model import BaseRisk 19 | ``` 20 | 21 | ### Define the Custom Risk Class: 22 | 23 | Subclass `BaseRisk` to implement your desired risk model. 24 | 25 | ```python 26 | class CustomRisk(BaseRisk): 27 | ``` 28 | 29 | ### Initialize Custom Attributes (Optional): 30 | 31 | You may want to add additional attributes specific to your risk model. Override the `__init__` method: 32 | 33 | ```python 34 | def __init__(self, *args, custom_param=None, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | self.custom_param = custom_param 37 | ``` 38 | 39 | ### Implement the `_estimated_cost_for_optimization` Method: 40 | 41 | `_estimated_cost_for_optimization` returns a utility cost expression for optimization that penalizes risk. 42 | 43 | Given a datetime `t`, a numpy-like array of holding weights `weights_portfolio_plus_trades`, a numpy-like array of trade weights `weights_trades`, and portfolio value `value`, return a two item tuple containing a CVXPY expression and a (possibly empty) list of constraints. 44 | 45 | See [FactorRisk](https://github.com/ForecastOS/investos/tree/v0.3.10/investos/portfolio/risk_model/factor_risk.py) for inspiration: 46 | 47 | ```python 48 | def _estimated_cost_for_optimization( 49 | self, t, weights_portfolio_plus_trades, weights_trades, value 50 | ): 51 | """Optimization (non-cash) cost penalty for assuming associated asset risk. 52 | 53 | Used by optimization strategy to determine trades. 54 | 55 | Not used to calculate simulated costs for backtest performance. 56 | """ 57 | factor_covar = util.get_value_at_t(self.factor_covariance, t, use_lookback=True) 58 | factor_load = util.get_value_at_t(self.factor_loadings, t, use_lookback=True) 59 | idiosync_var = util.get_value_at_t( 60 | self.idiosyncratic_variance, t, use_lookback=True 61 | ) 62 | 63 | risk_from_factors = factor_load.T @ factor_covar @ factor_load 64 | sigma = risk_from_factors + np.diag(idiosync_var) 65 | self.expression = cvx.quad_form(weights_portfolio_plus_trades, sigma) 66 | 67 | if self._penalize_risk: 68 | risk_penalty = self.expression 69 | else: 70 | risk_penalty = cvx.sum(0) 71 | 72 | if self._max_std_dev: 73 | constr_li = [self.expression <= (self._max_std_dev**2)] 74 | else: 75 | constr_li = [] 76 | 77 | return risk_penalty, constr_li 78 | ``` 79 | 80 | ### Implement Helper Methods (Optional): 81 | 82 | You can add custom helper methods to factor in specific logic or utilities that help in constructing your risk model (and help in keeping your logic understandable). 83 | 84 | ### Test Your Risk Model: 85 | 86 | You can test that your custom risk model generates a utility penalty for risk as expected for a specific datetime period: 87 | 88 | ```python 89 | actual_returns = pd.DataFrame(...) # Add your data here. Each asset should be a column, and it should be indexed by datetime 90 | initial_holdings = pd.Series(...) # Holding values, indexed by asset 91 | 92 | strategy = SPO( 93 | actual_returns=actual_returns, 94 | ... 95 | risk_model=CustomRisk(), 96 | ) 97 | 98 | trade_list = strategy.generate_trade_list( 99 | initial_holdings, 100 | dt.datetime.now() 101 | ) 102 | ``` 103 | 104 | You can also plug your custom risk model into BacktestController (through your investment strategy) to run a full backtest! 105 | 106 | ```python 107 | backtest_controller = inv.portfolio.BacktestController( 108 | strategy=strategy 109 | ) 110 | ``` 111 | -------------------------------------------------------------------------------- /investos/portfolio/risk_model/stat_factor_risk.py: -------------------------------------------------------------------------------- 1 | import cvxpy as cvx 2 | import numpy as np 3 | import pandas as pd 4 | 5 | import investos.util as util 6 | from investos.portfolio.risk_model import BaseRisk 7 | 8 | 9 | class StatFactorRisk(BaseRisk): 10 | """PCA-factor based risk model. 11 | 12 | The only requirement of custom risk models is that they implement a `_estimated_cost_for_optimization` method. 13 | 14 | Note: risk models are like cost models, except they return 0 for their `actual_cost` method (because they only influence optimization weights, not actual cash costs). 15 | """ 16 | 17 | def __init__(self, actual_returns: pd.DataFrame, n_factors=5, **kwargs): 18 | super().__init__(**kwargs) 19 | self.n = n_factors 20 | self.actual_returns = util.remove_excluded_columns_pd( 21 | actual_returns, 22 | exclude_assets=self.exclude_assets, 23 | ) 24 | self.start_date = kwargs.get("start_date", self.actual_returns.index[0]) 25 | self.end_date = kwargs.get("end_date", self.actual_returns.index[-1]) 26 | self.recalc_each_i_periods = kwargs.get("recalc_each_i_periods", False) 27 | self.timedelta = kwargs.get("timedelta", pd.Timedelta("730 days")) 28 | 29 | self.factor_variance = kwargs.get("factor_variance", None) 30 | self.factor_loadings = kwargs.get("factor_loadings", None) 31 | self.idiosyncratic_variance = kwargs.get("idiosyncratic_variance", None) 32 | 33 | if kwargs.get("calc_risk_model_on_init", False): 34 | self.create_risk_model(t=self.start_date) 35 | 36 | def _estimated_cost_for_optimization( 37 | self, t, weights_portfolio_plus_trades, weights_trades, value 38 | ): 39 | """Optimization (non-cash) cost penalty for assuming associated asset risk. 40 | 41 | Used by optimization strategy to determine trades. 42 | 43 | Not used to calculate simulated costs for backtest performance. 44 | """ 45 | if ( 46 | self.factor_variance is None 47 | or self.factor_loadings is None 48 | or self.idiosyncratic_variance is None 49 | or ( 50 | self.recalc_each_i_periods 51 | and self.actual_returns.index.get_loc(t) % self.recalc_each_i_periods 52 | == 0 53 | ) 54 | ): 55 | self.create_risk_model(t=t) 56 | 57 | self.expression = cvx.sum_squares( 58 | cvx.multiply( 59 | np.sqrt(self.idiosyncratic_variance), weights_portfolio_plus_trades 60 | ) 61 | ) 62 | 63 | risk_from_factors = (self.factor_loadings @ np.sqrt(self.factor_variance)).T 64 | 65 | self.expression += cvx.sum_squares( 66 | weights_portfolio_plus_trades @ risk_from_factors 67 | ) 68 | 69 | return self.expression, [] 70 | 71 | def create_risk_model(self, t): 72 | df = self.actual_returns 73 | df = df[(df.index < t) & (df.index >= pd.to_datetime(t) - self.timedelta)] 74 | 75 | covariance_matrix = df.cov().dropna().values 76 | eigenvalue, eigenvector = np.linalg.eigh(covariance_matrix) 77 | 78 | self.factor_variance = eigenvalue[-self.n :] 79 | 80 | self.factor_loadings = pd.DataFrame( 81 | data=eigenvector[:, -self.n :], index=df.columns 82 | ) 83 | self.idiosyncratic_variance = pd.Series( 84 | data=np.diag( 85 | eigenvector[:, : -self.n] 86 | @ np.diag(eigenvalue[: -self.n]) 87 | @ eigenvector[:, : -self.n].T 88 | ), 89 | index=df.columns, 90 | ) 91 | 92 | self._drop_excluded_assets() 93 | 94 | def _drop_excluded_assets(self): 95 | self.factor_loadings = util.remove_excluded_columns_pd( 96 | self.factor_loadings, 97 | exclude_assets=self.exclude_assets, 98 | ) 99 | self.idiosyncratic_variance = util.remove_excluded_columns_pd( 100 | self.idiosyncratic_variance, 101 | exclude_assets=self.exclude_assets, 102 | ) 103 | -------------------------------------------------------------------------------- /tests/guide_examples/spo_tranches_distr_test.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | import investos as inv 4 | from investos.portfolio.constraint_model import ( 5 | LongCashConstraint, 6 | MaxLongTradeLeverageConstraint, 7 | MaxShortTradeLeverageConstraint, 8 | MaxTradeWeightConstraint, 9 | MinTradeWeightConstraint, 10 | ) 11 | from investos.portfolio.cost_model import ShortHoldingCost, TradingCost 12 | 13 | 14 | def test_spo_tranches(): 15 | actual_returns = pd.read_parquet( 16 | "https://investos.io/example_actual_returns.parquet" 17 | ) 18 | forecast_returns = pd.read_parquet( 19 | "https://investos.io/example_forecast_returns.parquet" 20 | ) 21 | 22 | # For trading costs: 23 | actual_prices = pd.read_parquet( 24 | "https://investos.io/example_spo_actual_prices.parquet" 25 | ) 26 | forecast_volume = pd.Series( 27 | pd.read_csv( 28 | "https://investos.io/example_spo_forecast_volume.csv", index_col="asset" 29 | ).squeeze(), 30 | name="forecast_volume", 31 | ) 32 | forecast_std_dev = pd.Series( 33 | pd.read_csv( 34 | "https://investos.io/example_spo_forecast_std_dev.csv", index_col="asset" 35 | ).squeeze(), 36 | name="forecast_std_dev", 37 | ) 38 | half_spread_percent = 2.5 / 10_000 # 2.5 bps 39 | half_spread = pd.Series(index=forecast_returns.columns, data=half_spread_percent) 40 | 41 | # For short holding costs: 42 | short_cost_percent = 40 / 10_000 # 40 bps 43 | trading_days_per_year = 252 44 | short_rates = pd.Series( 45 | index=forecast_returns.columns, data=short_cost_percent / trading_days_per_year 46 | ) 47 | 48 | n_periods_held = 30 49 | 50 | strategy = inv.portfolio.strategy.SPOTranches( 51 | actual_returns=actual_returns, 52 | forecast_returns=forecast_returns, 53 | costs=[ 54 | ShortHoldingCost(short_rates=short_rates, exclude_assets=["cash"]), 55 | TradingCost( 56 | actual_prices=actual_prices, 57 | forecast_volume=forecast_volume, 58 | forecast_std_dev=forecast_std_dev, 59 | half_spread=half_spread, 60 | exclude_assets=["cash"], 61 | ), 62 | ], 63 | constraints=[ 64 | MaxLongTradeLeverageConstraint(limit=1.3 / n_periods_held), 65 | MaxShortTradeLeverageConstraint(limit=0.3 / n_periods_held), 66 | MinTradeWeightConstraint(limit=-0.03 / n_periods_held), 67 | MaxTradeWeightConstraint(limit=0.03 / n_periods_held), 68 | LongCashConstraint(), 69 | ], 70 | n_periods_held=n_periods_held, 71 | cash_column_name="cash", 72 | solver_opts={ 73 | "eps_abs": 1e-6, 74 | "eps_rel": 1e-6, 75 | "adaptive_rho_interval": 50, 76 | }, 77 | ) 78 | 79 | portfolio = inv.portfolio.BacktestController( 80 | strategy=strategy, 81 | distributed=True, 82 | dask_cluster_config={ 83 | "n_workers": 10, 84 | "environment": { 85 | "EXTRA_PIP_PACKAGES": "investos scikit-learn numpy>=2.0", 86 | }, 87 | }, 88 | start_date="2017-01-01", 89 | end_date="2017-06-30", 90 | hooks={ 91 | "after_trades": [ 92 | lambda backtest, t, u, h_next: print(".", end=""), 93 | ] 94 | }, 95 | ) 96 | 97 | backtest_result = portfolio.generate_positions() 98 | summary = backtest_result._summary_string() 99 | 100 | print(summary) 101 | 102 | assert isinstance(summary, str) 103 | assert ( 104 | round(backtest_result.annualized_return, 3) >= 0.033 105 | and round(backtest_result.annualized_return, 3) <= 0.037 106 | ) 107 | assert ( 108 | round(backtest_result.annual_turnover, 1) >= 8.3 109 | and round(backtest_result.annual_turnover, 1) <= 9.3 110 | ) 111 | assert ( 112 | round(backtest_result.portfolio_hit_rate, 2) >= 0.67 113 | and round(backtest_result.portfolio_hit_rate, 2) <= 0.77 114 | ) 115 | -------------------------------------------------------------------------------- /tests/guide_examples/spo_tranches_test.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | import investos as inv 4 | from investos.portfolio.constraint_model import ( 5 | LongCashConstraint, 6 | MaxAbsTurnoverConstraint, 7 | MaxLongLeverageConstraint, 8 | MaxLongTradeLeverageConstraint, 9 | MaxShortLeverageConstraint, 10 | MaxShortTradeLeverageConstraint, 11 | MaxTradeWeightConstraint, 12 | MinTradeWeightConstraint, 13 | ) 14 | from investos.portfolio.cost_model import ShortHoldingCost, TradingCost 15 | 16 | 17 | def test_spo_tranches(): 18 | actual_returns = pd.read_parquet( 19 | "https://investos.io/example_actual_returns.parquet" 20 | ) 21 | forecast_returns = pd.read_parquet( 22 | "https://investos.io/example_forecast_returns.parquet" 23 | ) 24 | 25 | # For trading costs: 26 | actual_prices = pd.read_parquet( 27 | "https://investos.io/example_spo_actual_prices.parquet" 28 | ) 29 | forecast_volume = pd.Series( 30 | pd.read_csv( 31 | "https://investos.io/example_spo_forecast_volume.csv", index_col="asset" 32 | ).squeeze(), 33 | name="forecast_volume", 34 | ) 35 | forecast_std_dev = pd.Series( 36 | pd.read_csv( 37 | "https://investos.io/example_spo_forecast_std_dev.csv", index_col="asset" 38 | ).squeeze(), 39 | name="forecast_std_dev", 40 | ) 41 | half_spread_percent = 2.5 / 10_000 # 2.5 bps 42 | half_spread = pd.Series(index=forecast_returns.columns, data=half_spread_percent) 43 | 44 | # For short holding costs: 45 | short_cost_percent = 40 / 10_000 # 40 bps 46 | trading_days_per_year = 252 47 | short_rates = pd.Series( 48 | index=forecast_returns.columns, data=short_cost_percent / trading_days_per_year 49 | ) 50 | 51 | n_periods_held = 30 52 | 53 | strategy = inv.portfolio.strategy.SPOTranches( 54 | actual_returns=actual_returns, 55 | forecast_returns=forecast_returns, 56 | costs=[ 57 | ShortHoldingCost(short_rates=short_rates, exclude_assets=["cash"]), 58 | TradingCost( 59 | actual_prices=actual_prices, 60 | forecast_volume=forecast_volume, 61 | forecast_std_dev=forecast_std_dev, 62 | half_spread=half_spread, 63 | exclude_assets=["cash"], 64 | ), 65 | ], 66 | constraints=[ 67 | MaxShortLeverageConstraint(limit=0.3), 68 | MaxLongLeverageConstraint(limit=1.3), 69 | MaxLongTradeLeverageConstraint(limit=1.3 / n_periods_held), 70 | MaxShortTradeLeverageConstraint(limit=0.3 / n_periods_held), 71 | MinTradeWeightConstraint(limit=-0.03 / n_periods_held), 72 | MaxTradeWeightConstraint(limit=0.03 / n_periods_held), 73 | LongCashConstraint(), 74 | MaxAbsTurnoverConstraint(limit=0.05), 75 | ], 76 | n_periods_held=n_periods_held, 77 | cash_column_name="cash", 78 | solver_opts={ 79 | "eps_abs": 1e-6, 80 | "eps_rel": 1e-6, 81 | "adaptive_rho_interval": 50, 82 | }, 83 | ) 84 | 85 | portfolio = inv.portfolio.BacktestController( 86 | strategy=strategy, 87 | start_date="2017-01-01", 88 | end_date="2017-06-30", 89 | hooks={ 90 | "after_trades": [ 91 | lambda backtest, t, u, h_next: print(".", end=""), 92 | ] 93 | }, 94 | ) 95 | 96 | backtest_result = portfolio.generate_positions() 97 | summary = backtest_result._summary_string() 98 | 99 | print(summary) 100 | 101 | assert isinstance(summary, str) 102 | assert ( 103 | round(backtest_result.annualized_return, 3) >= 0.033 104 | and round(backtest_result.annualized_return, 3) <= 0.037 105 | ) 106 | assert ( 107 | round(backtest_result.annual_turnover, 1) >= 8.3 108 | and round(backtest_result.annual_turnover, 1) <= 9.3 109 | ) 110 | assert ( 111 | round(backtest_result.portfolio_hit_rate, 2) >= 0.67 112 | and round(backtest_result.portfolio_hit_rate, 2) <= 0.77 113 | ) 114 | -------------------------------------------------------------------------------- /tests/guide_examples/spo_weights_test.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | import investos as inv 4 | from investos.portfolio.constraint_model import ( 5 | LongCashConstraint, 6 | LongOnlyConstraint, 7 | MaxAbsTurnoverConstraint, 8 | MaxLongLeverageConstraint, 9 | MaxWeightConstraint, 10 | ) 11 | from investos.portfolio.cost_model import ShortHoldingCost, TradingCost 12 | 13 | 14 | def test_spo_weights(): 15 | actual_returns = pd.read_parquet( 16 | "https://investos.io/example_actual_returns.parquet" 17 | ) 18 | forecast_returns = pd.read_parquet( 19 | "https://investos.io/example_forecast_returns.parquet" 20 | ) 21 | 22 | def top_n_mask(df, n=200, weight=0.005, window=20): 23 | # Rank each row in descending order (largest value gets rank 1) 24 | ranks = df.rank(axis=1, method="first", ascending=False) 25 | 26 | # Create mask for top 50 values 27 | mask = ranks <= n 28 | 29 | # Assign 0.02 to top 50, else 0 30 | weighted = mask.astype(float) * weight 31 | 32 | # Rolling average across the last `window` columns 33 | rolling_avg = weighted.rolling(window=window, min_periods=1).mean() 34 | 35 | return rolling_avg 36 | 37 | target_weights = top_n_mask(forecast_returns) 38 | 39 | # For trading costs: 40 | actual_prices = pd.read_parquet( 41 | "https://investos.io/example_spo_actual_prices.parquet" 42 | ) 43 | forecast_volume = pd.Series( 44 | pd.read_csv( 45 | "https://investos.io/example_spo_forecast_volume.csv", index_col="asset" 46 | ).squeeze(), 47 | name="forecast_volume", 48 | ) 49 | forecast_std_dev = pd.Series( 50 | pd.read_csv( 51 | "https://investos.io/example_spo_forecast_std_dev.csv", index_col="asset" 52 | ).squeeze(), 53 | name="forecast_std_dev", 54 | ) 55 | half_spread_percent = 2.5 / 10_000 # 2.5 bps 56 | half_spread = pd.Series(index=forecast_returns.columns, data=half_spread_percent) 57 | 58 | # For short holding costs: 59 | short_cost_percent = 40 / 10_000 # 40 bps 60 | trading_days_per_year = 252 61 | short_rates = pd.Series( 62 | index=forecast_returns.columns, data=short_cost_percent / trading_days_per_year 63 | ) 64 | 65 | strategy = inv.portfolio.strategy.SPOWeights( 66 | actual_returns=actual_returns, 67 | target_weights=target_weights, 68 | costs=[ 69 | ShortHoldingCost(short_rates=short_rates, exclude_assets=["cash"]), 70 | TradingCost( 71 | actual_prices=actual_prices, 72 | forecast_volume=forecast_volume, 73 | forecast_std_dev=forecast_std_dev, 74 | half_spread=half_spread, 75 | exclude_assets=["cash"], 76 | ), 77 | ], 78 | constraints=[ 79 | LongOnlyConstraint(), 80 | MaxLongLeverageConstraint(limit=1.0), 81 | MaxWeightConstraint(limit=0.01), 82 | LongCashConstraint(), 83 | MaxAbsTurnoverConstraint(limit=0.10), 84 | ], 85 | cash_column_name="cash", 86 | solver_opts={ 87 | "eps_abs": 5e-5, 88 | "eps_rel": 5e-5, 89 | "adaptive_rho_interval": 50, 90 | "max_iter": 100_000, 91 | }, 92 | ) 93 | 94 | portfolio = inv.portfolio.BacktestController( 95 | strategy=strategy, 96 | start_date="2017-01-01", 97 | end_date="2018-01-01", 98 | hooks={ 99 | "after_trades": [ 100 | lambda backtest, t, u, h_next: print(".", end=""), 101 | ] 102 | }, 103 | ) 104 | 105 | backtest_result = portfolio.generate_positions() 106 | summary = backtest_result._summary_string() 107 | 108 | print(summary) 109 | 110 | assert isinstance(summary, str) 111 | assert ( 112 | round(backtest_result.annualized_return, 2) >= 0.18 113 | and round(backtest_result.annualized_return, 2) <= 0.19 114 | ) 115 | assert ( 116 | round(backtest_result.annual_turnover, 1) >= 10.6 117 | and round(backtest_result.annual_turnover, 1) <= 11.4 118 | ) 119 | assert ( 120 | round(backtest_result.portfolio_hit_rate, 2) >= 0.57 121 | and round(backtest_result.portfolio_hit_rate, 2) <= 0.62 122 | ) 123 | -------------------------------------------------------------------------------- /guides/bespoke/custom_cost_models.md: -------------------------------------------------------------------------------- 1 |

Creating Custom Cost Models

2 | 3 | ## Extending BaseCost 4 | 5 | The [BaseCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/base_cost.py) class provides a foundational structure for creating custom cost models. 6 | 7 | Below is a step-by-step guide for extending BaseCost. 8 | 9 | ### Import Required Modules: 10 | 11 | First, ensure you have the necessary modules imported: 12 | 13 | ```python 14 | import datetime as dt 15 | import pandas as pd 16 | import numpy as np 17 | from investos.portfolio.cost_model import BaseCost 18 | from investos.util import get_value_at_t 19 | ``` 20 | 21 | ### Define the Custom Cost Class: 22 | 23 | Subclass `BaseCost` to implement your desired cost model. 24 | 25 | ```python 26 | class CustomCost(BaseCost): 27 | ``` 28 | 29 | ### Initialize Custom Attributes (Optional): 30 | 31 | You may want to add additional attributes specific to your cost model. Override the `__init__` method: 32 | 33 | ```python 34 | def __init__(self, *args, custom_param=None, **kwargs): 35 | super().__init__(*args, **kwargs) 36 | self.custom_param = custom_param 37 | ``` 38 | 39 | ### Implement the `get_actual_cost` Method: 40 | 41 | **This is the core method** where your cost logic resides. 42 | 43 | Given a datetime `t`, a series of holdings indexed by asset `dollars_holdings_plus_trades`, and a series of trades indexed by asset `dollars_trades`, return the sum of costs for all assets. 44 | 45 | See [ShortHoldingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/short_holding_cost.py) for inspiration: 46 | 47 | ```python 48 | def get_actual_cost( 49 | self, t: dt.datetime, dollars_holdings_plus_trades: pd.Series, dollars_trades: pd.Series 50 | ) -> pd.Series: 51 | """Method that calculates per-period (short position) holding costs given period `t` holdings and trades. 52 | """ 53 | return sum( 54 | -np.minimum(0, dollars_holdings_plus_trades) * self._get_short_rate(t) 55 | ) 56 | 57 | def _get_short_rate(self, t): 58 | return get_value_at_t(self.short_rates, t) 59 | ``` 60 | 61 | ### Implement the `_estimated_cost_for_optimization` Method (Optional): 62 | 63 | If you're using a convex optimization based investment strategy, `_estimated_cost_for_optimization` is used to return a cost expression for optimization. 64 | 65 | Given a datetime `t`, a numpy-like array of holding weights `weights_portfolio_plus_trades`, a numpy-like array of trade weights `weights_trades`, and `portfolio_value`, return a two item tuple containing a `cvx.sum(expression)` and a (possibly empty) list of constraints. 66 | 67 | See [ShortHoldingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/short_holding_cost.py) for inspiration: 68 | 69 | ```python 70 | def _estimated_cost_for_optimization( 71 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 72 | ): 73 | """Estimated holding costs. 74 | 75 | Used by optimization strategy to determine trades. 76 | 77 | Not used to calculate simulated holding costs for backtest performance. 78 | """ 79 | expression = cvx.multiply( 80 | self._get_short_rate(t), cvx.neg(weights_portfolio_plus_trades) 81 | ) 82 | 83 | return cvx.sum(expression), [] 84 | ``` 85 | 86 | ### Implement Helper Methods (Optional): 87 | 88 | You can add custom helper methods to factor in specific logic or utilities that help in constructing your cost model (and help in keeping your logic understandable). 89 | 90 | ### Test Your Cost Model: 91 | 92 | You can test that your custom cost model generates costs as expected for a specific datetime period: 93 | 94 | ```python 95 | actual_returns = pd.DataFrame(...) # Add your data here. Each asset should be a column, and it should be indexed by datetime 96 | initial_holdings = pd.Series(...) # Holding values, indexed by asset 97 | 98 | strategy = SPO( 99 | actual_returns=actual_returns, 100 | costs=[CustomCost] 101 | ) 102 | 103 | trade_list = strategy.generate_trade_list( 104 | initial_holdings, 105 | dt.datetime.now() 106 | ) 107 | ``` 108 | 109 | You can also plug your custom cost model into BacktestController (through your investment strategy) to run a full backtest! 110 | 111 | ```python 112 | backtest_controller = inv.portfolio.BacktestController( 113 | strategy=strategy 114 | ) 115 | ``` 116 | -------------------------------------------------------------------------------- /investos/portfolio/strategy/base_strategy.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import pandas as pd 4 | 5 | from investos.portfolio.constraint_model import BaseConstraint 6 | from investos.portfolio.cost_model import BaseCost 7 | 8 | 9 | class BaseStrategy: 10 | """Base class for an optimization strategy. 11 | 12 | Must implement :py:meth:`~investos.portfolio.strategy.base_strategy.BaseStrategy.generate_trade_list` as per below. 13 | 14 | Attributes 15 | ---------- 16 | costs : list 17 | Cost models evaluated during optimization strategy. Defaults to empty list. See :py:class:`~investos.portfolio.cost_model.base_cost.BaseCost` for cost model base class. 18 | constraints : list 19 | Constraints applied for optimization strategy. Defaults to empty list. See :py:class:`~investos.portfolio.constraint_model.base_constraint.BaseConstraint for optimization model base class. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | actual_returns: pd.DataFrame, 25 | costs: [BaseCost] = [], 26 | constraints: [BaseConstraint] = [], 27 | **kwargs, 28 | ): 29 | self.actual_returns = actual_returns 30 | self.costs = costs 31 | self.constraints = constraints 32 | 33 | self.cash_column_name = kwargs.get("cash_column_name", "cash") 34 | self.metadata_properties = ["cash_column_name"] 35 | 36 | def _zerotrade(self, holdings): 37 | return pd.Series(index=holdings.index, data=0.0) 38 | 39 | def generate_trade_list(self, holdings: pd.Series, t: dt.datetime) -> pd.Series: 40 | """Calculates and returns trade list (in units of currency passed in), given (added) optimization logic. 41 | 42 | Parameters 43 | ---------- 44 | holdings : pandas.Series 45 | Holdings at beginning of period `t`. 46 | t : datetime.datetime 47 | The datetime for associated holdings `holdings`. 48 | """ 49 | raise NotImplementedError 50 | 51 | def get_actual_positions_for_t( 52 | self, dollars_holdings: pd.Series, dollars_trades: pd.Series, t: dt.datetime 53 | ) -> pd.Series: 54 | """Calculates and returns actual positions, after accounting for trades and costs during period t.""" 55 | dollars_holdings_plus_trades = dollars_holdings + dollars_trades 56 | 57 | costs = [ 58 | cost.actual_cost( 59 | t, 60 | dollars_holdings_plus_trades=dollars_holdings_plus_trades, 61 | dollars_trades=dollars_trades, 62 | ) 63 | for cost in self.costs 64 | ] 65 | 66 | cash_col = self.cash_column_name 67 | dollars_trades[cash_col] = -sum( 68 | dollars_trades[dollars_trades.index != cash_col] 69 | ) - sum(costs) 70 | dollars_holdings_plus_trades[cash_col] = ( 71 | dollars_holdings[cash_col] + dollars_trades[cash_col] 72 | ) 73 | 74 | dollars_holdings_at_next_t = ( 75 | self.actual_returns.loc[t] * dollars_holdings_plus_trades 76 | + dollars_holdings_plus_trades 77 | ) 78 | 79 | return dollars_holdings_at_next_t, dollars_trades 80 | 81 | def metadata_dict(self): 82 | meta_d = { 83 | "strategy": self.__class__.__name__, 84 | } 85 | 86 | if getattr(self, "risk_model", False): 87 | meta_d[self.risk_model.__class__.__name__] = self.risk_model.metadata_dict() 88 | 89 | if getattr(self, "constraints", False): 90 | meta_d["constraint_models"] = { 91 | el.__class__.__name__: el.metadata_dict() for el in self.constraints 92 | } 93 | 94 | if getattr(self, "costs", False): 95 | meta_d["cost_models"] = { 96 | el.__class__.__name__: el.metadata_dict() 97 | for el in self.costs 98 | if "Risk" not in el.__class__.__name__ 99 | } 100 | 101 | return self._add_strategy_metadata_params(meta_d) 102 | 103 | def _add_strategy_metadata_params(self, meta_d): 104 | metadata_properties = getattr(self, "metadata_properties", []) 105 | if metadata_properties: 106 | meta_d["strategy_config"] = {} 107 | for p in metadata_properties: 108 | meta_d["strategy_config"][p] = getattr(self, p, "n.a.") 109 | 110 | return meta_d 111 | -------------------------------------------------------------------------------- /investos/portfolio/strategy/spo_weights.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import cvxpy as cvx 4 | import pandas as pd 5 | 6 | import investos.util as util 7 | from investos.portfolio.constraint_model import ( 8 | BaseConstraint, 9 | LongOnlyConstraint, 10 | ) 11 | from investos.portfolio.cost_model import BaseCost 12 | from investos.portfolio.risk_model import BaseRisk 13 | from investos.portfolio.strategy import BaseStrategy 14 | from investos.util import get_value_at_t 15 | 16 | 17 | class SPOWeights(BaseStrategy): 18 | """Optimization strategy that builds trade list using single period optimization. 19 | 20 | If you're using OSQP as your solver (the default), view the following for tuning: https://osqp.org/docs/interfaces/solver_settings.html 21 | """ 22 | 23 | BASE_SOLVER_OPTS = { 24 | "max_iter": 50_000, 25 | } 26 | 27 | def __init__( 28 | self, 29 | actual_returns: pd.DataFrame, 30 | target_weights: pd.DataFrame, 31 | costs: [BaseCost] = [], 32 | constraints: [BaseConstraint] = [ 33 | LongOnlyConstraint(), 34 | ], 35 | risk_model: BaseRisk = None, 36 | solver=cvx.OSQP, 37 | solver_opts=None, 38 | **kwargs, 39 | ): 40 | super().__init__( 41 | actual_returns=actual_returns, 42 | costs=costs, 43 | constraints=constraints, 44 | **kwargs, 45 | ) 46 | self.risk_model = risk_model 47 | if self.risk_model: 48 | self.costs.append(self.risk_model) 49 | 50 | self.target_weights = target_weights 51 | self.solver = solver 52 | self.solver_opts = util.deep_dict_merge( 53 | self.BASE_SOLVER_OPTS, solver_opts or {} 54 | ) 55 | 56 | self.metadata_properties = ["solver", "solver_opts"] 57 | 58 | def generate_trade_list(self, holdings: pd.Series, t: dt.datetime) -> pd.Series: 59 | """Calculates and returns trade list (in units of currency passed in) using convex (single period) optimization. 60 | 61 | Parameters 62 | ---------- 63 | holdings : pandas.Series 64 | Holdings at beginning of period `t`. 65 | t : datetime.datetime 66 | The datetime for associated holdings `holdings`. 67 | """ 68 | 69 | if t is None: 70 | t = dt.datetime.today() 71 | 72 | value = sum(holdings) 73 | weights_portfolio = holdings / value # Portfolio weights 74 | weights_trades = cvx.Variable(weights_portfolio.size) # Portfolio trades 75 | weights_portfolio_plus_trades = ( 76 | weights_portfolio.values + weights_trades 77 | ) # Portfolio weights after trades 78 | 79 | wdiff = ( 80 | weights_portfolio_plus_trades 81 | - get_value_at_t(self.target_weights, t).values 82 | ) 83 | 84 | assert wdiff.is_concave() 85 | 86 | # Costs not used during optimization step (since optimization is for distance from target_weights) 87 | constraints = [] 88 | 89 | constraints += [ 90 | item 91 | for item in ( 92 | con.cvxpy_expression( 93 | t, 94 | weights_portfolio_plus_trades, 95 | weights_trades, 96 | value, 97 | holdings.index, 98 | ) 99 | for con in self.constraints 100 | ) 101 | ] 102 | 103 | for el in constraints: 104 | if not el.is_dcp(): 105 | print(t, el, "is not dcp") 106 | 107 | objective = cvx.Minimize(cvx.sum(cvx.abs(wdiff))) 108 | constraints += [cvx.sum(weights_trades) == 0] 109 | self.prob = cvx.Problem( 110 | objective, constraints 111 | ) # Trades need to 0 out, i.e. cash account must adjust to make everything net to 0 112 | 113 | try: 114 | self.prob.solve(solver=self.solver, **self.solver_opts) 115 | 116 | if self.prob.status in ("unbounded", "infeasible"): 117 | print(f"The problem is {self.prob.status} at {t}.") 118 | return self._zerotrade(holdings) 119 | 120 | dollars_trades = pd.Series( 121 | index=holdings.index, data=(weights_trades.value * value) 122 | ) 123 | 124 | return dollars_trades 125 | 126 | except (cvx.SolverError, cvx.DCPError, TypeError) as e: 127 | print(f"The solver failed for {t}. Error details: {e}") 128 | return self._zerotrade(holdings) 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

InvestOS Banner

2 |

InvestOS

3 | 4 | Welcome to the **InvestOS** portfolio engineering and backtesting framework! 5 | 6 | InvestOS is an opinionated framework for constructing and backtesting portfolios in a consistent, albeit flexible way. We built it to make institutional-grade backtesting and portfolio optimization simple, extensible, and open-source. 7 | 8 |
9 | 10 | | | | 11 | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 12 | | Docs | [![website - investos.io](https://img.shields.io/badge/website-investos.io-black)](https://investos.io/) [![docs](https://img.shields.io/readthedocs/investos)](https://investos.readthedocs.io/en/latest/) | 13 | | Package | [![PyPI - Version](https://img.shields.io/pypi/v/investos.svg?logo=pypi&label=PyPI&logoColor=gold)](https://pypi.python.org/pypi/investos) [![PyPI - Python Versions](https://img.shields.io/pypi/pyversions/investos.svg?logo=python&label=Python&logoColor=gold)](https://pypi.org/project/investos/) | 14 | | Meta | [![Poetry](https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json)](https://python-poetry.org/) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![license - MIT](https://img.shields.io/badge/license-MIT-blue)](https://spdx.org/licenses/MIT.html) | 15 | 16 |
17 | 18 | ## Getting Started 19 | 20 | ### 🔗 [Read the full Getting Started guides Here](https://investos.io/guides/introduction/getting_started) 21 | 22 | ### Prerequisites 23 | 24 | **To run InvestOS you'll need**: 25 | 26 | - [Python +3.8](https://www.python.org/doc/) 27 | - You can [download it here](https://www.python.org/downloads/) 28 | - If you're working on MacOS, you may wish to [install it via Homebrew](https://docs.python-guide.org/starting/install3/osx/) 29 | - [pip](https://packaging.python.org/en/latest/key_projects/#pip) 30 | - For installing InvestOS (and any other Python packages) 31 | - [pip installation instructions here](https://packaging.python.org/en/latest/tutorials/installing-packages/) 32 | 33 | **Although not required, running InvestOS might be easier if you have**: 34 | 35 | - [Poetry](https://python-poetry.org/), a package and dependency manager 36 | - Familiarity with [pandas](https://pandas.pydata.org/) 37 | - The popular Python data analysis package (originally) released by AQR Capital Management 38 | 39 | ## Installation 40 | 41 | If you're using pip: 42 | 43 | ```bash 44 | $ pip install investos 45 | ``` 46 | 47 | If you're using poetry: 48 | 49 | ```bash 50 | $ poetry add investos 51 | ``` 52 | 53 | ## Importing InvestOS 54 | 55 | At the top of your python file or .ipynb, add: 56 | 57 | ```python 58 | import investos as inv 59 | ``` 60 | 61 | Congratulations on setting up InvestOS! 62 | 63 | ### Let's move on to our next guide: [How InvestOS Works](https://investos.io/guides/introduction/how_investos_works) 64 | 65 | ## Contributing 66 | 67 | InvestOS is an open-source project and we welcome contributions from the community. 68 | 69 | If you'd like to contribute, please fork the repository and make changes as you'd like. Pull requests are warmly welcome. 70 | 71 | ### Contributors ✨ 72 | 73 | 74 | 75 | 76 | 77 | ## Thank You 78 | 79 | A special thank you to: 80 | 81 | 1. The authors of the [Multi-Period Trading via Convex Optimization](https://stanford.edu/~boyd/papers/pdf/cvx_portfolio.pdf) paper: Stephen Boyd, Enzo Busseti, Ronald Kahn, et al. It formed much of the basis of my knowledge on convex optimization. 82 | 2. The team behind [CVXPY](https://github.com/cvxpy/cvxpy) 83 | 3. The team behind [OSQP](https://osqp.org/docs/index.html) 84 | 85 | -------------------------------------------------------------------------------- /investos/portfolio/strategy/spo.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import cvxpy as cvx 4 | import pandas as pd 5 | 6 | import investos.util as util 7 | from investos.portfolio.constraint_model import ( 8 | BaseConstraint, 9 | LongOnlyConstraint, 10 | MaxWeightConstraint, 11 | ) 12 | from investos.portfolio.cost_model import BaseCost 13 | from investos.portfolio.risk_model import BaseRisk 14 | from investos.portfolio.strategy import BaseStrategy 15 | from investos.util import get_value_at_t 16 | 17 | 18 | class SPO(BaseStrategy): 19 | """Optimization strategy that builds trade list using single period optimization. 20 | 21 | If you're using OSQP as your solver (the default), view the following for tuning: https://osqp.org/docs/interfaces/solver_settings.html 22 | """ 23 | 24 | BASE_SOLVER_OPTS = { 25 | "max_iter": 50_000, 26 | } 27 | 28 | def __init__( 29 | self, 30 | actual_returns: pd.DataFrame, 31 | forecast_returns: pd.DataFrame, 32 | costs: [BaseCost] = [], 33 | constraints: [BaseConstraint] = [ 34 | LongOnlyConstraint(), 35 | MaxWeightConstraint(), 36 | ], 37 | risk_model: BaseRisk = None, 38 | solver=cvx.OSQP, 39 | solver_opts=None, 40 | **kwargs, 41 | ): 42 | super().__init__( 43 | actual_returns=actual_returns, 44 | costs=costs, 45 | constraints=constraints, 46 | **kwargs, 47 | ) 48 | self.risk_model = risk_model 49 | if self.risk_model: 50 | self.costs.append(self.risk_model) 51 | 52 | self.forecast_returns = forecast_returns 53 | self.solver = solver 54 | self.solver_opts = util.deep_dict_merge( 55 | self.BASE_SOLVER_OPTS, solver_opts or {} 56 | ) 57 | 58 | self.metadata_properties = ["solver", "solver_opts"] 59 | 60 | def generate_trade_list(self, holdings: pd.Series, t: dt.datetime) -> pd.Series: 61 | """Calculates and returns trade list (in units of currency passed in) using convex (single period) optimization. 62 | 63 | Parameters 64 | ---------- 65 | holdings : pandas.Series 66 | Holdings at beginning of period `t`. 67 | t : datetime.datetime 68 | The datetime for associated holdings `holdings`. 69 | """ 70 | 71 | if t is None: 72 | t = dt.datetime.today() 73 | 74 | value = sum(holdings) 75 | weights_portfolio = holdings / value # Portfolio weights 76 | weights_trades = cvx.Variable(weights_portfolio.size) # Portfolio trades 77 | weights_portfolio_plus_trades = ( 78 | weights_portfolio.values + weights_trades 79 | ) # Portfolio weights after trades 80 | 81 | alpha_term = cvx.sum( 82 | cvx.multiply( 83 | get_value_at_t(self.forecast_returns, t).values, 84 | weights_portfolio_plus_trades, 85 | ) 86 | ) 87 | 88 | assert alpha_term.is_concave() 89 | 90 | costs, constraints = [], [] 91 | 92 | for cost in self.costs: 93 | cost_expr, const_expr = cost.cvxpy_expression( 94 | t, weights_portfolio_plus_trades, weights_trades, value, holdings.index 95 | ) 96 | costs.append(cost_expr) 97 | constraints += const_expr 98 | 99 | constraints += [ 100 | item 101 | for item in ( 102 | con.cvxpy_expression( 103 | t, 104 | weights_portfolio_plus_trades, 105 | weights_trades, 106 | value, 107 | holdings.index, 108 | ) 109 | for con in self.constraints 110 | ) 111 | ] 112 | 113 | # For help debugging: 114 | for el in costs: 115 | if not el.is_convex(): 116 | print(t, el, "is not convex") 117 | 118 | for el in constraints: 119 | if not el.is_dcp(): 120 | print(t, el, "is not dcp") 121 | 122 | objective = cvx.Maximize(alpha_term - cvx.sum(costs)) 123 | constraints += [cvx.sum(weights_trades) == 0] 124 | self.prob = cvx.Problem( 125 | objective, constraints 126 | ) # Trades need to 0 out, i.e. cash account must adjust to make everything net to 0 127 | 128 | try: 129 | self.prob.solve(solver=self.solver, **self.solver_opts) 130 | 131 | if self.prob.status in ("unbounded", "infeasible"): 132 | print(f"The problem is {self.prob.status} at {t}.") 133 | return self._zerotrade(holdings) 134 | 135 | dollars_trades = pd.Series( 136 | index=holdings.index, data=(weights_trades.value * value) 137 | ) 138 | 139 | return dollars_trades 140 | 141 | except (cvx.SolverError, cvx.DCPError, TypeError): 142 | print(f"The solver failed for {t}.") 143 | return self._zerotrade(holdings) 144 | -------------------------------------------------------------------------------- /investos/portfolio/constraint_model/factor_constraint.py: -------------------------------------------------------------------------------- 1 | import cvxpy as cvx 2 | 3 | from investos.portfolio.constraint_model.base_constraint import BaseConstraint 4 | from investos.util import get_value_at_t 5 | 6 | 7 | class ZeroFactorExposureConstraint(BaseConstraint): 8 | def __init__(self, factor_exposure, **kwargs): 9 | self.factor_exposure = factor_exposure 10 | super().__init__(**kwargs) 11 | 12 | def _cvxpy_expression( 13 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 14 | ): 15 | return ( 16 | cvx.sum( 17 | cvx.multiply( 18 | get_value_at_t(self.factor_exposure, t), 19 | weights_portfolio_plus_trades, 20 | ) 21 | ) 22 | == 0 23 | ) 24 | 25 | 26 | class ZeroTradeFactorExposureConstraint(BaseConstraint): 27 | def __init__(self, factor_exposure, **kwargs): 28 | self.factor_exposure = factor_exposure 29 | super().__init__(**kwargs) 30 | 31 | def _cvxpy_expression( 32 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 33 | ): 34 | return ( 35 | cvx.sum( 36 | cvx.multiply(get_value_at_t(self.factor_exposure, t), weights_trades) 37 | ) 38 | == 0 39 | ) 40 | 41 | 42 | class MaxFactorExposureConstraint(BaseConstraint): 43 | def __init__(self, factor_exposure, limit=0.05, **kwargs): 44 | self.factor_exposure = factor_exposure 45 | self.limit = limit 46 | super().__init__(**kwargs) 47 | 48 | def _cvxpy_expression( 49 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 50 | ): 51 | return ( 52 | cvx.sum( 53 | cvx.multiply( 54 | get_value_at_t(self.factor_exposure, t), 55 | weights_portfolio_plus_trades, 56 | ) 57 | ) 58 | <= self.limit 59 | ) 60 | 61 | 62 | class MinFactorExposureConstraint(BaseConstraint): 63 | def __init__(self, factor_exposure, limit=-0.05, **kwargs): 64 | self.factor_exposure = factor_exposure 65 | self.limit = limit 66 | super().__init__(**kwargs) 67 | 68 | def _cvxpy_expression( 69 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 70 | ): 71 | return ( 72 | cvx.sum( 73 | cvx.multiply( 74 | get_value_at_t(self.factor_exposure, t), 75 | weights_portfolio_plus_trades, 76 | ) 77 | ) 78 | >= self.limit 79 | ) 80 | 81 | 82 | class MaxAbsoluteFactorExposureConstraint(BaseConstraint): 83 | def __init__(self, factor_exposure, limit=0.4, **kwargs): 84 | self.factor_exposure = factor_exposure 85 | self.limit = limit 86 | super().__init__(**kwargs) 87 | 88 | def _cvxpy_expression( 89 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 90 | ): 91 | return ( 92 | cvx.sum( 93 | cvx.multiply( 94 | get_value_at_t(self.factor_exposure, t), 95 | cvx.abs(weights_portfolio_plus_trades), 96 | ) 97 | ) 98 | <= self.limit 99 | ) 100 | 101 | 102 | class MaxAbsoluteTradeFactorExposureConstraint(BaseConstraint): 103 | def __init__(self, factor_exposure, limit=0.01, **kwargs): 104 | self.factor_exposure = factor_exposure 105 | self.limit = limit 106 | super().__init__(**kwargs) 107 | 108 | def _cvxpy_expression( 109 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 110 | ): 111 | return ( 112 | cvx.sum( 113 | cvx.multiply( 114 | get_value_at_t(self.factor_exposure, t), cvx.abs(weights_trades) 115 | ) 116 | ) 117 | <= self.limit 118 | ) 119 | 120 | 121 | class MaxTradeFactorExposureConstraint(BaseConstraint): 122 | def __init__(self, factor_exposure, limit=0.05, **kwargs): 123 | self.factor_exposure = factor_exposure 124 | self.limit = limit 125 | super().__init__(**kwargs) 126 | 127 | def _cvxpy_expression( 128 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 129 | ): 130 | return ( 131 | cvx.sum( 132 | cvx.multiply(get_value_at_t(self.factor_exposure, t), weights_trades) 133 | ) 134 | <= self.limit 135 | ) 136 | 137 | 138 | class MinTradeFactorExposureConstraint(BaseConstraint): 139 | def __init__(self, factor_exposure, limit=-0.05, **kwargs): 140 | self.factor_exposure = factor_exposure 141 | self.limit = limit 142 | super().__init__(**kwargs) 143 | 144 | def _cvxpy_expression( 145 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 146 | ): 147 | return ( 148 | cvx.sum( 149 | cvx.multiply(get_value_at_t(self.factor_exposure, t), weights_trades) 150 | ) 151 | >= self.limit 152 | ) 153 | -------------------------------------------------------------------------------- /investos/portfolio/strategy/rank_long_short.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import pandas as pd 4 | 5 | from investos.portfolio.cost_model import BaseCost 6 | from investos.portfolio.strategy import BaseStrategy 7 | from investos.util import get_value_at_t 8 | 9 | 10 | class RankLongShort(BaseStrategy): 11 | """Investment strategy that builds trade list by going long assets with best metric_to_rank and short stocks with worst metric_to_rank. 12 | 13 | Attributes 14 | ---------- 15 | costs : list[:py:class:`~investos.portfolio.cost_model.base_cost.BaseCost`] 16 | Cost models evaluated during optimization strategy. 17 | percent_short : float 18 | Percent of assets in forecast returns to go short. 19 | percent_long : float 20 | Percent of assets in forecast returns to go long. 21 | leverage : float 22 | Absolute value of exposure / AUM. Used to calculate holdings. 23 | n_periods_held : integer 24 | Number of periods positions held. After n number of periods, positions unwound. 25 | """ 26 | 27 | def __init__( 28 | self, 29 | actual_returns: pd.DataFrame, 30 | metric_to_rank: pd.DataFrame, 31 | n_periods_held: int = 1, 32 | leverage: float = 1, 33 | ratio_long: float = 1, 34 | ratio_short: float = 1, 35 | percent_short: float = 0.25, 36 | percent_long: float = 0.25, 37 | costs: [BaseCost] = [], 38 | **kwargs, 39 | ): 40 | super().__init__( 41 | actual_returns=actual_returns, 42 | costs=costs, 43 | **kwargs, 44 | ) 45 | self.metric_to_rank = metric_to_rank 46 | self.percent_short = percent_short 47 | self.percent_long = percent_long 48 | self.n_periods_held = n_periods_held 49 | self.leverage = leverage 50 | self.leverage_per_trade = leverage / n_periods_held 51 | self.ratio_long = ratio_long 52 | self.ratio_short = ratio_short 53 | 54 | self.metadata_properties = [ 55 | "n_periods_held", 56 | "leverage", 57 | "ratio_long", 58 | "ratio_short", 59 | "percent_long", 60 | "percent_short", 61 | ] 62 | 63 | def generate_trade_list(self, holdings: pd.Series, t: dt.datetime) -> pd.Series: 64 | """Calculates and returns trade list (in units of currency passed in) by going long top :py:attr:`~investos.portfolio.strategy.rank_long_short.RankLongShort.percent_long` assets and short bottom :py:attr:`~investos.portfolio.strategy.rank_long_short.RankLongShort.percent_short` assets. 65 | 66 | Parameters 67 | ---------- 68 | holdings : pandas.Series 69 | Holdings at beginning of period `t`. 70 | t : datetime.datetime 71 | The datetime for associated holdings `holdings`. 72 | """ 73 | weights_portfolio = self._get_trade_weights_for_t(holdings, t) 74 | dollars_trades = sum(holdings) * weights_portfolio * self.leverage_per_trade 75 | 76 | idx_t = self.metric_to_rank.index.get_loc(t) 77 | positions_saved = self.backtest_controller.results.dollars_holdings.shape[0] 78 | 79 | if positions_saved >= self.n_periods_held: 80 | # Use holdings_unwind, t_unwind, weights_portfolio_unwind, dollars_trades_unwind, dollars_trades_unwind_scaled 81 | t_unwind = self.metric_to_rank.index[idx_t - self.n_periods_held] 82 | holdings_unwind = self.backtest_controller.results.dollars_holdings.loc[ 83 | t_unwind 84 | ] 85 | weights_portfolio_unwind = self._get_trade_weights_for_t( 86 | holdings_unwind, t_unwind 87 | ) 88 | dollars_trades_unwind_pre = ( 89 | sum(holdings_unwind) 90 | * weights_portfolio_unwind 91 | * self.leverage_per_trade 92 | ) 93 | dollars_trades_unwind_scaled = ( 94 | dollars_trades_unwind_pre 95 | * self._cum_returns_to_scale_unwind(t_unwind, t) 96 | ) 97 | 98 | dollars_trades -= dollars_trades_unwind_scaled 99 | 100 | return dollars_trades 101 | 102 | def _get_trade_weights_for_t(self, holdings: pd.Series, t: dt.datetime): 103 | n_short = round(self.metric_to_rank.shape[1] * self.percent_short) 104 | n_long = round(self.metric_to_rank.shape[1] * self.percent_long) 105 | 106 | prediction = get_value_at_t(self.metric_to_rank, t) 107 | prediction_sorted = prediction.sort_values() 108 | 109 | short_trades = prediction_sorted.index[:n_short] 110 | long_trades = prediction_sorted.index[-n_long:] 111 | 112 | weights_portfolio = pd.Series(0.0, index=prediction.index) 113 | weights_portfolio[short_trades] = ( 114 | -1.0 * self.ratio_short * (self.percent_long / self.percent_short) 115 | ) 116 | weights_portfolio[long_trades] = 1.0 * self.ratio_long 117 | 118 | weights_portfolio /= sum(abs(weights_portfolio)) 119 | 120 | return weights_portfolio 121 | 122 | def _cum_returns_to_scale_unwind(self, t_unwind: dt.datetime, t: dt.datetime): 123 | df = self.actual_returns + 1 124 | df = df[(df.index >= t_unwind) & (df.index < t)] 125 | 126 | return df.cumprod().iloc[-1] 127 | -------------------------------------------------------------------------------- /investos/portfolio/cost_model/trading_cost.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import cvxpy as cvx 4 | import numpy as np 5 | import pandas as pd 6 | 7 | from investos.portfolio.cost_model import BaseCost 8 | from investos.util import get_value_at_t, remove_excluded_columns_pd 9 | 10 | 11 | class TradingCost(BaseCost): 12 | """Calculates per period cost for trades, based on spread, standard deviation, volume, and price. 13 | 14 | Actual t-cost calculation approximated (loosely) on AQR's research 15 | on market impact for live trades from their execution database 16 | between 1998 and 2016. 17 | Frazzini, Andrea and Israel, Ronen and Moskowitz, Tobias J. and Moskowitz, Tobias J., 18 | Trading Costs (April 7, 2018). 19 | Available at SSRN: https://ssrn.com/abstract=3229719 20 | 21 | Attributes 22 | ---------- 23 | sensitivity_coeff : float 24 | For scaling volume-based transaction cost component. Does not impact spread related costs. 25 | """ 26 | 27 | def __init__(self, forecast_volume, actual_prices, **kwargs): 28 | super().__init__(**kwargs) 29 | self.forecast_volume = remove_excluded_columns_pd( 30 | forecast_volume, 31 | exclude_assets=self.exclude_assets, 32 | include_assets=self.include_assets, 33 | ) 34 | self.actual_prices = remove_excluded_columns_pd( 35 | actual_prices, 36 | exclude_assets=self.exclude_assets, 37 | include_assets=self.include_assets, 38 | ) 39 | self.sensitivity_coeff = ( 40 | remove_excluded_columns_pd( 41 | kwargs.get("price_movement_sensitivity", 1), 42 | exclude_assets=self.exclude_assets, 43 | include_assets=self.include_assets, 44 | ) 45 | / 100 # Convert to bps 46 | ) 47 | self.half_spread = remove_excluded_columns_pd( 48 | kwargs.get("half_spread", 1 / 10_000), 49 | exclude_assets=self.exclude_assets, 50 | include_assets=self.include_assets, 51 | ) 52 | self.est_opt_cost_config = kwargs.get( 53 | "est_opt_cost_config", 54 | { 55 | "linear_mi_multiplier": 2, 56 | "min_half_spread": 8 / 10_000, 57 | }, 58 | ) 59 | 60 | def _estimated_cost_for_optimization( 61 | self, t, weights_portfolio_plus_trades, weights_trades, portfolio_value 62 | ): 63 | """Estimated trading costs. 64 | 65 | Used by optimization strategy to determine trades. 66 | """ 67 | constraints = [] 68 | 69 | volume_dollars = get_value_at_t(self.forecast_volume, t) * get_value_at_t( 70 | self.actual_prices, t 71 | ) 72 | percent_volume_traded_pre_trade_weight = ( 73 | np.abs(portfolio_value) / volume_dollars 74 | ) 75 | 76 | try: # Spread (convex, estimated) costs 77 | self.estimate_expression = cvx.multiply( 78 | np.clip( 79 | get_value_at_t(self.half_spread, t), 80 | self.est_opt_cost_config["min_half_spread"], 81 | None, 82 | ), 83 | cvx.abs(weights_trades), 84 | ) 85 | except TypeError: 86 | self.estimate_expression = cvx.multiply( 87 | np.clip( 88 | get_value_at_t(self.half_spread, t).values, 89 | self.est_opt_cost_config["min_half_spread"], 90 | None, 91 | ), 92 | cvx.abs(weights_trades), 93 | ) 94 | 95 | try: # Market impact (convex, estimated) costs 96 | self.estimate_expression += ( 97 | cvx.multiply( 98 | cvx.multiply( 99 | percent_volume_traded_pre_trade_weight, 100 | cvx.abs(weights_trades), 101 | ), 102 | self.sensitivity_coeff, 103 | ) 104 | * self.est_opt_cost_config["linear_mi_multiplier"] 105 | ) 106 | except TypeError: 107 | self.estimate_expression += ( 108 | cvx.multiply( 109 | cvx.multiply( 110 | percent_volume_traded_pre_trade_weight.values, 111 | cvx.abs(weights_trades), 112 | ), 113 | self.sensitivity_coeff, 114 | ) 115 | * self.est_opt_cost_config["linear_mi_multiplier"] 116 | ) 117 | 118 | return cvx.sum(self.estimate_expression), constraints 119 | 120 | def get_actual_cost( 121 | self, 122 | t: dt.datetime, 123 | dollars_holdings_plus_trades: pd.Series, 124 | dollars_trades: pd.Series, 125 | ) -> pd.Series: 126 | spread_cost = np.abs(dollars_trades) * get_value_at_t(self.half_spread, t) 127 | volume_dollars = get_value_at_t(self.forecast_volume, t) * get_value_at_t( 128 | self.actual_prices, t 129 | ) 130 | percent_volume_traded = np.abs(dollars_trades) / volume_dollars 131 | 132 | trading_costs = spread_cost + ( 133 | self.sensitivity_coeff 134 | * np.abs(dollars_trades) 135 | * (percent_volume_traded**0.5) 136 | ) 137 | 138 | return trading_costs.sum() 139 | -------------------------------------------------------------------------------- /guides/reporting/external_portfolios.md: -------------------------------------------------------------------------------- 1 |

Analyzing External Portfolios

2 | 3 | If your portfolio was created outside of InvestOS, you can still use InvestOS to review its performance. 4 | 5 | The several (fairly simple) steps to do so are detailed below. 6 | 7 | #### Generate / compile required data 8 | 9 | Let's assume we have a time-series asset weight portfolio in the following format, saved as a CSV: 10 | 11 | ``` 12 | | date | asset | weight | 13 | | ------------------ | ------------ | ------ | 14 | | 2021-10-12 9:30:00 | AAPL | 0.10 | 15 | | 2021-10-12 9:30:00 | MSFT | 0.15 | 16 | | 2021-10-12 9:30:00 | GOOGL | 0.12 | 17 | | 2021-10-12 9:30:00 | AMZN | 0.18 | 18 | | 2021-10-12 9:30:00 | TSLA | 0.20 | 19 | | 2021-10-12 9:30:00 | FB | 0.08 | 20 | | 2021-10-12 9:30:00 | NFLX | 0.05 | 21 | | 2021-10-12 9:30:00 | TWTR | 0.07 | 22 | | 2021-10-12 9:30:00 | NVDA | 0.04 | 23 | | 2021-10-12 9:30:00 | AMD | 0.01 | 24 | | ... | ... | ... | 25 | ``` 26 | 27 | Let's also assume you have a time-series asset price history in the following format, saved as a CSV: 28 | 29 | ``` 30 | | date | asset | price | 31 | | ------------------ | ------------ | ------ | 32 | | 2021-10-12 9:30:00 | AAPL | 1.01 | 33 | | 2021-10-12 9:30:00 | MSFT | 2.02 | 34 | | 2021-10-12 9:30:00 | GOOGL | 3.03 | 35 | | 2021-10-12 9:30:00 | AMZN | 4.04 | 36 | | 2021-10-12 9:30:00 | TSLA | 5.05 | 37 | | 2021-10-12 9:30:00 | FB | 6.06 | 38 | | 2021-10-12 9:30:00 | NFLX | 7.07 | 39 | | 2021-10-12 9:30:00 | TWTR | 8.08 | 40 | | 2021-10-12 9:30:00 | NVDA | 9.09 | 41 | | 2021-10-12 9:30:00 | AMD | 1.11 | 42 | | ... | ... | ... | 43 | ``` 44 | 45 | #### Load asset weights 46 | 47 | ```python 48 | import pandas as pd 49 | import numpy as np 50 | 51 | df_weights = pd.read_csv('weights.csv') 52 | df_weights = df_weights.pivot(index="date", columns="asset", values="weight") 53 | ``` 54 | 55 | #### Calculate returns 56 | 57 | ```python 58 | df_returns = pd.read_csv('returns.csv') 59 | df_returns['return'] = df_returns.groupby('asset')['price'].pct_change() 60 | df_returns = df_returns.pivot(index="date", columns="asset", values="return") 61 | 62 | # Convert to fwd period return 63 | df_returns = df_returns.shift(-1) 64 | ``` 65 | 66 | #### Scale weights for returns 67 | 68 | ```python 69 | s_scale_weights = ( 70 | df_weights.shift(1).fillna(0) * df_returns.shift(1).fillna(0) 71 | ).sum(axis=1) + 1 72 | s_scale_weights = s_scale_weights.cumprod() 73 | df_weights = df_weights.multiply(s_scale_weights, axis=0) 74 | ``` 75 | 76 | #### Infer trades 77 | 78 | ```python 79 | # Start weights at 0 for T=0 (before AUM is invested) 80 | new_row = pd.DataFrame( 81 | [], index=[df_weights.index[0] - pd.Timedelta(days=1)] 82 | ) 83 | 84 | df_returns = pd.concat([new_row, df_returns]).fillna(0) 85 | df_weights = pd.concat([new_row, df_weights]).fillna(0) 86 | 87 | # Infer trade weights 88 | df_trades = df_weights - df_weights.shift(1) * (1 + df_returns.shift(1)) 89 | df_trades = df_trades.fillna(0) 90 | ``` 91 | 92 | #### Run backtest for weights 93 | 94 | ```python 95 | import investos as inv 96 | from investos.portfolio.result import WeightsResult 97 | 98 | # Calculate cash trades, add cash returns 99 | df_weights["cash"] = 1 100 | df_trades["cash"] = 0 101 | df_trades["cash"] -= df_trades.sum(axis=1) 102 | # df_returns["cash"] series could be 103 | # -- the 90 day T-bill yield, a constant number, 104 | # -- 0 (for simplicity), etc. 105 | df_returns["cash"] = 0.04 / 252 # (4% / 252 trading days) 106 | 107 | backtest_result = WeightsResult( 108 | initial_weights=df_weights.iloc[0], 109 | trade_weights=df_trades, 110 | actual_returns=df_returns, 111 | risk_free=df_returns["cash"], # Add any series you want 112 | benchmark=df_returns["cash"], # Add any series you want 113 | aum=100_000_000, 114 | cash_column_name="cash" 115 | ) 116 | ``` 117 | 118 | #### Result object 119 | 120 | Getting the `result` backtest object takes a few more steps for portfolios created outside of InvestOS, but you end up with the same `result` object. 121 | 122 | ## Exploring backtest results 123 | 124 | You can print summary results for you backtest with the following code: 125 | 126 | ```python 127 | backtest_result.summary 128 | ``` 129 | 130 | In an ipynb, you can easily plot: 131 | 132 | - Your portfolio value evolution `result.portfolio_value.plot()` 133 | - Your leverage `result.long_leverage.plot()` 134 | - Your holdings in a specific asset `result.dollars_holdings['TSLA'].plot()` 135 | - Your trades in a specific asset `result.trades['TSLA'].plot()` 136 | - And many more metrics, series, and dataframes provided by the result object 137 | 138 | To view all of the reporting functionality available to you, [check out the result class on Github](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py). 139 | 140 | Looking for more reporting? Consider extending the [BaseResult](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py) class, or opening a pull request for [InvestOS on GitHub](https://github.com/ForecastOS/investos)! 141 | 142 | ## Next: Backtest Controller 143 | 144 | Next, let's explore the BacktestController class, which coordinates backtesting your chosen investment strategy: [Backtest Controller](/guides/off_the_shelf/backtest_controller). 145 | -------------------------------------------------------------------------------- /guides/off_the_shelf/investment_strategies.md: -------------------------------------------------------------------------------- 1 |

Using Off-The-Shelf Investment Strategies

2 | 3 | InvestOS provides the following optimization strategies: 4 | 5 | - [RankLongShort](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/rank_long_short.py) 6 | - Builds trade lists based on long and short positions ranked by any (possibly forecasted) metric 7 | - [SPO](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo.py) (Single Period Optimization) 8 | - Builds trade lists using single period convex optimization 9 | - Uses [CVXPY](https://www.cvxpy.org/tutorial/intro/index.html) 10 | - **Optimization**: maximizes expected return, less costs, while adhering to constraints 11 | - [SPOTranches](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo_tranches.py) (Single Period Optimization Tranches) 12 | - Like SPO, but builds portfolio in separate, optimized tranches. Tranches are cycled in and out by customizable holding period. Tranches can be analyzed and altered in flight using BacktestController hooks 13 | - Uses [CVXPY](https://www.cvxpy.org/tutorial/intro/index.html) 14 | - **Optimization**: maximizes expected return, less costs, while adhering to constraints 15 | - [SPOWeights](https://github.com/ForecastOS/investos/tree/v0.6.3/investos/portfolio/strategy/spo_weights.py) (Single Period Optimization Weights) 16 | - Like SPO, but performs optimization using target weights instead of expected returns 17 | - Uses [CVXPY](https://www.cvxpy.org/tutorial/intro/index.html) 18 | - **Optimization**: minimizes deviation from target weights, while adhering to constraints 19 | 20 | This guide will explain how to use these classes. 21 | 22 | ## RankLongShort 23 | 24 | To use the `RankLongShort` strategy, you will need: 25 | 26 | - actual_returns: pd.DataFrame 27 | - `metric_to_rank`: pd.DataFrame 28 | 29 | Optional instantiation options you may need: 30 | 31 | - `n_periods_held`: int = 1 32 | - leverage: float = 1 33 | - ratio_long: float = 1 34 | - ratio_short: float = 1 35 | - percent_short: float = 0.25 36 | - percent_long: float = 0.25 37 | - costs: [BaseCost] = [] 38 | 39 | Here's a simple instantiation example: 40 | 41 | ```python 42 | from investos.portfolio.strategy import RankLongShort 43 | 44 | actual_returns_df = pd.DataFrame(...) # Assets as columns, datetimes as index 45 | metric_to_rank_df = pd.DataFrame(...) # Assets as columns, datetimes as index 46 | 47 | strategy = RankLongShort( 48 | actual_returns=actual_returns_df, 49 | metric_to_rank=metric_to_rank_df, 50 | leverage=1.5, 51 | ratio_long=100, 52 | ratio_short=50, 53 | ) 54 | ``` 55 | 56 | Once instantiated, you can use the `generate_trade_list` method to get trades for a given datetime: 57 | 58 | ```python 59 | holdings = pd.Series(...) # Indexed by asset 60 | t = dt.datetime.today() 61 | 62 | trades = strategy.generate_trade_list(holdings, t) 63 | ``` 64 | 65 | You can also plug the strategy into BacktestController to run a full backtest! 66 | 67 | ```python 68 | import investos as inv 69 | 70 | backtest_controller = inv.portfolio.BacktestController( 71 | strategy=strategy 72 | ) 73 | ``` 74 | 75 | ## SPO 76 | 77 | To use the `SPO` strategy, you will need: 78 | 79 | - actual_returns: pd.DataFrame 80 | - forecast_returns: pd.DataFrame 81 | 82 | Optional instantiation options you may need: 83 | 84 | - costs: [BaseCost] = [] 85 | - constraints: [BaseConstraint] = [LongOnlyConstraint(), MaxWeightConstraint()] 86 | - risk_model: BaseRisk = None 87 | - solver=cvx.OSQP 88 | - solver_opts=None 89 | - `cash_column_name`="cash" 90 | 91 | Here's a simple instantiation example: 92 | 93 | ```python 94 | actual_returns_df = pd.DataFrame(...) # Assets as columns, datetimes as index 95 | forecast_returns_df = pd.DataFrame(...) # Assets as columns, datetimes as index 96 | 97 | strategy = SPO( 98 | actual_returns=actual_returns_df, 99 | forecast_returns=forecast_returns_df 100 | ) 101 | ``` 102 | 103 | Like RankLongShort, or any other InvestOS investment strategy, once instantiated, you can use the `generate_trade_list` method to get trades for a given datetime: 104 | 105 | ```python 106 | holdings = pd.Series(...) 107 | t = dt.datetime.today() 108 | 109 | trades = strategy.generate_trade_list(holdings, t) 110 | ``` 111 | 112 | and plug the strategy into BacktestController to run a full backtest! 113 | 114 | ```python 115 | import investos as inv 116 | 117 | backtest_controller = inv.portfolio.BacktestController( 118 | strategy=strategy 119 | ) 120 | ``` 121 | 122 | For SPO specifically, if the optimization problem is unbounded, infeasible, or if there's an error with the solver, the `generate_trade_list` method will return a zero trade for all holdings for the given `t` datetime. 123 | 124 | ## SPOTranches 125 | 126 | The `SPOTranches` strategy uses all of the same arguments as `SPO`. It also uses one additional (optional) argument for determining the holding period for each optimized tranche: 127 | 128 | - `n_periods_held`: integer = 5 129 | 130 | ## SPOWeights 131 | 132 | The `SPOWeights` strategy uses most of the same arguments as `SPO`. 133 | 134 | It doesn't using the following argument from `SPO`: 135 | 136 | - forecast_returns: pd.DataFrame 137 | 138 | It uses the following additional argument instead: 139 | 140 | - target_weights: pd.DataFrame 141 | 142 | ## Next: The Choice Is Yours 143 | 144 | Want to explore creating your own custom investment strategy? Check out [Custom Investment Strategies](/guides/bespoke/custom_investment_strategies). 145 | 146 | Want to learn more about using cost models? Check out [Cost Models](/guides/off_the_shelf/cost_models). 147 | -------------------------------------------------------------------------------- /guides/bespoke/custom_investment_strategies.md: -------------------------------------------------------------------------------- 1 |

Creating Custom Investment Strategies

2 | 3 | ## Extending BaseStrategy 4 | 5 | The [BaseStrategy](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/base_strategy.py) class provides a foundational structure for creating custom investment strategies. 6 | 7 | Below is a step-by-step guide for extending BaseStrategy. 8 | 9 | ### Import Required Modules: 10 | 11 | First, ensure you have the necessary modules imported: 12 | 13 | ```python 14 | import datetime as dt 15 | import pandas as pd 16 | from investos.portfolio.strategy import BaseStrategy 17 | from investos.util import get_value_at_t 18 | ``` 19 | 20 | ### Define the Custom Strategy Class: 21 | 22 | Subclass `BaseStrategy` to implement the desired strategy. 23 | 24 | ```python 25 | class CustomStrategy(BaseStrategy): 26 | ``` 27 | 28 | ### Initialize Custom Attributes (Optional): 29 | 30 | You may want to add additional attributes specific to your strategy. Override the `__init__` method: 31 | 32 | ```python 33 | def __init__(self, *args, custom_param=None, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | self.custom_param = custom_param 36 | ``` 37 | 38 | ### Implement the `generate_trade_list` Method: 39 | 40 | **This is the core method** where your strategy logic resides. 41 | 42 | Given a series of holdings indexed by asset, and a date `t`, it should calculate and return a trade list series, also indexed by asset. 43 | 44 | For example, a simple, contrived momentum-based strategy might look like this: 45 | 46 | ```python 47 | def generate_trade_list(self, holdings: pd.Series, t: dt.datetime) -> pd.Series: 48 | # A placeholder example: 49 | ### Buy assets that have had positive returns in the last period 50 | returns = get_value_at_t(self.actual_returns, t) 51 | buy_assets = returns[returns > 0].index 52 | trade_values = pd.Series(index=holdings.index, data=0.0) 53 | trade_values[buy_assets] = 100 # Buying $100 of each positive-return asset 54 | 55 | return trade_values 56 | ``` 57 | 58 | ### Implement Helper Methods (Optional): 59 | 60 | You can add custom helper methods to factor in specific logic or utilities that help in constructing your strategy (and help in keeping your logic understandable). 61 | 62 | ### Test Your Strategy: 63 | 64 | You can test that your custom strategy generates trades as expected for a specific datetime period: 65 | 66 | ```python 67 | actual_returns = pd.DataFrame(...) # Add your data here. Each asset should be a column, and it should be indexed by datetime 68 | initial_holdings = pd.Series(...) # Holding values, indexed by asset 69 | 70 | strategy = CustomStrategy( 71 | actual_returns=actual_returns, 72 | custom_param="example_value" 73 | ) 74 | 75 | trade_list = strategy.generate_trade_list( 76 | initial_holdings, 77 | dt.datetime.now() 78 | ) 79 | 80 | print(trade_list) 81 | ``` 82 | 83 | You can also plug your custom strategy into BacktestController to run a full backtest! 84 | 85 | ```python 86 | backtest_controller = inv.portfolio.BacktestController( 87 | strategy=strategy 88 | ) 89 | ``` 90 | 91 | ### Integrate Costs and Constraints: 92 | 93 | Use the `costs` parameter (in `BaseStrategy`) to incorporate costs. 94 | 95 | If your strategy uses convex optimization, use the `constraints` parameter (in `BaseStrategy`) to incorporate constraints. 96 | 97 | --- 98 | 99 | ## Customizing Existing Strategies 100 | 101 | Both [RankLongShort](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/rank_long_short.py) and [SPO](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo.py) classes, which extend [BaseStrategy](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/base_strategy.py), offer a base foundation for implementing investment strategies. 102 | 103 | While their structure is designed for general use, for users seeking to implement more advanced and nuanced strategies, their architecture supports customization and extension. 104 | 105 | ### General Extension Ideas 106 | 107 | 1. **Dynamic Leverage**: Rather than using a fixed leverage ratio, you could dynamically adjust leverage based on market volatility, market sentiment, or other indicators. 108 | 109 | 2. **Adaptive Percent Long/Short**: Adjust the percentage of assets that are long or short based on changing market conditions. For example, during bullish markets, increase the percent long. 110 | 111 | ### RankLongShort Extension Ideas 112 | 113 | 1. **Sector-Based Ranking**: Instead of ranking all assets, classify them by sectors and rank within each sector. This can ensure diversification across various sectors. 114 | 115 | 2. **Custom Weighting Mechanisms**: Override the `_get_trade_weights_for_t` method to use custom weights beyond simple ranking, perhaps factoring in other metrics such as asset volatility or liquidity. 116 | 117 | 3. **Custom Unwind Logic**: Modify the unwinding logic for positions to not just be based on time but on market conditions, metrics, or other constraints. 118 | 119 | ### SPO Extension Ideas 120 | 121 | 1. **Post-Solution Adjustments**: After the solver has provided a solution, make post-optimization adjustments, perhaps for real-world considerations like rounding off to whole shares or accounting for latest market prices. 122 | 123 | 2. **Advanced Error Handling**: Instead of just handling errors by zeroing trades when the solver fails, consider implementing a fallback mechanism or use an alternative optimization approach. 124 | 125 | ### Final Thoughts 126 | 127 | - Like BaseStrategy, both RankLongShort and SPO are designed to be extensible. 128 | 129 | - Make sure you test extensively with historical data before deploying to a live environment! 130 | -------------------------------------------------------------------------------- /guides/simple_examples/spo.md: -------------------------------------------------------------------------------- 1 |

Single Period Optimization (SPO)

2 | 3 | ## What We Need 4 | 5 | In order to backtest a portfolio using [SPO](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo.py), we'll need: 6 | 7 | - Forecast stock returns over the time periods we wish to backtest: `forecast_returns` 8 | - Actual stock returns over the time periods we wish to backtest: `actual_returns` 9 | - Start and end dates: `start_date` and `end_date` 10 | 11 | For [TradingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/trading_cost.py), we'll need: 12 | 13 | - Actual stock prices over the time periods we want to backtest: `actual_prices` 14 | - Forecast volume (perhaps an average of the last year for each asset, for simplicity) over the time periods we want to backtest: `forecast_volume` 15 | - Forecast standard deviation in returns (perhaps the standard deviation of the last year for each asset, for simplicity) over the time periods we want to backtest: `forecast_std_dev` 16 | - Forecast (half) trading spreads for each asset (we are using 2.5bps for all assets for simplicity in this example) over the time periods we want to backtest: `half_spread` 17 | 18 | For [ShortHoldingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/short_holding_cost.py): we'll need: 19 | 20 | - Forecast short borrowing rates for each asset (we are using 40bps for all assets for simplicity in this example) over the time periods we want to backtest: `short_rates` 21 | 22 | In order to make this example as easy as possible, we've prepared, and will use, actual returns and prices and forecast returns, volumes, standard deviations, spreads, and short rates from 2017 - 2018 for a universe of 319 stocks. 23 | 24 | ## Sample Code For a SPO Backtest 25 | 26 | Set up modules and load data: 27 | 28 | ```python 29 | import pandas as pd 30 | import investos as inv 31 | from investos.portfolio.cost_model import * 32 | from investos.portfolio.constraint_model import * 33 | 34 | actual_returns = pd.read_parquet("https://investos.io/example_actual_returns.parquet") 35 | forecast_returns = pd.read_parquet("https://investos.io/example_forecast_returns.parquet") 36 | 37 | # For trading costs: 38 | actual_prices = pd.read_parquet("https://investos.io/example_spo_actual_prices.parquet") 39 | forecast_volume = pd.Series( 40 | pd.read_csv("https://investos.io/example_spo_forecast_volume.csv", index_col="asset") 41 | .squeeze(), 42 | name="forecast_volume" 43 | ) 44 | forecast_std_dev = pd.Series( 45 | pd.read_csv("https://investos.io/example_spo_forecast_std_dev.csv", index_col="asset") 46 | .squeeze(), 47 | name="forecast_std_dev" 48 | ) 49 | half_spread_percent = 2.5 / 10_000 # 2.5 bps 50 | half_spread = pd.Series(index=forecast_returns.columns, data=half_spread_percent) 51 | 52 | # For short holding costs: 53 | short_cost_percent = 40 / 10_000 # 40 bps 54 | trading_days_per_year = 252 55 | short_rates = pd.Series(index=forecast_returns.columns, data=short_cost_percent/trading_days_per_year) 56 | ``` 57 | 58 | Run SPO: 59 | 60 | ```python 61 | strategy = inv.portfolio.strategy.SPO( 62 | actual_returns = actual_returns, 63 | forecast_returns = forecast_returns, 64 | costs = [ 65 | ShortHoldingCost(short_rates=short_rates, exclude_assets=["cash"]), 66 | TradingCost( 67 | actual_prices=actual_prices, 68 | forecast_volume=forecast_volume, 69 | forecast_std_dev=forecast_std_dev, 70 | half_spread=half_spread, 71 | exclude_assets=["cash"], 72 | ), 73 | ], 74 | constraints = [ 75 | MaxShortLeverageConstraint(limit=0.3), 76 | MaxLongLeverageConstraint(limit=1.3), 77 | MinWeightConstraint(limit=-0.03), 78 | MaxWeightConstraint(limit=0.03), 79 | LongCashConstraint(), 80 | MaxAbsTurnoverConstraint(limit=0.05), 81 | ], 82 | cash_column_name="cash" 83 | ) 84 | 85 | portfolio = inv.portfolio.BacktestController( 86 | strategy=strategy, 87 | start_date='2017-01-01', 88 | end_date='2018-01-01', 89 | hooks = { 90 | "after_trades": [ 91 | lambda backtest, t, u, h_next: print(".", end=''), 92 | ] 93 | } 94 | ) 95 | 96 | backtest_result = portfolio.generate_positions() 97 | backtest_result.summary 98 | ``` 99 | 100 | When `backtest_result.summary` is executed, it will output summary backtest results similar to the below: 101 | 102 | ```python 103 | # Initial timestamp 2017-01-03 00:00:00 104 | # Final timestamp 2017-12-29 00:00:00 105 | # Total portfolio return (%) 5.6% 106 | # Annualized portfolio return (%) 5.68% 107 | # Annualized excess portfolio return (%) 2.61% 108 | # Annualized excess risk (%) 2.47% 109 | # Information ratio (x) 1.06x 110 | # Annualized risk over risk-free (%) 2.47% 111 | # Sharpe ratio (x) 1.05x 112 | # Max drawdown (%) 1.05% 113 | # Annual turnover (x) 12.88x 114 | # Portfolio hit rate (%) 60.0% 115 | ``` 116 | 117 | What a difference trading costs make vs our previous RankLongShort example! 118 | 119 | If you have a charting library installed, like matplotlib, check out [BaseResult](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py) for the many `backtest_result` metrics you can plot! 120 | 121 | ## Next: Reporting 122 | 123 | Next, let's explore the backtest performance reporting available to you through `backtest_result` (an instance of BaseResult): [Analyzing Backtest Results](/guides/reporting/analyzing_backtest_results). 124 | -------------------------------------------------------------------------------- /investos/portfolio/backtest_controller.py: -------------------------------------------------------------------------------- 1 | import statistics 2 | import time 3 | from datetime import datetime 4 | 5 | import pandas as pd 6 | from dask.distributed import Client 7 | from dask_cloudprovider.aws import FargateCluster 8 | 9 | from investos.portfolio.result import BaseResult 10 | 11 | 12 | class BacktestController: 13 | """Container class that runs backtests using passed-in portfolio engineering `strategy` (see :py:class:`~investos.portfolio.strategy.base_strategy.BaseStrategy`), then saves results into `result` (see :py:class:`~investos.backtest.result.Result`) class.""" 14 | 15 | def __init__(self, strategy, **kwargs): 16 | self.strategy = strategy 17 | self.strategy.backtest_controller = self 18 | 19 | # Optional 20 | self._set_time_periods(**kwargs) 21 | 22 | self.hooks = kwargs.get("hooks", {}) 23 | 24 | self.distributed = kwargs.get("distributed", False) 25 | self.dask_cluster = kwargs.get("dask_cluster", False) 26 | self.dask_cluster_config = { 27 | "n_workers": 50, 28 | "image": "daskdev/dask:latest", 29 | "region_name": "us-east-2", # Change this to your preferred AWS region 30 | "worker_cpu": 1024 * 2, # 2 vCPU 31 | "worker_mem": 1024 * 4, # 4 GB memory 32 | "scheduler_cpu": 1024 * 16, 33 | "scheduler_mem": 1024 * 32, 34 | "scheduler_timeout": "3600s", 35 | "environment": { 36 | "EXTRA_PIP_PACKAGES": "investos scikit-learn", 37 | }, 38 | } 39 | self.dask_cluster_config.update(kwargs.get("dask_cluster_config", {})) 40 | 41 | self.initial_portfolio = kwargs.get( 42 | "initial_portfolio", 43 | self._create_initial_portfolio_if_not_provided(**kwargs), 44 | ) 45 | 46 | # Create results instance for saving performance 47 | self.results = kwargs.get("results_model", BaseResult)( 48 | start_date=self.start_date, 49 | end_date=self.end_date, 50 | ) 51 | self.results.strategy = self.strategy 52 | 53 | def generate_positions(self): 54 | print( 55 | f"Generating historical portfolio trades and positions at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}..." 56 | ) 57 | # Create t == 0 position (no trades) 58 | t = self._get_initial_t() 59 | dollars_trades = pd.Series(index=self.initial_portfolio.index, data=0) 60 | dollars_holdings_at_next_t = self.initial_portfolio # Includes cash 61 | self.results.save_position(t, dollars_trades, dollars_holdings_at_next_t) 62 | 63 | if self.distributed: 64 | self._dask_start_client_and_cluster() 65 | self.strategy.precompute_trades_distributed( 66 | dollars_holdings_at_next_t, self.time_periods 67 | ) 68 | print("\nClosing dask cluster...") 69 | self.dask_cluster.close() 70 | print("\nDask cluster closed.\n") 71 | self.strategy.weights_trades_distr = ( 72 | self.strategy.weights_trades_distr.fillna(0) 73 | ) # Fill NAs that occur due to no solution 74 | 75 | # Walk through time and calculate future trades, estimated and actual costs and returns, and resulting positions 76 | for t in self.time_periods: 77 | dollars_trades = self.strategy.generate_trade_list( 78 | dollars_holdings_at_next_t, t 79 | ) 80 | dollars_holdings_at_next_t, dollars_trades = ( 81 | self.strategy.get_actual_positions_for_t( 82 | dollars_holdings_at_next_t, dollars_trades, t 83 | ) 84 | ) 85 | self.results.save_position(t, dollars_trades, dollars_holdings_at_next_t) 86 | 87 | for func in self.hooks.get("after_trades", []): 88 | func(self, t, dollars_trades, dollars_holdings_at_next_t) 89 | 90 | print(f"\n\nDone simulating at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.") 91 | return self.results 92 | 93 | def _set_time_periods(self, **kwargs): 94 | time_periods = kwargs.get("time_periods", self.strategy.actual_returns.index) 95 | self.start_date = kwargs.get("start_date", time_periods[0]) 96 | self.end_date = kwargs.get("end_date", time_periods[-1]) 97 | 98 | self.time_periods = time_periods[ 99 | (time_periods >= self.start_date) & (time_periods <= self.end_date) 100 | ] 101 | 102 | def _create_initial_portfolio_if_not_provided(self, **kwargs): 103 | aum = kwargs.get("aum", 100_000_000) 104 | initial_portfolio = pd.Series( 105 | index=self.strategy.actual_returns.columns, data=0 106 | ) 107 | initial_portfolio[self.strategy.cash_column_name] = aum 108 | 109 | return initial_portfolio 110 | 111 | def _get_initial_t(self): 112 | try: 113 | median_time_delta = statistics.median( 114 | self.time_periods[1:5] - self.time_periods[0:4] 115 | ) 116 | except ValueError: 117 | median_time_delta = self.time_periods[1] - self.time_periods[0] 118 | 119 | return pd.to_datetime(self.start_date) - median_time_delta 120 | 121 | def _dask_start_client_and_cluster(self, retries=5, delay=15): 122 | print( 123 | "\nDistributing trade generation with Dask client.\n\nTrade-specific costs, constraints, and risk-models will continue to work as expected." 124 | ) 125 | if self.dask_cluster: 126 | self.client = Client(self.dask_cluster, timeout="3600s") 127 | else: 128 | for i in range(retries): 129 | try: 130 | print( 131 | f"\nCreating Dask cluster at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}..." 132 | ) 133 | self.dask_cluster = FargateCluster(**self.dask_cluster_config) 134 | self.client = Client(self.dask_cluster, timeout="600s") 135 | print( 136 | f"\nCluster created. Distributing tasks at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}." 137 | ) 138 | return True 139 | except Exception as e: 140 | print( 141 | f"Connection attempt {i+1} failed: {e}. Will retry in {delay}s" 142 | ) 143 | if i < retries - 1: 144 | time.sleep(delay) 145 | else: 146 | raise 147 | -------------------------------------------------------------------------------- /investos/util.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from functools import wraps 3 | 4 | import cvxpy as cvx 5 | import numpy as np 6 | import pandas as pd 7 | 8 | 9 | def deep_dict_merge(default_d, update_d): 10 | "Deep copies update_d onto default_d recursively" 11 | 12 | default_d = copy.deepcopy(default_d) 13 | update_d = copy.deepcopy(update_d) 14 | 15 | def deep_dict_merge_inner(default_d, update_d): 16 | for k in update_d.keys(): 17 | if ( 18 | k in default_d 19 | and isinstance(default_d[k], dict) 20 | and isinstance(update_d[k], dict) 21 | ): 22 | deep_dict_merge_inner(default_d[k], update_d[k]) 23 | else: 24 | default_d[k] = update_d[k] 25 | 26 | deep_dict_merge_inner(default_d, update_d) 27 | return default_d # With update_d values copied onto it 28 | 29 | 30 | def get_value_at_t(source, current_time, prediction_time=None, use_lookback=False): 31 | """ 32 | Obtain the value(s) of a source object at a given time. 33 | 34 | Parameters 35 | ---------- 36 | source : callable, pd.Series, pd.DataFrame, or other object 37 | - If callable, returns source(current_time, prediction_time). 38 | - If a pandas object, returns the value at the index matching 39 | current_time (or (current_time, prediction_time) for MultiIndex). 40 | - If no matching index is found, returns the object itself unless 41 | use_lookback=True, in which case it returns the most recent prior index. 42 | 43 | current_time : np.Timestamp 44 | Time at which the value is desired. 45 | 46 | prediction_time : np.Timestamp or None 47 | Optional forecast time. If None, defaults to current_time. 48 | 49 | use_lookback : bool 50 | If True, and no exact index match exists, return the value at the 51 | closest index strictly before current_time. 52 | 53 | Returns 54 | ------- 55 | The retrieved value or the original source object. 56 | """ 57 | if prediction_time is None: 58 | prediction_time = current_time 59 | 60 | # Case 1: Callable source 61 | if callable(source): 62 | return source(current_time, prediction_time) 63 | 64 | # Case 2: Pandas Series/DataFrame 65 | if isinstance(source, (pd.Series, pd.DataFrame)): 66 | try: 67 | if isinstance(source.index, pd.MultiIndex): 68 | return source.loc[(current_time, prediction_time)] 69 | else: 70 | return source.loc[current_time] 71 | except KeyError: 72 | if not use_lookback: 73 | return source 74 | 75 | # Lookback mode: find the closest earlier timestamp 76 | time_index = source.index.get_level_values(0) 77 | earlier_times = time_index[time_index < current_time] 78 | 79 | if not earlier_times.empty: 80 | closest_time = earlier_times.max() 81 | return source.loc[closest_time] 82 | else: 83 | return source 84 | 85 | # Case 3: Fallback 86 | return source 87 | 88 | 89 | def clip_for_dates(func): 90 | """ 91 | Decorator that restricts the returned pandas object to the date range 92 | defined by the instance's `start_date` and `end_date`. 93 | """ 94 | 95 | @wraps(func) 96 | def wrapper(self, *args, **kwargs): 97 | pd_obj = func(self, *args, **kwargs) 98 | return pd_obj[ 99 | (pd_obj.index >= self.start_date) & (pd_obj.index <= self.end_date) 100 | ] 101 | 102 | return wrapper 103 | 104 | 105 | def remove_excluded_columns_pd(arg, exclude_assets=None, include_assets=None): 106 | """Filter a DataFrame or Series by keeping `include_assets` if provided, otherwise dropping `exclude_assets`.""" 107 | if include_assets: 108 | if isinstance(arg, pd.DataFrame): 109 | return arg[[col for col in include_assets if col in arg.columns]] 110 | elif isinstance(arg, pd.Series): 111 | return arg[[col for col in include_assets if col in arg]] 112 | else: 113 | return arg 114 | else: 115 | if isinstance(arg, pd.DataFrame): 116 | return arg.drop(columns=exclude_assets, errors="ignore") 117 | elif isinstance(arg, pd.Series): 118 | return arg.drop(exclude_assets, errors="ignore") 119 | else: 120 | return arg 121 | 122 | 123 | def remove_excluded_columns_np( 124 | np_arr, holdings_cols, exclude_assets=None, include_assets=None 125 | ): 126 | """Filter a NumPy array by including or excluding columns based on asset names.""" 127 | if include_assets: 128 | idx_incl_assets = holdings_cols.get_indexer(include_assets) 129 | # Filter out -1 values (i.e. assets with no match) 130 | idx_incl_assets = idx_incl_assets[idx_incl_assets != -1] 131 | # Create a boolean array of False values 132 | mask = np.zeros(np_arr.shape, dtype=bool) 133 | # Set the values at the indices to exclude to False 134 | mask[idx_incl_assets] = True 135 | return np_arr[mask] 136 | elif exclude_assets: 137 | idx_excl_assets = holdings_cols.get_indexer(exclude_assets) 138 | # Filter out -1 values (i.e. assets with no match) 139 | idx_excl_assets = idx_excl_assets[idx_excl_assets != -1] 140 | # Create a boolean array of True values 141 | mask = np.ones(np_arr.shape, dtype=bool) 142 | # Set the values at the indices to exclude to False 143 | mask[idx_excl_assets] = False 144 | return np_arr[mask] 145 | else: 146 | return np_arr 147 | 148 | 149 | def get_max_key_lt_or_eq_value(dictionary, value): 150 | """ 151 | Returns the maximum key in the dictionary that is less than or equal to the given value. 152 | If no such key exists, returns None. 153 | 154 | Useful for looking up values by datetime. 155 | """ 156 | # Filter keys that are less than or equal to the value 157 | valid_keys = [k for k in dictionary.keys() if k <= value] 158 | 159 | # Return the max of the valid keys if the list is not empty 160 | if valid_keys: 161 | return max(valid_keys) 162 | else: 163 | return None 164 | 165 | 166 | def _solve_and_extract_trade_weights( 167 | prob, weights_trades, t, solver, solver_opts, holdings 168 | ): 169 | try: 170 | prob.solve(solver=solver, **solver_opts) 171 | return ( 172 | t, 173 | weights_trades.value, 174 | ) # Return the value of weights_trades after solving 175 | except (cvx.SolverError, cvx.DCPError, TypeError): 176 | return t, pd.Series(index=holdings.index, data=0.0).values # Zero trade 177 | -------------------------------------------------------------------------------- /examples/rank_long_short.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "id": "8bf7da3d-4666-42f6-a41c-a4acc3930191", 7 | "metadata": {}, 8 | "outputs": [], 9 | "source": [ 10 | "# From investos.io/guides/0.4/simple_examples/rank_long_short/" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 1, 16 | "id": "adfc1a6f-3805-40bc-bc2a-81093827ada0", 17 | "metadata": { 18 | "execution": { 19 | "iopub.execute_input": "2024-07-16T17:35:40.773727Z", 20 | "iopub.status.busy": "2024-07-16T17:35:40.772467Z", 21 | "iopub.status.idle": "2024-07-16T17:36:07.232707Z", 22 | "shell.execute_reply": "2024-07-16T17:36:07.232420Z", 23 | "shell.execute_reply.started": "2024-07-16T17:35:40.773669Z" 24 | }, 25 | "tags": [] 26 | }, 27 | "outputs": [], 28 | "source": [ 29 | "import pandas as pd\n", 30 | "import investos as inv" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 2, 36 | "id": "8b102414-abc8-4ede-b657-453605cd5586", 37 | "metadata": { 38 | "execution": { 39 | "iopub.execute_input": "2024-07-16T17:36:07.233573Z", 40 | "iopub.status.busy": "2024-07-16T17:36:07.233420Z", 41 | "iopub.status.idle": "2024-07-16T17:36:10.710706Z", 42 | "shell.execute_reply": "2024-07-16T17:36:10.709960Z", 43 | "shell.execute_reply.started": "2024-07-16T17:36:07.233564Z" 44 | }, 45 | "tags": [] 46 | }, 47 | "outputs": [], 48 | "source": [ 49 | "actual_returns = pd.read_parquet(\"https://investos.io/example_actual_returns.parquet\")\n", 50 | "forecast_returns = pd.read_parquet(\"https://investos.io/example_forecast_returns.parquet\")" 51 | ] 52 | }, 53 | { 54 | "cell_type": "code", 55 | "execution_count": 15, 56 | "id": "46280ac6-d647-4a82-b2fb-ad77296d221f", 57 | "metadata": { 58 | "execution": { 59 | "iopub.execute_input": "2024-07-16T17:41:15.344226Z", 60 | "iopub.status.busy": "2024-07-16T17:41:15.343635Z", 61 | "iopub.status.idle": "2024-07-16T17:41:15.350045Z", 62 | "shell.execute_reply": "2024-07-16T17:41:15.349111Z", 63 | "shell.execute_reply.started": "2024-07-16T17:41:15.344189Z" 64 | }, 65 | "tags": [] 66 | }, 67 | "outputs": [], 68 | "source": [ 69 | "strategy = inv.portfolio.strategy.RankLongShort(\n", 70 | " actual_returns = actual_returns,\n", 71 | " metric_to_rank = forecast_returns,\n", 72 | " leverage=1.6,\n", 73 | " ratio_long=130,\n", 74 | " ratio_short=30,\n", 75 | " percent_long=0.2,\n", 76 | " percent_short=0.2,\n", 77 | " n_periods_held=60,\n", 78 | " cash_column_name=\"cash\"\n", 79 | ")" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 16, 85 | "id": "ccccfe79-5b86-495d-b7f9-846721907004", 86 | "metadata": { 87 | "execution": { 88 | "iopub.execute_input": "2024-07-16T17:41:15.818515Z", 89 | "iopub.status.busy": "2024-07-16T17:41:15.817707Z", 90 | "iopub.status.idle": "2024-07-16T17:41:15.824825Z", 91 | "shell.execute_reply": "2024-07-16T17:41:15.823945Z", 92 | "shell.execute_reply.started": "2024-07-16T17:41:15.818481Z" 93 | }, 94 | "tags": [] 95 | }, 96 | "outputs": [], 97 | "source": [ 98 | "portfolio = inv.portfolio.BacktestController(\n", 99 | " strategy=strategy,\n", 100 | " start_date='2017-01-01',\n", 101 | " end_date='2018-01-01',\n", 102 | " aum=100_000_000\n", 103 | ")" 104 | ] 105 | }, 106 | { 107 | "cell_type": "code", 108 | "execution_count": 17, 109 | "id": "19631ae7-0d98-46c5-8054-3708b2c9b833", 110 | "metadata": { 111 | "execution": { 112 | "iopub.execute_input": "2024-07-16T17:41:16.326630Z", 113 | "iopub.status.busy": "2024-07-16T17:41:16.325500Z", 114 | "iopub.status.idle": "2024-07-16T17:41:16.838414Z", 115 | "shell.execute_reply": "2024-07-16T17:41:16.838185Z", 116 | "shell.execute_reply.started": "2024-07-16T17:41:16.326575Z" 117 | }, 118 | "tags": [] 119 | }, 120 | "outputs": [ 121 | { 122 | "name": "stdout", 123 | "output_type": "stream", 124 | "text": [ 125 | "Generating historical portfolio trades and positions...\n", 126 | "Done simulating.\n" 127 | ] 128 | } 129 | ], 130 | "source": [ 131 | "backtest_result = portfolio.generate_positions()" 132 | ] 133 | }, 134 | { 135 | "cell_type": "code", 136 | "execution_count": 18, 137 | "id": "481729a6-63c6-47d8-8f76-19ab9058e532", 138 | "metadata": { 139 | "execution": { 140 | "iopub.execute_input": "2024-07-16T17:41:16.839145Z", 141 | "iopub.status.busy": "2024-07-16T17:41:16.839073Z", 142 | "iopub.status.idle": "2024-07-16T17:41:16.857432Z", 143 | "shell.execute_reply": "2024-07-16T17:41:16.857223Z", 144 | "shell.execute_reply.started": "2024-07-16T17:41:16.839137Z" 145 | }, 146 | "tags": [] 147 | }, 148 | "outputs": [ 149 | { 150 | "name": "stdout", 151 | "output_type": "stream", 152 | "text": [ 153 | "Initial timestamp 2017-01-03 00:00:00\n", 154 | "Final timestamp 2017-12-29 00:00:00\n", 155 | "Total portfolio return (%) 17.22%\n", 156 | "Annualized portfolio return (%) 17.49%\n", 157 | "Annualized excess portfolio return (%) 14.42%\n", 158 | "Annualized excess risk (%) 6.09%\n", 159 | "Information ratio (x) 2.37x\n", 160 | "Annualized risk over risk-free (%) 6.09%\n", 161 | "Sharpe ratio (x) 2.37x\n", 162 | "Max drawdown (%) 3.21%\n", 163 | "Annual turnover (x) 9.97x\n", 164 | "Portfolio hit rate (%) 60.0%\n" 165 | ] 166 | } 167 | ], 168 | "source": [ 169 | "backtest_result.summary" 170 | ] 171 | }, 172 | { 173 | "cell_type": "code", 174 | "execution_count": null, 175 | "id": "e1aa49c9-c9f7-41d3-afe2-27c96de46b78", 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [] 179 | } 180 | ], 181 | "metadata": { 182 | "kernelspec": { 183 | "display_name": "Python 3 (ipykernel)", 184 | "language": "python", 185 | "name": "python3" 186 | }, 187 | "language_info": { 188 | "codemirror_mode": { 189 | "name": "ipython", 190 | "version": 3 191 | }, 192 | "file_extension": ".py", 193 | "mimetype": "text/x-python", 194 | "name": "python", 195 | "nbconvert_exporter": "python", 196 | "pygments_lexer": "ipython3", 197 | "version": "3.11.9" 198 | } 199 | }, 200 | "nbformat": 4, 201 | "nbformat_minor": 5 202 | } 203 | -------------------------------------------------------------------------------- /docs/source/_static/tailwind_output.css: -------------------------------------------------------------------------------- 1 | /*@tailwind base;*/ 2 | 3 | .sr-only { 4 | position: absolute; 5 | width: 1px; 6 | height: 1px; 7 | padding: 0; 8 | margin: -1px; 9 | overflow: hidden; 10 | clip: rect(0, 0, 0, 0); 11 | white-space: nowrap; 12 | border-width: 0 13 | } 14 | 15 | .absolute { 16 | position: absolute 17 | } 18 | 19 | .inset-x-0 { 20 | left: 0px; 21 | right: 0px 22 | } 23 | 24 | .top-0 { 25 | top: 0px 26 | } 27 | 28 | .z-50 { 29 | z-index: 50 30 | } 31 | 32 | .-m-1 { 33 | margin: -0.25rem 34 | } 35 | 36 | .-m-1\.5 { 37 | margin: -0.375rem 38 | } 39 | 40 | .mx-auto { 41 | margin-left: auto; 42 | margin-right: auto 43 | } 44 | 45 | .mt-10 { 46 | margin-top: 2.5rem 47 | } 48 | 49 | .mt-16 { 50 | margin-top: 4rem 51 | } 52 | 53 | .mt-2 { 54 | margin-top: 0.5rem 55 | } 56 | 57 | .mt-6 { 58 | margin-top: 1.5rem 59 | } 60 | 61 | .mt-8 { 62 | margin-top: 2rem 63 | } 64 | 65 | .block { 66 | display: block 67 | } 68 | 69 | .flex { 70 | display: flex 71 | } 72 | 73 | .grid { 74 | display: grid 75 | } 76 | 77 | .hidden { 78 | display: none 79 | } 80 | 81 | .h-10 { 82 | height: 2.5rem 83 | } 84 | 85 | .h-6 { 86 | height: 1.5rem 87 | } 88 | 89 | .w-6 { 90 | width: 1.5rem 91 | } 92 | 93 | .w-auto { 94 | width: auto 95 | } 96 | 97 | .w-full { 98 | width: 100% 99 | } 100 | 101 | .max-w-7xl { 102 | max-width: 80rem 103 | } 104 | 105 | .grid-cols-2 { 106 | grid-template-columns: repeat(2, minmax(0, 1fr)) 107 | } 108 | 109 | .items-center { 110 | align-items: center 111 | } 112 | 113 | .justify-center { 114 | justify-content: center 115 | } 116 | 117 | .justify-between { 118 | justify-content: space-between 119 | } 120 | 121 | .gap-8 { 122 | gap: 2rem 123 | } 124 | 125 | .space-x-6 > :not([hidden]) ~ :not([hidden]) { 126 | --tw-space-x-reverse: 0; 127 | margin-right: calc(1.5rem * var(--tw-space-x-reverse)); 128 | margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))) 129 | } 130 | 131 | .space-y-4 > :not([hidden]) ~ :not([hidden]) { 132 | --tw-space-y-reverse: 0; 133 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); 134 | margin-bottom: calc(1rem * var(--tw-space-y-reverse)) 135 | } 136 | 137 | .rounded-md { 138 | border-radius: 0.375rem 139 | } 140 | 141 | .border-t { 142 | border-top-width: 1px 143 | } 144 | 145 | .border-gray-900\/10 { 146 | border-color: rgb(17 24 39 / 0.1) 147 | } 148 | 149 | .bg-indigo-600 { 150 | --tw-bg-opacity: 1; 151 | background-color: rgb(79 70 229 / var(--tw-bg-opacity)) 152 | } 153 | 154 | .bg-white { 155 | --tw-bg-opacity: 1; 156 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)) 157 | } 158 | 159 | .p-1 { 160 | padding: 0.25rem 161 | } 162 | 163 | .p-1\.5 { 164 | padding: 0.375rem 165 | } 166 | 167 | .p-6 { 168 | padding: 1.5rem 169 | } 170 | 171 | .px-3 { 172 | padding-left: 0.75rem; 173 | padding-right: 0.75rem 174 | } 175 | 176 | .px-6 { 177 | padding-left: 1.5rem; 178 | padding-right: 1.5rem 179 | } 180 | 181 | .py-2 { 182 | padding-top: 0.5rem; 183 | padding-bottom: 0.5rem 184 | } 185 | 186 | .pb-8 { 187 | padding-bottom: 2rem 188 | } 189 | 190 | .pt-20 { 191 | padding-top: 5rem 192 | } 193 | 194 | .pt-28 { 195 | padding-top: 7rem 196 | } 197 | 198 | .pt-8 { 199 | padding-top: 2rem 200 | } 201 | 202 | .text-sm { 203 | font-size: 0.875rem; 204 | line-height: 1.25rem 205 | } 206 | 207 | .text-xs { 208 | font-size: 0.75rem; 209 | line-height: 1rem 210 | } 211 | 212 | .font-semibold { 213 | font-weight: 600 214 | } 215 | 216 | .leading-5 { 217 | line-height: 1.25rem 218 | } 219 | 220 | .leading-6 { 221 | line-height: 1.5rem 222 | } 223 | 224 | .text-gray-400 { 225 | --tw-text-opacity: 1; 226 | color: rgb(156 163 175 / var(--tw-text-opacity)) 227 | } 228 | 229 | .text-gray-500 { 230 | --tw-text-opacity: 1; 231 | color: rgb(107 114 128 / var(--tw-text-opacity)) 232 | } 233 | 234 | .text-gray-600 { 235 | --tw-text-opacity: 1; 236 | color: rgb(75 85 99 / var(--tw-text-opacity)) 237 | } 238 | 239 | .text-gray-900 { 240 | --tw-text-opacity: 1; 241 | color: rgb(17 24 39 / var(--tw-text-opacity)) 242 | } 243 | 244 | .text-white { 245 | --tw-text-opacity: 1; 246 | color: rgb(255 255 255 / var(--tw-text-opacity)) 247 | } 248 | 249 | .underline { 250 | text-decoration-line: underline 251 | } 252 | 253 | .shadow-sm { 254 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); 255 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color); 256 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) 257 | } 258 | 259 | .hover\:bg-indigo-500:hover { 260 | --tw-bg-opacity: 1; 261 | background-color: rgb(99 102 241 / var(--tw-bg-opacity)) 262 | } 263 | 264 | .hover\:text-gray-500:hover { 265 | --tw-text-opacity: 1; 266 | color: rgb(107 114 128 / var(--tw-text-opacity)) 267 | } 268 | 269 | .hover\:text-gray-900:hover { 270 | --tw-text-opacity: 1; 271 | color: rgb(17 24 39 / var(--tw-text-opacity)) 272 | } 273 | 274 | .focus-visible\:outline:focus-visible { 275 | outline-style: solid 276 | } 277 | 278 | .focus-visible\:outline-2:focus-visible { 279 | outline-width: 2px 280 | } 281 | 282 | .focus-visible\:outline-offset-2:focus-visible { 283 | outline-offset: 2px 284 | } 285 | 286 | .focus-visible\:outline-indigo-600:focus-visible { 287 | outline-color: #4f46e5 288 | } 289 | 290 | @media (min-width: 640px) { 291 | .sm\:mt-20 { 292 | margin-top: 5rem 293 | } 294 | 295 | .sm\:flex { 296 | display: flex 297 | } 298 | 299 | .sm\:max-w-md { 300 | max-width: 28rem 301 | } 302 | 303 | .sm\:pt-24 { 304 | padding-top: 6rem 305 | } 306 | } 307 | 308 | @media (min-width: 768px) { 309 | .md\:order-1 { 310 | order: 1 311 | } 312 | 313 | .md\:order-2 { 314 | order: 2 315 | } 316 | 317 | .md\:mt-0 { 318 | margin-top: 0px 319 | } 320 | 321 | .md\:flex { 322 | display: flex 323 | } 324 | 325 | .md\:grid { 326 | display: grid 327 | } 328 | 329 | .md\:grid-cols-2 { 330 | grid-template-columns: repeat(2, minmax(0, 1fr)) 331 | } 332 | 333 | .md\:items-center { 334 | align-items: center 335 | } 336 | 337 | .md\:justify-between { 338 | justify-content: space-between 339 | } 340 | 341 | .md\:gap-8 { 342 | gap: 2rem 343 | } 344 | } 345 | 346 | @media (min-width: 1024px) { 347 | .lg\:mt-24 { 348 | margin-top: 6rem 349 | } 350 | 351 | .lg\:flex { 352 | display: flex 353 | } 354 | 355 | .lg\:flex-1 { 356 | flex: 1 1 0% 357 | } 358 | 359 | .lg\:justify-end { 360 | justify-content: flex-end 361 | } 362 | 363 | .lg\:gap-x-12 { 364 | -moz-column-gap: 3rem; 365 | column-gap: 3rem 366 | } 367 | 368 | .lg\:px-8 { 369 | padding-left: 2rem; 370 | padding-right: 2rem 371 | } 372 | 373 | .lg\:pt-32 { 374 | padding-top: 8rem 375 | } 376 | } 377 | 378 | @media (min-width: 1280px) { 379 | .xl\:col-span-2 { 380 | grid-column: span 2 / span 2 381 | } 382 | 383 | .xl\:mt-0 { 384 | margin-top: 0px 385 | } 386 | 387 | .xl\:grid { 388 | display: grid 389 | } 390 | 391 | .xl\:grid-cols-3 { 392 | grid-template-columns: repeat(3, minmax(0, 1fr)) 393 | } 394 | 395 | .xl\:gap-8 { 396 | gap: 2rem 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /guides/introduction/how_investos_works.md: -------------------------------------------------------------------------------- 1 |

How InvestOS Works

2 | 3 | ## The Pieces 4 | 5 | InvestOS has the following classes, which work together (as will be described shortly) to create portfolios and associated backtest results: 6 | 7 | - [BacktestController](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/backtest_controller.py) 8 | - [BaseStrategy](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/base_strategy.py) 9 | - [BaseResult](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py) 10 | - [BaseCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/base_cost.py) 11 | - [BaseConstraint](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/constraint_model/base_constraint.py) 12 | - [BaseRisk](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/risk_model/base_risk.py) 13 | 14 | ### BacktestController 15 | 16 | [BacktestController](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/backtest_controller.py) incrementally generates point-in-time portfolio positions based on an investment BaseStrategy. 17 | 18 | Incrementally generated positions are saved into a BaseResult class, which contains a myriad of performance reporting methods for convenience. 19 | 20 | ### BaseStrategy 21 | 22 | [BaseStrategy](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/base_strategy.py) provides a common interface to extend to create custom investment strategies. 23 | 24 | BacktestController will ask (BaseStrategy) investment strategies to `generate_trade_list` for each point-in-time period in your backtest. BacktestController will then save the trade list returned by the (BaseStrategy) investment strategy, calculate resulting portfolio holdings, and save both into BaseResult for performance reporting. 25 | 26 | Off-the-shelf investment strategies, which extend BaseStrategy, include: 27 | 28 | - [Single Period Optimization](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo.py) (SPO): optimizes for max estimated return after estimated costs and (a utility penalty for) estimated portfolio variance 29 | - [Single Period Optimization Tranches](https://github.com/ForecastOS/investos/tree/v0.4.1/investos/portfolio/strategy/spo_tranches.py) (SPOTranches): Like SPO, but builds portfolio in separate tranches. Tranches are cycled in and out by customizable holding period. Tranches can be analyzed and altered in flight using BacktestController hooks. 30 | - [Single Period Optimization Weights](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo_weights.py) (SPOWeights): optimizes for min deviation in portfolio weights, given constraints. 31 | - [RankLongShort](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/rank_long_short.py) 32 | 33 | **Note**: `generate_trade_list` can be used to generate a trade list outside of a backtest context (i.e. to implement your investment strategy in the market). 34 | 35 | ### BaseResult 36 | 37 | The [BaseResult](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/result/base_result.py) class captures trades and resulting portfolio positions sent from BacktestController. 38 | 39 | It provides performance reporting methods for convenience, allowing you to analyze your backtest results. 40 | 41 | ### BaseCost 42 | 43 | [BaseCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/base_cost.py) provides a common interface to extend to create custom cost models. 44 | 45 | Cost models are passed into your investment strategy (BaseStrategy) upon initialization of your investment strategy. Your investment strategy will calculate estimated (if using SPO) and simulated realized costs, based on the logic in your cost model. 46 | 47 | Off-the-shelf costs, which extend BaseCost, include: 48 | 49 | - [ShortHoldingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/short_holding_cost.py) 50 | - [TradingCost](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/cost_model/trading_cost.py) 51 | 52 | ### BaseConstraint 53 | 54 | [BaseConstraint](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/constraint_model/base_constraint.py) provides a common interface to extend to create custom constraint models. 55 | 56 | **Constraint models are only useful if your investment strategy uses convex portfolio optimization** (used by SPO classes). 57 | 58 | Constraint models are passed into your investment strategy (BaseStrategy) upon initialization of your investment strategy. Your investment strategy will optimize your trades and resulting positions without breaching any of the constraints you define (e.g. max leverage, max weight, equal long / short, etc.). 59 | 60 | If your constraints are overly restrictive (preventing a possible solution), the BacktestController will default to a zero trade (i.e. will hold starting positions with no changes). 61 | 62 | Off-the-shelf constraints, which extend BaseConstraint, include: 63 | 64 | - [Factor constraints](https://github.com/ForecastOS/investos/tree/v0.4.1/investos/portfolio/constraint_model/factor_constraint.py) 65 | - [Leverage constraints](https://github.com/ForecastOS/investos/tree/v0.4.1/investos/portfolio/constraint_model/leverage_constraint.py) 66 | - [Long / market-neutral constraints](https://github.com/ForecastOS/investos/tree/v0.4.1/investos/portfolio/constraint_model/long_constraint.py) 67 | - [Position weight (max/min) constraints](https://github.com/ForecastOS/investos/tree/v0.4.1/investos/portfolio/constraint_model/weight_constraint.py) 68 | - [Turnover constraints](https://github.com/ForecastOS/investos/tree/v0.4.1/investos/portfolio/constraint_model/trade_constraint.py) 69 | 70 | **There are +20 off-the-shelf contraint models available.** We regularly release new constraint models as needed / helpful. 71 | 72 | ### BaseRisk 73 | 74 | [BaseRisk](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/risk_model/base_risk.py) extends BaseCost. 75 | 76 | Unlike BaseCost, it does not apply actual costs to your backtest results (BaseResult); realized costs from risk models in your backtest will always be 0. 77 | 78 | It does, however: 79 | 80 | 1. Optionally apply a (utility) cost during portfolio creation for convex-optimization-based investment strategies (like [SPO](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo.py)) to penalize estimated portfolio volatility 81 | 2. Optionally output a portfolio variance estimate for creating a mean-variance optimized (MVO) portfolio (i.e. minimizing variance for a given return) 82 | 83 | ## Extend Base Classes For Custom Use Cases 84 | 85 | With the exception of BacktestController, we expect you to extend the above base classes to fit your own use cases (where needed). Following guides will expand on how to customize each class above. 86 | 87 | If this is of interest, we also encourage you to review the open-source codebase; we've done our best to make it as simple and understandable as possible. Should you extend one of our base classes in a way that might be useful to other investors, we also encourage you to open a PR! 88 | 89 | ## Use Off-The-Shelf Classes Where Possible 90 | 91 | As mentioned above, we've created some off-the-shelf classes that extend the above base classes - like [SPO](https://github.com/ForecastOS/investos/tree/v0.3.9/investos/portfolio/strategy/spo.py) (single period optimization), an extension of BaseStrategy. 92 | 93 | Hopefully you find them useful and they save you time. Our goal is to cover 100% of common backtesting and portfolio engineering requirements with our off-the-shelf models. 94 | 95 | Common off-the-shelf classes will be discussed in more detail in the following guides! 96 | 97 | ## Next: An Example Backtest Using RankLongShort 98 | 99 | Now that you have an idea how InvestOS works, let's move on to our next guide: [Rank Long Short](/guides/simple_examples/rank_long_short). 100 | -------------------------------------------------------------------------------- /examples/spo.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "id": "b916eae5-159c-46f9-b869-55866686c35e", 7 | "metadata": { 8 | "execution": { 9 | "iopub.execute_input": "2024-07-16T18:36:23.514356Z", 10 | "iopub.status.busy": "2024-07-16T18:36:23.513711Z", 11 | "iopub.status.idle": "2024-07-16T18:36:24.889943Z", 12 | "shell.execute_reply": "2024-07-16T18:36:24.889671Z", 13 | "shell.execute_reply.started": "2024-07-16T18:36:23.514314Z" 14 | } 15 | }, 16 | "outputs": [], 17 | "source": [ 18 | "import pandas as pd\n", 19 | "import investos as inv\n", 20 | "from investos.portfolio.cost_model import *\n", 21 | "from investos.portfolio.constraint_model import *" 22 | ] 23 | }, 24 | { 25 | "cell_type": "code", 26 | "execution_count": 2, 27 | "id": "10c4be82-1de9-4384-9b61-88772101707c", 28 | "metadata": { 29 | "execution": { 30 | "iopub.execute_input": "2024-07-16T18:36:24.890647Z", 31 | "iopub.status.busy": "2024-07-16T18:36:24.890557Z", 32 | "iopub.status.idle": "2024-07-16T18:36:27.085324Z", 33 | "shell.execute_reply": "2024-07-16T18:36:27.084488Z", 34 | "shell.execute_reply.started": "2024-07-16T18:36:24.890641Z" 35 | }, 36 | "tags": [] 37 | }, 38 | "outputs": [], 39 | "source": [ 40 | "actual_returns = pd.read_parquet(\"https://investos.io/example_actual_returns.parquet\")\n", 41 | "forecast_returns = pd.read_parquet(\"https://investos.io/example_forecast_returns.parquet\")" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": 3, 47 | "id": "979b6a6c-6feb-4726-b23d-fc194b899533", 48 | "metadata": { 49 | "execution": { 50 | "iopub.execute_input": "2024-07-16T18:36:27.086806Z", 51 | "iopub.status.busy": "2024-07-16T18:36:27.086291Z", 52 | "iopub.status.idle": "2024-07-16T18:36:28.590130Z", 53 | "shell.execute_reply": "2024-07-16T18:36:28.589039Z", 54 | "shell.execute_reply.started": "2024-07-16T18:36:27.086781Z" 55 | }, 56 | "tags": [] 57 | }, 58 | "outputs": [], 59 | "source": [ 60 | "# For trading costs:\n", 61 | "actual_prices = pd.read_parquet(\"https://investos.io/example_spo_actual_prices.parquet\")\n", 62 | "forecast_volume = pd.Series(\n", 63 | " pd.read_csv(\"https://investos.io/example_spo_forecast_volume.csv\", index_col=\"asset\")\n", 64 | " .squeeze(),\n", 65 | " name=\"forecast_volume\"\n", 66 | ")\n", 67 | "forecast_std_dev = pd.Series(\n", 68 | " pd.read_csv(\"https://investos.io/example_spo_forecast_std_dev.csv\", index_col=\"asset\")\n", 69 | " .squeeze(),\n", 70 | " name=\"forecast_std_dev\"\n", 71 | ")\n", 72 | "half_spread_percent = 2.5 / 10_000 # 2.5 bps\n", 73 | "half_spread = pd.Series(index=forecast_returns.columns, data=half_spread_percent)" 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": 4, 79 | "id": "d1bed968-ed87-4b7f-953f-fc320a7fd3d4", 80 | "metadata": { 81 | "execution": { 82 | "iopub.execute_input": "2024-07-16T18:36:28.593246Z", 83 | "iopub.status.busy": "2024-07-16T18:36:28.592849Z", 84 | "iopub.status.idle": "2024-07-16T18:36:28.598558Z", 85 | "shell.execute_reply": "2024-07-16T18:36:28.596840Z", 86 | "shell.execute_reply.started": "2024-07-16T18:36:28.593217Z" 87 | }, 88 | "tags": [] 89 | }, 90 | "outputs": [], 91 | "source": [ 92 | "# For short holding costs:\n", 93 | "short_cost_percent = 40 / 10_000 # 40 bps\n", 94 | "trading_days_per_year = 252\n", 95 | "short_rates = pd.Series(index=forecast_returns.columns, data=short_cost_percent/trading_days_per_year)" 96 | ] 97 | }, 98 | { 99 | "cell_type": "code", 100 | "execution_count": 20, 101 | "id": "bc296f92-d0cd-4715-b74c-7fb78ae44ae2", 102 | "metadata": { 103 | "execution": { 104 | "iopub.execute_input": "2024-07-16T18:42:10.112838Z", 105 | "iopub.status.busy": "2024-07-16T18:42:10.112246Z", 106 | "iopub.status.idle": "2024-07-16T18:42:10.125715Z", 107 | "shell.execute_reply": "2024-07-16T18:42:10.124587Z", 108 | "shell.execute_reply.started": "2024-07-16T18:42:10.112803Z" 109 | }, 110 | "tags": [] 111 | }, 112 | "outputs": [], 113 | "source": [ 114 | "strategy = inv.portfolio.strategy.SPO(\n", 115 | " actual_returns = actual_returns,\n", 116 | " forecast_returns = forecast_returns,\n", 117 | " costs = [\n", 118 | " ShortHoldingCost(short_rates=short_rates, exclude_assets=[\"cash\"]),\n", 119 | " TradingCost(\n", 120 | " actual_prices=actual_prices,\n", 121 | " forecast_volume=forecast_volume,\n", 122 | " forecast_std_dev=forecast_std_dev,\n", 123 | " half_spread=half_spread,\n", 124 | " exclude_assets=[\"cash\"],\n", 125 | " ),\n", 126 | " ],\n", 127 | " constraints = [\n", 128 | " MaxShortLeverageConstraint(limit=0.3),\n", 129 | " MaxLongLeverageConstraint(limit=1.3),\n", 130 | " MinWeightConstraint(limit=-0.03),\n", 131 | " MaxWeightConstraint(limit=0.03),\n", 132 | " LongCashConstraint(),\n", 133 | " MaxAbsTurnoverConstraint(limit=0.05),\n", 134 | " ],\n", 135 | " cash_column_name=\"cash\"\n", 136 | ")" 137 | ] 138 | }, 139 | { 140 | "cell_type": "code", 141 | "execution_count": 21, 142 | "id": "3a0d2038-e8a6-4cba-9af2-454c79d25c05", 143 | "metadata": { 144 | "execution": { 145 | "iopub.execute_input": "2024-07-16T18:42:10.281288Z", 146 | "iopub.status.busy": "2024-07-16T18:42:10.280758Z", 147 | "iopub.status.idle": "2024-07-16T18:42:10.288612Z", 148 | "shell.execute_reply": "2024-07-16T18:42:10.287363Z", 149 | "shell.execute_reply.started": "2024-07-16T18:42:10.281249Z" 150 | }, 151 | "tags": [] 152 | }, 153 | "outputs": [], 154 | "source": [ 155 | "portfolio = inv.portfolio.BacktestController(\n", 156 | " strategy=strategy,\n", 157 | " start_date='2017-01-01',\n", 158 | " end_date='2018-01-01',\n", 159 | " hooks = {\n", 160 | " \"after_trades\": [\n", 161 | " lambda backtest, t, u, h_next: print(\".\", end=''),\n", 162 | " ]\n", 163 | " }\n", 164 | ")" 165 | ] 166 | }, 167 | { 168 | "cell_type": "code", 169 | "execution_count": 22, 170 | "id": "ad515921-0be9-4df3-9b02-8490c86d79f5", 171 | "metadata": { 172 | "execution": { 173 | "iopub.execute_input": "2024-07-16T18:42:10.496170Z", 174 | "iopub.status.busy": "2024-07-16T18:42:10.494979Z", 175 | "iopub.status.idle": "2024-07-16T18:43:12.364144Z", 176 | "shell.execute_reply": "2024-07-16T18:43:12.363893Z", 177 | "shell.execute_reply.started": "2024-07-16T18:42:10.496116Z" 178 | }, 179 | "tags": [] 180 | }, 181 | "outputs": [ 182 | { 183 | "name": "stdout", 184 | "output_type": "stream", 185 | "text": [ 186 | "Generating historical portfolio trades and positions...\n", 187 | "...........................................................................................................................................................................................................................................................Done simulating.\n" 188 | ] 189 | } 190 | ], 191 | "source": [ 192 | "backtest_result = portfolio.generate_positions()" 193 | ] 194 | }, 195 | { 196 | "cell_type": "code", 197 | "execution_count": 23, 198 | "id": "d9ef6a7a-68c3-42fa-a8dc-6272660b3eda", 199 | "metadata": { 200 | "execution": { 201 | "iopub.execute_input": "2024-07-16T18:43:12.364856Z", 202 | "iopub.status.busy": "2024-07-16T18:43:12.364777Z", 203 | "iopub.status.idle": "2024-07-16T18:43:12.384146Z", 204 | "shell.execute_reply": "2024-07-16T18:43:12.383904Z", 205 | "shell.execute_reply.started": "2024-07-16T18:43:12.364849Z" 206 | }, 207 | "tags": [] 208 | }, 209 | "outputs": [ 210 | { 211 | "name": "stdout", 212 | "output_type": "stream", 213 | "text": [ 214 | "Initial timestamp 2017-01-03 00:00:00\n", 215 | "Final timestamp 2017-12-29 00:00:00\n", 216 | "Total portfolio return (%) 5.6%\n", 217 | "Annualized portfolio return (%) 5.68%\n", 218 | "Annualized excess portfolio return (%) 2.61%\n", 219 | "Annualized excess risk (%) 2.47%\n", 220 | "Information ratio (x) 1.06x\n", 221 | "Annualized risk over risk-free (%) 2.47%\n", 222 | "Sharpe ratio (x) 1.05x\n", 223 | "Max drawdown (%) 1.05%\n", 224 | "Annual turnover (x) 12.88x\n", 225 | "Portfolio hit rate (%) 60.0%\n" 226 | ] 227 | } 228 | ], 229 | "source": [ 230 | "backtest_result.summary" 231 | ] 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": null, 236 | "id": "46690049-9bca-482d-92ce-4e4b3f067c05", 237 | "metadata": {}, 238 | "outputs": [], 239 | "source": [] 240 | } 241 | ], 242 | "metadata": { 243 | "kernelspec": { 244 | "display_name": "Python 3 (ipykernel)", 245 | "language": "python", 246 | "name": "python3" 247 | }, 248 | "language_info": { 249 | "codemirror_mode": { 250 | "name": "ipython", 251 | "version": 3 252 | }, 253 | "file_extension": ".py", 254 | "mimetype": "text/x-python", 255 | "name": "python", 256 | "nbconvert_exporter": "python", 257 | "pygments_lexer": "ipython3", 258 | "version": "3.11.9" 259 | } 260 | }, 261 | "nbformat": 4, 262 | "nbformat_minor": 5 263 | } 264 | -------------------------------------------------------------------------------- /investos/portfolio/strategy/spo_tranches.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from datetime import datetime 3 | 4 | import cvxpy as cvx 5 | import numpy as np 6 | import pandas as pd 7 | from dask import compute, delayed 8 | 9 | import investos.util as util 10 | from investos.portfolio.constraint_model import ( 11 | BaseConstraint, 12 | LongOnlyConstraint, 13 | MaxWeightConstraint, 14 | ) 15 | from investos.portfolio.cost_model import BaseCost 16 | from investos.portfolio.risk_model import BaseRisk 17 | from investos.portfolio.strategy import BaseStrategy 18 | from investos.util import _solve_and_extract_trade_weights, get_value_at_t 19 | 20 | 21 | class SPOTranches(BaseStrategy): 22 | """Optimization strategy that builds trade list using single period optimization for n_periods_held number of tranches. 23 | 24 | Each tranch is sold after n_periods_held. 25 | 26 | If you're using OSQP as your solver (the default), view the following for tuning: https://osqp.org/docs/interfaces/solver_settings.html 27 | """ 28 | 29 | BASE_SOLVER_OPTS = { 30 | "max_iter": 50_000, 31 | } 32 | 33 | def __init__( 34 | self, 35 | actual_returns: pd.DataFrame, 36 | forecast_returns: pd.DataFrame, 37 | costs: [BaseCost] = [], 38 | constraints: [BaseConstraint] = [ 39 | LongOnlyConstraint(), 40 | MaxWeightConstraint(), 41 | ], 42 | risk_model: BaseRisk = None, 43 | solver=cvx.OSQP, 44 | solver_opts=None, 45 | n_periods_held=5, 46 | **kwargs, 47 | ): 48 | super().__init__( 49 | actual_returns=actual_returns, 50 | costs=costs, 51 | constraints=constraints, 52 | **kwargs, 53 | ) 54 | self.risk_model = risk_model 55 | if self.risk_model: 56 | self.costs.append(self.risk_model) 57 | 58 | self.forecast_returns = forecast_returns 59 | self.solver = solver 60 | self.solver_opts = util.deep_dict_merge( 61 | self.BASE_SOLVER_OPTS, solver_opts or {} 62 | ) 63 | 64 | self.metadata_properties = ["solver", "solver_opts"] 65 | 66 | self.n_periods_held = n_periods_held 67 | self.u_unwind = {} 68 | 69 | self.polishing = kwargs.get("polishing", True) 70 | self.polishing_denom = kwargs.get("polishing_denom", 100_000) 71 | 72 | self.discreet_shares = kwargs.get("discreet_shares", False) 73 | if self.discreet_shares: 74 | self.n_share_block = kwargs.get("n_share_block", 100) 75 | self.actual_prices = kwargs.get("actual_prices", None) 76 | 77 | def formulate_optimization_problem(self, holdings: pd.Series, t: dt.datetime): 78 | value = sum(holdings) 79 | weights_portfolio = holdings / value # Portfolio weights 80 | weights_trades = cvx.Variable(weights_portfolio.size) # Portfolio trades 81 | weights_portfolio_plus_trades = ( 82 | weights_portfolio.values + weights_trades 83 | ) # Portfolio weights after trades 84 | 85 | # JUST CURRENT TRANCHE FOR SPO_TRANCHES 86 | alpha_term = cvx.sum( 87 | cvx.multiply( 88 | get_value_at_t(self.forecast_returns, t).values, weights_trades 89 | ) 90 | ) 91 | 92 | assert alpha_term.is_concave() 93 | 94 | costs, constraints = [], [] 95 | 96 | for cost in self.costs: 97 | cost_expr, const_expr = cost.cvxpy_expression( 98 | t, weights_trades, weights_trades, value, holdings.index 99 | ) # weights_trades only: only calc costs per tranche, no weights_portfolio_plus_trades for tranche opt class 100 | costs.append(cost_expr) 101 | constraints += const_expr 102 | 103 | constraints += [ 104 | item 105 | for item in ( 106 | con.cvxpy_expression( 107 | t, 108 | weights_portfolio_plus_trades, 109 | weights_trades, 110 | value, 111 | holdings.index, 112 | ) 113 | for con in self.constraints 114 | ) 115 | ] 116 | 117 | # For help debugging: 118 | for el in costs: 119 | if not el.is_convex(): 120 | print(t, el, "is not convex") 121 | 122 | for el in constraints: 123 | if not el.is_dcp(): 124 | print(t, el, "is not dcp") 125 | 126 | objective = cvx.Maximize(alpha_term - cvx.sum(costs)) 127 | constraints += [cvx.sum(weights_trades) == 0] 128 | prob = cvx.Problem( 129 | objective, constraints 130 | ) # Trades need to 0 out, i.e. cash account must adjust to make everything net to 0 131 | 132 | return (prob, weights_trades) 133 | 134 | def precompute_trades_distributed(self, holdings: pd.Series, time_periods): 135 | delayed_tasks = [] 136 | 137 | for t in time_periods: 138 | prob, weights_trades = self.formulate_optimization_problem(holdings, t) 139 | delayed_task = delayed(_solve_and_extract_trade_weights)( 140 | prob, weights_trades, t, self.solver, self.solver_opts, holdings 141 | ) 142 | delayed_tasks.append(delayed_task) 143 | 144 | print(f"\nComputing trades at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}.") 145 | results = compute(*delayed_tasks) 146 | print( 147 | f"\nFinished computing trades at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}." 148 | ) 149 | 150 | print("\nSaving distributed trades...") 151 | for t, weights_trades in results: 152 | self._save_data("weights_trades_distr", t, weights_trades) 153 | print("\nDistributed trades saved.") 154 | 155 | return results 156 | 157 | def generate_trade_list(self, holdings: pd.Series, t: dt.datetime) -> pd.Series: 158 | """Calculates and returns trade list (in units of currency passed in) using convex (single period) optimization. 159 | 160 | Parameters 161 | ---------- 162 | holdings : pandas.Series 163 | Holdings at beginning of period `t`. 164 | t : datetime.datetime 165 | The datetime for associated holdings `holdings`. 166 | """ 167 | is_distributed = self.backtest_controller.distributed 168 | 169 | if t is None: 170 | t = dt.datetime.today() 171 | 172 | if is_distributed: 173 | weights_trades = self.weights_trades_distr.loc[t].values 174 | 175 | else: 176 | prob, weights_trades = self.formulate_optimization_problem(holdings, t) 177 | 178 | try: 179 | prob.solve(solver=self.solver, **self.solver_opts) 180 | 181 | if prob.status in ("unbounded", "infeasible"): 182 | print(f"The problem is {prob.status} at {t}.") 183 | return self._zerotrade(holdings) 184 | 185 | weights_trades = weights_trades.value 186 | 187 | except (cvx.SolverError, cvx.DCPError, TypeError) as e: 188 | print(f"The solver failed for {t}. Error details: {e}") 189 | return self._zerotrade(holdings) 190 | 191 | value = sum(holdings) 192 | 193 | try: 194 | dollars_trades = pd.Series( 195 | index=holdings.index, data=(weights_trades * value) 196 | ) 197 | except Exception as e: 198 | print(f"Calculating trades failed for {t}. Error details: {e}") 199 | return self._zerotrade(holdings) 200 | 201 | # Zero out small values; cash (re)calculated later based on trade balance, cash value here doesn't matter 202 | if self.polishing: 203 | dollars_trades[abs(dollars_trades) < value / self.polishing_denom] = 0 204 | 205 | # Round trade to discreet n_share_block (default: 100) 206 | if self.discreet_shares: 207 | prices = get_value_at_t(self.actual_prices, t) 208 | block_prices = prices * self.n_share_block 209 | block_prices[self.cash_column_name] = None 210 | 211 | non_cash_mask = dollars_trades.index != self.cash_column_name 212 | dollars_trades[non_cash_mask] = ( 213 | dollars_trades[non_cash_mask] / block_prices[non_cash_mask] 214 | ).round() * block_prices[non_cash_mask] 215 | 216 | # Unwind logic starts 217 | trades_saved = self.backtest_controller.results.dollars_trades.shape[0] 218 | self._save_data("dollars_trades_unwind_pre", t, dollars_trades) 219 | 220 | if trades_saved > self.n_periods_held: 221 | idx_unwind = trades_saved - self.n_periods_held 222 | t_unwind = self.backtest_controller.results.dollars_trades.index[idx_unwind] 223 | 224 | dollars_trades_unwind_pre = self.dollars_trades_unwind_pre.loc[t_unwind] 225 | dollars_trades_unwind_pre = dollars_trades_unwind_pre.drop( 226 | self.cash_column_name 227 | ) 228 | 229 | r_scale_unwind = self._cum_returns_to_scale_unwind(t_unwind, t) 230 | dollars_trades_unwind_scaled = dollars_trades_unwind_pre * r_scale_unwind 231 | 232 | dollars_trades -= dollars_trades_unwind_scaled 233 | # Unwind logic ends 234 | 235 | return dollars_trades 236 | 237 | def _cum_returns_to_scale_unwind(self, t_unwind: dt.datetime, t: dt.datetime): 238 | df = self.actual_returns + 1 239 | df = df[(df.index >= t_unwind) & (df.index < t)] 240 | 241 | return df.cumprod().iloc[-1].drop(self.cash_column_name) 242 | 243 | def _save_data(self, name: str, t: dt.datetime, entry: pd.Series) -> None: 244 | try: 245 | getattr(self, name).loc[t] = entry 246 | except AttributeError: 247 | setattr( 248 | self, 249 | name, 250 | (pd.Series if np.isscalar(entry) else pd.DataFrame)( 251 | index=[t], data=[entry] 252 | ), 253 | ) 254 | --------------------------------------------------------------------------------