├── 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 | 
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 | [](https://investos.io/) [](https://investos.readthedocs.io/en/latest/) |
13 | | Package | [](https://pypi.python.org/pypi/investos) [](https://pypi.org/project/investos/) |
14 | | Meta | [](https://python-poetry.org/) [](https://github.com/astral-sh/ruff) [](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 |
--------------------------------------------------------------------------------