This a backtest of the aforementioned strategy using synthetically generated LETFs data that allows us to have valid and accurate simulated close prices, dating all the way back to the inception date of the corresponding unleveraged ETF they are based on. Real and simulated data are combined together to form a longer time-series.
import pandas as pd
import yfinance as yf
import pandas_ta as ta
import plotly.express as px
from IPython.display import IFrame
# Import the LETFs data
data = pd.read_csv("./data/extended-leveraged-etfs.csv", index_col=0, parse_dates=True)
data
| SSO | UPRO | SPXL | SH | SDS | SPXS | QLD | TQQQ | PSQ | QID | ... | TYO | UBT | TMF | TBF | TBT | TMV | UGL | GLL | UVXY | TLT | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | |||||||||||||||||||||
| 1986-05-19 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | 0.69170 | NaN | NaN | NaN | NaN | NaN | NaN | 10.60539 |
| 1986-05-20 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | 0.68715 | NaN | NaN | NaN | NaN | NaN | NaN | 10.58417 |
| 1986-05-21 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | 0.70741 | NaN | NaN | NaN | NaN | NaN | NaN | 10.69021 |
| 1986-05-22 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | 0.70279 | NaN | NaN | NaN | NaN | NaN | NaN | 10.66902 |
| 1986-05-23 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | 0.70658 | NaN | NaN | NaN | NaN | NaN | NaN | 10.69021 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2022-10-12 | 39.42 | 27.97 | 52.77 | 17.30 | 54.48 | 29.31 | 35.26 | 18.10 | 15.14 | 28.14 | ... | 13.84 | 24.81 | 8.06000 | 22.69 | 33.01 | 141.25999 | 47.6000 | 36.62 | 13.25000 | 100.35000 |
| 2022-10-13 | 41.49 | 30.14 | 56.85 | 16.85 | 51.63 | 27.04 | 36.86 | 19.31 | 14.79 | 26.89 | ... | 14.03 | 24.32 | 7.85000 | 22.93 | 33.63 | 145.06000 | 46.9100 | 37.12 | 12.69000 | 99.39000 |
| 2022-10-14 | 39.60 | 28.08 | 52.98 | 17.23 | 53.93 | 28.87 | 34.62 | 17.57 | 15.25 | 28.51 | ... | 14.20 | 23.97 | 7.67000 | 23.12 | 34.19 | 148.89000 | 45.7400 | 38.08 | 13.14000 | 98.57000 |
| 2022-10-17 | 41.61 | 30.21 | 57.00 | 16.79 | 51.22 | 26.66 | 36.97 | 19.30 | 14.72 | 26.56 | ... | 14.18 | 23.68 | 7.53000 | 23.26 | 34.61 | 151.49001 | 45.9700 | 37.84 | 12.61000 | 98.09000 |
| 2022-10-18 | 42.57 | 31.11 | 58.99 | 16.59 | 49.96 | 25.73 | 37.55 | 19.78 | 14.59 | 26.17 | ... | 14.13 | 23.80 | 7.59000 | 23.21 | 34.40 | 150.53999 | 46.1852 | 37.72 | 12.21000 | 98.32000 |
9180 rows × 43 columns
We now download the additional unleveraged ETFs required for this specific strategy backtest, and add them to the dataframe. It's possible to generate synthetic versions of these unleveraged ETFs too (there's already TLT in the data above, for example), but the results might be less accurate depending on what index you choose to generate the returns.
# Download the additional unleveraged ETFs required for this specific strategy backtest
unleveraged_etfs = yf.download(["SPY", "QQQ"])["Adj Close"]
unleveraged_etfs.index = unleveraged_etfs.index.tz_localize(None) # remove time info while keeping index type as datetime
# Add the unleveraged ETFs to the main dataframe
merged_df = pd.concat([data, unleveraged_etfs], axis=1)
merged_df
[*********************100%***********************] 2 of 2 completed
| SSO | UPRO | SPXL | SH | SDS | SPXS | QLD | TQQQ | PSQ | QID | ... | TMF | TBF | TBT | TMV | UGL | GLL | UVXY | TLT | QQQ | SPY | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | |||||||||||||||||||||
| 1986-05-19 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | 0.69170 | NaN | NaN | NaN | NaN | NaN | NaN | 10.60539 | NaN | NaN |
| 1986-05-20 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | 0.68715 | NaN | NaN | NaN | NaN | NaN | NaN | 10.58417 | NaN | NaN |
| 1986-05-21 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | 0.70741 | NaN | NaN | NaN | NaN | NaN | NaN | 10.69021 | NaN | NaN |
| 1986-05-22 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | 0.70279 | NaN | NaN | NaN | NaN | NaN | NaN | 10.66902 | NaN | NaN |
| 1986-05-23 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | 0.70658 | NaN | NaN | NaN | NaN | NaN | NaN | 10.69021 | NaN | NaN |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2022-10-13 | 41.49 | 30.14 | 56.85 | 16.85 | 51.63 | 27.04 | 36.86 | 19.31 | 14.79 | 26.89 | ... | 7.85000 | 22.93 | 33.63 | 145.06000 | 46.9100 | 37.12 | 12.69000 | 99.39000 | 268.820007 | 365.970001 |
| 2022-10-14 | 39.60 | 28.08 | 52.98 | 17.23 | 53.93 | 28.87 | 34.62 | 17.57 | 15.25 | 28.51 | ... | 7.67000 | 23.12 | 34.19 | 148.89000 | 45.7400 | 38.08 | 13.14000 | 98.57000 | 260.739990 | 357.630005 |
| 2022-10-17 | 41.61 | 30.21 | 57.00 | 16.79 | 51.22 | 26.66 | 36.97 | 19.30 | 14.72 | 26.56 | ... | 7.53000 | 23.26 | 34.61 | 151.49001 | 45.9700 | 37.84 | 12.61000 | 98.09000 | 269.350006 | 366.820007 |
| 2022-10-18 | 42.57 | 31.11 | 58.99 | 16.59 | 49.96 | 25.73 | 37.55 | 19.78 | 14.59 | 26.17 | ... | 7.59000 | 23.21 | 34.40 | 150.53999 | 46.1852 | 37.72 | 12.21000 | 98.32000 | 271.480011 | 371.130005 |
| 2022-10-19 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | ... | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 270.489990 | 368.500000 |
9181 rows × 45 columns
We now get a subset of this dataframe with only the tickers that are relevant for this particular strategy, and we do a sanity check to make sure there are no issues.
strategy_symbols = ["SPY", "TQQQ", "SPXL", "UVXY", "SQQQ", "TLT", "QQQ"]
strategy_data = merged_df[strategy_symbols].copy()
strategy_data
| SPY | TQQQ | SPXL | UVXY | SQQQ | TLT | QQQ | |
|---|---|---|---|---|---|---|---|
| Date | |||||||
| 1986-05-19 | NaN | NaN | NaN | NaN | NaN | 10.60539 | NaN |
| 1986-05-20 | NaN | NaN | NaN | NaN | NaN | 10.58417 | NaN |
| 1986-05-21 | NaN | NaN | NaN | NaN | NaN | 10.69021 | NaN |
| 1986-05-22 | NaN | NaN | NaN | NaN | NaN | 10.66902 | NaN |
| 1986-05-23 | NaN | NaN | NaN | NaN | NaN | 10.69021 | NaN |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 2022-10-13 | 365.970001 | 19.31 | 56.85 | 12.69000 | 59.11 | 99.39000 | 268.820007 |
| 2022-10-14 | 357.630005 | 17.57 | 52.98 | 13.14000 | 64.38 | 98.57000 | 260.739990 |
| 2022-10-17 | 366.820007 | 19.30 | 57.00 | 12.61000 | 58.00 | 98.09000 | 269.350006 |
| 2022-10-18 | 371.130005 | 19.78 | 58.99 | 12.21000 | 56.59 | 98.32000 | 271.480011 |
| 2022-10-19 | 368.500000 | NaN | NaN | NaN | NaN | NaN | 270.489990 |
9181 rows × 7 columns
strategy_data.info()
<class 'pandas.core.frame.DataFrame'> DatetimeIndex: 9181 entries, 1986-05-19 to 2022-10-19 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 SPY 7486 non-null float64 1 TQQQ 5943 non-null float64 2 SPXL 8769 non-null float64 3 UVXY 8264 non-null object 4 SQQQ 5943 non-null float64 5 TLT 9180 non-null float64 6 QQQ 5944 non-null float64 dtypes: float64(6), object(1) memory usage: 573.8+ KB
UVXY is currently of dtype object. Let's try to convert the column to float64, so we can use it in our calculations.
strategy_data["UVXY"] = strategy_data["UVXY"].astype("float64")
strategy_data["UVXY"].info()
<class 'pandas.core.series.Series'> DatetimeIndex: 9181 entries, 1986-05-19 to 2022-10-19 Series name: UVXY Non-Null Count Dtype -------------- ----- 8264 non-null float64 dtypes: float64(1) memory usage: 143.5 KB
We now create the signals columns we need, using built-in pandas methods or pandas-ta.
# Signals
strategy_data["SPY_MA200"] = ta.sma(strategy_data["SPY"], length=200)
strategy_data["TQQQ_RSI10"] = ta.rsi(strategy_data["TQQQ"], length=10)
strategy_data["SPXL_RSI10"] = ta.rsi(strategy_data["SPXL"], length=10)
strategy_data["SPY_RSI10"] = ta.rsi(strategy_data["SPY"], length=10)
strategy_data["QQQ_RSI10"] = ta.rsi(strategy_data["QQQ"], length=10)
strategy_data["UVXY_RSI10"] = ta.rsi(strategy_data["UVXY"], length=10)
strategy_data["TQQQ_MA20"] = ta.sma(strategy_data["TQQQ"], length=20)
strategy_data["SQQQ_RSI10"] = ta.rsi(strategy_data["SQQQ"], length=10)
strategy_data["TLT_RSI10"] = ta.rsi(strategy_data["TLT"], length=10)
strategy_data["QQQ_5dgain"] = strategy_data["QQQ"].pct_change(5) * 100
strategy_data["TQQQ_1dgain"] = strategy_data["TQQQ"].pct_change(1) * 100
strategy_data["TQQQ_10dSTDreturn"] = strategy_data["TQQQ_1dgain"].rolling(10).std()
# Drop missing values so we start the backtest from the first day all signals and prices are available
strategy_data.dropna(inplace=True)
# Add "BUY" column in first position
strategy_data.insert(0, "BUY", pd.NA) # pd.NA is the new pandas native version of np.nan
strategy_data
| BUY | SPY | TQQQ | SPXL | UVXY | SQQQ | TLT | QQQ | SPY_MA200 | TQQQ_RSI10 | SPXL_RSI10 | SPY_RSI10 | QQQ_RSI10 | UVXY_RSI10 | TQQQ_MA20 | SQQQ_RSI10 | TLT_RSI10 | QQQ_5dgain | TQQQ_1dgain | TQQQ_10dSTDreturn | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||||||
| 1999-04-07 | NaN | 86.938591 | 81.72095 | 45.31803 | 2.879196e+20 | 2.102258e+08 | 33.35910 | 47.432098 | 75.215919 | 63.071977 | 62.293455 | 63.046299 | 64.348921 | 31.463376 | 71.300601 | 34.759583 | 61.585289 | 3.345116 | -3.161540 | 6.725346 |
| 1999-04-08 | NaN | 88.040405 | 83.63468 | 47.05327 | 2.817470e+20 | 2.053532e+08 | 33.66911 | 47.809162 | 75.298414 | 65.188269 | 67.303069 | 67.243478 | 66.252551 | 30.133641 | 72.093586 | 33.332605 | 68.707198 | 5.717654 | 2.341786 | 6.329253 |
| 1999-04-09 | NaN | 88.060799 | 85.15391 | 47.49072 | 2.644424e+20 | 2.016712e+08 | 33.57613 | 48.105450 | 75.376765 | 66.863324 | 68.476215 | 67.319813 | 67.755756 | 26.628145 | 72.914247 | 32.221921 | 64.709255 | 4.200681 | 1.816507 | 5.661916 |
| 1999-04-12 | NaN | 88.999359 | 83.40080 | 48.55757 | 2.655143e+20 | 2.058710e+08 | 33.63814 | 47.782230 | 75.454958 | 62.977932 | 71.269533 | 70.799538 | 64.284968 | 27.210950 | 73.899929 | 34.968226 | 66.168040 | -0.112632 | -2.058755 | 5.549664 |
| 1999-04-13 | NaN | 88.428055 | 79.98025 | 47.59537 | 2.781925e+20 | 2.143630e+08 | 33.42109 | 47.135807 | 75.530900 | 55.931812 | 65.457097 | 66.043419 | 57.715211 | 34.090945 | 74.441727 | 40.394174 | 57.003922 | -1.657761 | -4.101340 | 4.904016 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2022-10-12 | NaN | 356.559998 | 18.10000 | 52.77000 | 1.325000e+01 | 6.335000e+01 | 100.35000 | 262.660004 | 413.231901 | 29.380442 | 32.266999 | 33.369398 | 30.256253 | 65.041294 | 21.645000 | 68.684792 | 34.975614 | -6.851552 | -0.220507 | 6.327154 |
| 2022-10-13 | NaN | 365.970001 | 19.31000 | 56.85000 | 1.269000e+01 | 5.911000e+01 | 99.39000 | 268.820007 | 412.704691 | 37.938121 | 43.105232 | 45.241914 | 40.328453 | 57.202852 | 21.356000 | 57.165998 | 31.833812 | -3.910495 | 6.685083 | 6.389549 |
| 2022-10-14 | NaN | 357.630005 | 17.57000 | 52.98000 | 1.314000e+01 | 6.438000e+01 | 98.57000 | 260.739990 | 412.132767 | 31.784061 | 36.884882 | 38.488365 | 33.316123 | 61.360555 | 21.004500 | 65.221002 | 29.333059 | -3.106658 | -9.010875 | 6.806726 |
| 2022-10-17 | NaN | 366.820007 | 19.30000 | 57.00000 | 1.261000e+01 | 5.800000e+01 | 98.09000 | 269.350006 | 411.613316 | 42.150708 | 45.896145 | 47.993457 | 44.700758 | 54.439482 | 20.710000 | 52.053713 | 27.907179 | 1.103563 | 9.846329 | 7.220773 |
| 2022-10-18 | NaN | 371.130005 | 19.78000 | 58.99000 | 1.221000e+01 | 5.659000e+01 | 98.32000 | 271.480011 | 411.121348 | 44.739629 | 49.835568 | 51.869085 | 47.179520 | 49.735237 | 20.469500 | 49.595027 | 29.725895 | 3.322554 | 2.487047 | 6.484238 |
5924 rows × 20 columns
We create an empty dataframe that will be populated with our asset allocation history as we iterate over the strategy_data dataframe and calculate the strategy signal for each row (day). This allocation dataframe will later be passed to vectorbt.pro for backtesting.
strategy_allocations = pd.DataFrame(index=strategy_data.index, columns=["TQQQ", "SQQQ", "UVXY", "TLT"])
strategy_allocations
| TQQQ | SQQQ | UVXY | TLT | |
|---|---|---|---|---|
| Date | ||||
| 1999-04-07 | NaN | NaN | NaN | NaN |
| 1999-04-08 | NaN | NaN | NaN | NaN |
| 1999-04-09 | NaN | NaN | NaN | NaN |
| 1999-04-12 | NaN | NaN | NaN | NaN |
| 1999-04-13 | NaN | NaN | NaN | NaN |
| ... | ... | ... | ... | ... |
| 2022-10-12 | NaN | NaN | NaN | NaN |
| 2022-10-13 | NaN | NaN | NaN | NaN |
| 2022-10-14 | NaN | NaN | NaN | NaN |
| 2022-10-17 | NaN | NaN | NaN | NaN |
| 2022-10-18 | NaN | NaN | NaN | NaN |
5924 rows × 4 columns
We now write our strategy logic and iterate over each row of the dataframe, finding the asset allocations for each row and putting them in the strategy_allocations dataframe (1 = 100% allocation, 0.5 = 50% etc.)
# Strategy logic
for row in strategy_data.index:
if strategy_data.at[row, "SPY"] > strategy_data.at[row, "SPY_MA200"]: # using .at when workining with single values is more efficient than .loc
if strategy_data.at[row, "TQQQ_RSI10"] > 79:
strategy_allocations.at[row, "UVXY"] = 1
else:
if strategy_data.at[row, "SPXL_RSI10"] > 80:
strategy_allocations.at[row, "UVXY"] = 1
else:
if strategy_data.at[row, "QQQ_5dgain"] < -6:
if strategy_data.at[row, "TQQQ_1dgain"] > 5:
strategy_allocations.at[row, "SQQQ"] = 1
else:
if strategy_data.at[row, "TQQQ_RSI10"] > 31:
strategy_allocations.at[row, "SQQQ"] = 1
else:
strategy_allocations.at[row, "TQQQ"] = 1
else:
if strategy_data.at[row, "QQQ_RSI10"] > 80:
strategy_allocations.at[row, "SQQQ"] = 1
else:
if strategy_data.at[row, "TQQQ_10dSTDreturn"] > 5:
strategy_allocations.at[row, "TLT"] = 1
else:
strategy_allocations.at[row, "TQQQ"] = 1
else:
if strategy_data.at[row, "TQQQ_RSI10"] < 31:
strategy_allocations.at[row, "TQQQ"] = 1
else:
if strategy_data.at[row, "SPY_RSI10"] < 30:
strategy_allocations.at[row, "TQQQ"] = 1
else:
if strategy_data.at[row, "UVXY_RSI10"] > 74:
if strategy_data.at[row, "UVXY_RSI10"] > 84:
if (
strategy_data.at[row, "SQQQ_RSI10"]
> strategy_data.at[row, "TLT_RSI10"]
):
strategy_allocations.at[row, "SQQQ"] = 1
else:
strategy_allocations.at[row, "TLT"] = 1
else:
strategy_allocations.at[row, "UVXY"] = 1
else:
if strategy_data.at[row, "TQQQ"] > strategy_data.at[row, "TQQQ_MA20"]:
if strategy_data.at[row, "SQQQ_RSI10"] < 31:
strategy_allocations.at[row, "SQQQ"] = 1
else:
strategy_allocations.at[row, "TQQQ"] = 1
else:
if (
strategy_data.at[row, "SQQQ_RSI10"]
> strategy_data.at[row, "TLT_RSI10"]
):
strategy_allocations.at[row, "SQQQ"] = 1
else:
strategy_allocations.at[row, "TLT"] = 1
# Replace NaN values with 0
strategy_allocations.fillna(0, inplace=True)
strategy_allocations
| TQQQ | SQQQ | UVXY | TLT | |
|---|---|---|---|---|
| Date | ||||
| 1999-04-07 | 0 | 0 | 0 | 1 |
| 1999-04-08 | 0 | 0 | 0 | 1 |
| 1999-04-09 | 0 | 0 | 0 | 1 |
| 1999-04-12 | 0 | 0 | 0 | 1 |
| 1999-04-13 | 1 | 0 | 0 | 0 |
| ... | ... | ... | ... | ... |
| 2022-10-12 | 1 | 0 | 0 | 0 |
| 2022-10-13 | 0 | 1 | 0 | 0 |
| 2022-10-14 | 0 | 1 | 0 | 0 |
| 2022-10-17 | 0 | 1 | 0 | 0 |
| 2022-10-18 | 0 | 1 | 0 | 0 |
5924 rows × 4 columns
# Save strategy_allocations to CSV for later use
strategy_allocations.to_csv("./data/strategy_allocations.csv")
We also save a dataframe with the close prices of the instruments used in our strategy allocation. This will be needed for backtesting in vectorbt.pro.
strategy_prices = strategy_data[["TQQQ", "SQQQ", "UVXY", "TLT"]]
strategy_prices.to_csv("./data/strategy_prices.csv")
# Save the entire strategy data. This won't be needed later, but it's nice to have, just in case we want to reuse it somewhere.
strategy_data.to_csv("./data/strategy_data.csv")
To run this backtest you'll need to have a valid subscription to vectorbt.pro.
First, we are loading the tickers close prices from the .csv file we saved earlier.
import vectorbtpro as vbt
vbt_prices = vbt.CSVData.fetch("./data/strategy_prices.csv", index_col=0, parse_dates=True) # same syntax you would use with pd.read_csv
vbt_prices.data
{'strategy_prices': TQQQ SQQQ UVXY TLT
Date
1999-04-07 00:00:00+00:00 81.72095 2.102258e+08 2.879196e+20 33.35910
1999-04-08 00:00:00+00:00 83.63468 2.053532e+08 2.817470e+20 33.66911
1999-04-09 00:00:00+00:00 85.15391 2.016712e+08 2.644424e+20 33.57613
1999-04-12 00:00:00+00:00 83.40080 2.058710e+08 2.655143e+20 33.63814
1999-04-13 00:00:00+00:00 79.98025 2.143630e+08 2.781925e+20 33.42109
... ... ... ... ...
2022-10-12 00:00:00+00:00 18.10000 6.335000e+01 1.325000e+01 100.35000
2022-10-13 00:00:00+00:00 19.31000 5.911000e+01 1.269000e+01 99.39000
2022-10-14 00:00:00+00:00 17.57000 6.438000e+01 1.314000e+01 98.57000
2022-10-17 00:00:00+00:00 19.30000 5.800000e+01 1.261000e+01 98.09000
2022-10-18 00:00:00+00:00 19.78000 5.659000e+01 1.221000e+01 98.32000
[5924 rows x 4 columns]}
We now generate a dataframe in a format compatible with vectorbt.pro, which will be populated with our allocation weights later.
symbol_wrapper = vbt_prices.get_symbol_wrapper(symbols=["TQQQ", "SQQQ", "UVXY", "TLT"], freq='1d') # the order of symbols= should match the order of columns in strategy_allocations
print(symbol_wrapper.shape)
print(strategy_allocations.shape)
(5924, 4) (5924, 4)
The wrapper should have the same number of rows and columns as the data we have loaded. Now we can fill symbol_wrapper with data from our allocations dataframe.
tickers_allocations = vbt.PortfolioOptimizer.from_allocations(symbol_wrapper, strategy_allocations)
allocations = tickers_allocations.allocations # can also type tickers_allocations.get_allocations()
allocations
| symbol | TQQQ | SQQQ | UVXY | TLT |
|---|---|---|---|---|
| Date | ||||
| 1999-04-07 00:00:00+00:00 | 0.0 | 0.0 | 0.0 | 1.0 |
| 1999-04-08 00:00:00+00:00 | 0.0 | 0.0 | 0.0 | 1.0 |
| 1999-04-09 00:00:00+00:00 | 0.0 | 0.0 | 0.0 | 1.0 |
| 1999-04-12 00:00:00+00:00 | 0.0 | 0.0 | 0.0 | 1.0 |
| 1999-04-13 00:00:00+00:00 | 1.0 | 0.0 | 0.0 | 0.0 |
| ... | ... | ... | ... | ... |
| 2022-10-12 00:00:00+00:00 | 1.0 | 0.0 | 0.0 | 0.0 |
| 2022-10-13 00:00:00+00:00 | 0.0 | 1.0 | 0.0 | 0.0 |
| 2022-10-14 00:00:00+00:00 | 0.0 | 1.0 | 0.0 | 0.0 |
| 2022-10-17 00:00:00+00:00 | 0.0 | 1.0 | 0.0 | 0.0 |
| 2022-10-18 00:00:00+00:00 | 0.0 | 1.0 | 0.0 | 0.0 |
5924 rows × 4 columns
Now we get a dataframe with the close prices from the data we have loaded.
close_prices = vbt_prices.get()
close_prices
| TQQQ | SQQQ | UVXY | TLT | |
|---|---|---|---|---|
| Date | ||||
| 1999-04-07 00:00:00+00:00 | 81.72095 | 2.102258e+08 | 2.879196e+20 | 33.35910 |
| 1999-04-08 00:00:00+00:00 | 83.63468 | 2.053532e+08 | 2.817470e+20 | 33.66911 |
| 1999-04-09 00:00:00+00:00 | 85.15391 | 2.016712e+08 | 2.644424e+20 | 33.57613 |
| 1999-04-12 00:00:00+00:00 | 83.40080 | 2.058710e+08 | 2.655143e+20 | 33.63814 |
| 1999-04-13 00:00:00+00:00 | 79.98025 | 2.143630e+08 | 2.781925e+20 | 33.42109 |
| ... | ... | ... | ... | ... |
| 2022-10-12 00:00:00+00:00 | 18.10000 | 6.335000e+01 | 1.325000e+01 | 100.35000 |
| 2022-10-13 00:00:00+00:00 | 19.31000 | 5.911000e+01 | 1.269000e+01 | 99.39000 |
| 2022-10-14 00:00:00+00:00 | 17.57000 | 6.438000e+01 | 1.314000e+01 | 98.57000 |
| 2022-10-17 00:00:00+00:00 | 19.30000 | 5.800000e+01 | 1.261000e+01 | 98.09000 |
| 2022-10-18 00:00:00+00:00 | 19.78000 | 5.659000e+01 | 1.221000e+01 | 98.32000 |
5924 rows × 4 columns
We now have everything that is needed to run our backtest: a dataframe with the close prices and one with the target allocations, both in a valid format recognized by vectorbt.pro . We can proceed with running the backtest and showing the results.
portfolio = vbt.Portfolio.from_orders(
close=close_prices,
#bm_close=put_your_benchmark_close_prices_here,
size=allocations, # Here we pass the allocations we calculated earlier
size_type="targetpercent",
group_by=True,
cash_sharing=True,
call_seq="auto",
freq='D', # needed to calculate annualized returns and some metrics like sharpe, sortino etc in portfolio.stats()
fees=0, # can specify a transaction fee
)
Read the documentation of Portfolio.from_orders() for more info. It's possible compare the performance to a benchmark if you pass the desired benchmark strategy/ticker time series to #bm_close . Beware that the close_prices dataframe and the benchmark you are providing must have the same index for accurate comparisons.
In this notebook we will show just the tip of the iceberg of what's possible with vectorbt.pro . Read the documentation for more ideas and metrics.
# This is the most basic overview of the portfolio metrics
portfolio.stats()
Start 1999-04-07 00:00:00+00:00 End 2022-10-18 00:00:00+00:00 Period 5924 days 00:00:00 Start Value 100.0 Min Value 45.944895 Max Value 49532199635.866318 End Value 43538788092.477097 Total Return [%] 43538787992.477097 Benchmark Return [%] -20.26587 Total Time Exposure [%] 99.561107 Max Gross Exposure [%] 100.0 Max Drawdown [%] 73.491664 Max Drawdown Duration 875 days 00:00:00 Total Orders 1910 Total Fees Paid 0.0 Total Trades 959 Win Rate [%] 56.680585 Best Trade [%] 65.372054 Worst Trade [%] -37.213453 Avg Winning Trade [%] 9.067937 Avg Losing Trade [%] -5.788179 Avg Winning Trade Duration 7 days 18:17:54.033149171 Avg Losing Trade Duration 7 days 18:41:46.077348066 Profit Factor 3.598681 Expectancy 47471405.655066 Sharpe Ratio 1.813416 Calmar Ratio 1.805824 Omega Ratio 1.368747 Sortino Ratio 3.026992 Name: group, dtype: object
We can go even further than that and do in-depth analysis of returns and other metrics.
portfolio.returns_stats()
Start 1999-04-07 00:00:00+00:00 End 2022-10-18 00:00:00+00:00 Period 5924 days 00:00:00 Total Return [%] 43538787992.477104 Benchmark Return [%] -20.26587 Annualized Return [%] 132.712975 Annualized Volatility [%] 88.617529 Max Drawdown [%] 73.491664 Max Drawdown Duration 875 days 00:00:00 Sharpe Ratio 1.813416 Calmar Ratio 1.805824 Omega Ratio 1.368747 Sortino Ratio 3.026992 Skew 1.516343 Kurtosis 16.768799 Tail Ratio 1.117592 Common Sense Ratio 2.600781 Value at Risk -0.06385 Alpha 3.985386 Beta -0.107782 Name: group, dtype: object
We can see the annual returns of the strategy, and the advantage of having the output in a simple pandas series is that we can then manipulate and display this data however we want to analyze it in different ways, for example by making a bar plot of the annual returns, in this specific case.
# Annual returns
annual_returns = portfolio.annual_returns # can also type portfolio.get_annual_returns()
annual_returns
Date 1999-04-07 00:00:00+00:00 -0.225791 2000-04-06 00:00:00+00:00 4.087178 2001-04-06 00:00:00+00:00 -0.189695 2002-04-06 00:00:00+00:00 1.131165 2003-04-06 00:00:00+00:00 0.884160 2004-04-05 00:00:00+00:00 -0.289397 2005-04-05 00:00:00+00:00 0.356911 2006-04-05 00:00:00+00:00 -0.166240 2007-04-05 00:00:00+00:00 0.687942 2008-04-04 00:00:00+00:00 2.966231 2009-04-04 00:00:00+00:00 1.305499 2010-04-04 00:00:00+00:00 1.417747 2011-04-04 00:00:00+00:00 0.821203 2012-04-03 00:00:00+00:00 0.130452 2013-04-03 00:00:00+00:00 1.395278 2014-04-03 00:00:00+00:00 0.842624 2015-04-03 00:00:00+00:00 1.350014 2016-04-02 00:00:00+00:00 0.675128 2017-04-02 00:00:00+00:00 1.948730 2018-04-02 00:00:00+00:00 1.696418 2019-04-02 00:00:00+00:00 25.100971 2020-04-01 00:00:00+00:00 9.515804 2021-04-01 00:00:00+00:00 1.459666 2022-04-01 00:00:00+00:00 4.898051 Freq: 365D, Name: group, dtype: float64
# Make an bar plot of the annual returns
annual_returns_plot = px.bar(annual_returns, x=annual_returns.index, y=annual_returns.values, labels={"x":"Year", "y":"Portfolio Return"}, title="Annual Returns")
annual_returns_plot.show()
# Delete this when running the notebook yourself
annual_returns_plot.write_html("./html/annual_returns.html", default_width=800, default_height=500)
IFrame(src='./html/annual_returns.html', width=800, height=500)
Vectorbt.pro also has a lot of already built-in interactive plots that can be generated very easily. Below we'll plot all the available plots just for reference
# Let's plot all the possible subplots just for reference. By default there's only the return curve.
all_portfolio_plots = portfolio.plot(settings=dict(bm_returns=True), subplots="all")
all_portfolio_plots
/Users/lorenzominghetti/Projects/vectorbt.pro-1.7.1/vectorbtpro/generic/plots_builder.py:396: UserWarning: Subplot 'orders' does not support grouped data /Users/lorenzominghetti/Projects/vectorbt.pro-1.7.1/vectorbtpro/generic/plots_builder.py:396: UserWarning: Subplot 'trades' does not support grouped data /Users/lorenzominghetti/Projects/vectorbt.pro-1.7.1/vectorbtpro/generic/plots_builder.py:396: UserWarning: Subplot 'trade_pnl' does not support grouped data /Users/lorenzominghetti/Projects/vectorbt.pro-1.7.1/vectorbtpro/generic/plots_builder.py:396: UserWarning: Subplot 'asset_flow' does not support grouped data /Users/lorenzominghetti/Projects/vectorbt.pro-1.7.1/vectorbtpro/generic/plots_builder.py:396: UserWarning: Subplot 'assets' does not support grouped data
FigureWidget({
'data': [{'legendgroup': '0',
'line': {'color': '#2ca02c'},
'mo…
# Delete this this when running the notebook yourself
all_portfolio_plots.write_html("./html/all_portfolio_plots.html", default_width=800, default_height=1000)
IFrame(src='./html/all_portfolio_plots.html', width=800, height=1000)
It's also possible to specify custom subplots. The example below would create a cumulative returns and rolling drawdown plot. You can set the settings across all the subplots by using the global template mapping by adding template_context=dict(window=10) for example.
# subplots = [
# ('cumulative_returns', dict(
# title='Cumulative Returns',
# yaxis_kwargs=dict(title='Cumulative returns'),
# plot_func='cumulative_returns.vbt.plot',
# select_col_cumulative_returns=True,
# pass_add_trace_kwargs=True
# )),
# ('rolling_drawdown', dict(
# title='Rolling Drawdown',
# yaxis_kwargs=dict(title='Rolling drawdown'),
# plot_func=[
# 'returns_acc', # returns accessor
# (
# 'rolling_max_drawdown', # function name
# (vbt.Rep('window'),)), # positional arguments
# 'vbt.plot' # plotting function
# ],
# select_col_returns_acc=True,
# pass_add_trace_kwargs=True,
# trace_names=[vbt.Sub('rolling_drawdown(${window})')], # add window to the trace name
# ))
# ]
# portfolio.plot(subplots, template_context=dict(window=10))
Like the returns, drawdowns can also be analyzed extensively.
portfolio.get_drawdowns().stats()
Start 1999-04-07 00:00:00+00:00 End 2022-10-18 00:00:00+00:00 Period 5924 days 00:00:00 Coverage [%] 86.79946 Total Records 332 Total Recovered Drawdowns 331 Total Active Drawdowns 1 Active Drawdown [%] 12.100031 Active Duration 2 days 00:00:00 Active Recovery [%] 0.0 Active Recovery Return [%] 0.0 Active Recovery Duration 0 days 00:00:00 Max Drawdown [%] 73.491664 Avg Drawdown [%] 8.102684 Max Drawdown Duration 875 days 00:00:00 Avg Drawdown Duration 15 days 12:41:19.758308157 Max Recovery Return [%] 294.319213 Avg Recovery Return [%] 14.915101 Max Recovery Duration 249 days 00:00:00 Avg Recovery Duration 7 days 12:06:31.540785498 Avg Recovery Duration Ratio 1.367338 Name: group, dtype: object
drawdown_plot = portfolio.get_drawdowns().plot()
drawdown_plot
FigureWidget({
'data': [{'line': {'color': '#1f77b4'},
'mode': 'lines',
'name'…
# Delete this this when running the notebook yourself
drawdown_plot.write_html("./html/drawdown_plot.html", default_width=800, default_height=500)
IFrame(src='./html/all_portfolio_plots.html', width=800, height=500)
underwater_plot = portfolio.plot_underwater()
underwater_plot
FigureWidget({
'data': [{'fill': 'tozeroy',
'fillcolor': 'rgba(220,57,18,0.3000)',
…
# Delete this this when running the notebook yourself
underwater_plot.write_html("./html/drawdown_plot.html", default_width=800, default_height=500)
IFrame(src='./html/drawdown_plot.html', width=800, height=500)
drawdown_df = portfolio.get_drawdowns().records_readable
drawdown_df
| Drawdown Id | Column | Peak Index | Start Index | Valley Index | End Index | Peak Value | Valley Value | End Value | Status | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | group | 1999-04-08 00:00:00+00:00 | 1999-04-09 00:00:00+00:00 | 1999-04-16 00:00:00+00:00 | 1999-04-19 00:00:00+00:00 | 1.009293e+02 | 8.917106e+01 | 1.017766e+02 | Recovered |
| 1 | 1 | group | 1999-04-20 00:00:00+00:00 | 1999-04-21 00:00:00+00:00 | 1999-10-28 00:00:00+00:00 | 2000-01-06 00:00:00+00:00 | 1.086694e+02 | 4.954582e+01 | 1.268284e+02 | Recovered |
| 2 | 2 | group | 2000-01-06 00:00:00+00:00 | 2000-01-07 00:00:00+00:00 | 2000-04-14 00:00:00+00:00 | 2000-08-02 00:00:00+00:00 | 1.268284e+02 | 4.594489e+01 | 1.304440e+02 | Recovered |
| 3 | 3 | group | 2000-08-02 00:00:00+00:00 | 2000-08-03 00:00:00+00:00 | 2000-08-07 00:00:00+00:00 | 2000-08-31 00:00:00+00:00 | 1.304440e+02 | 1.135298e+02 | 1.365279e+02 | Recovered |
| 4 | 4 | group | 2000-09-01 00:00:00+00:00 | 2000-09-05 00:00:00+00:00 | 2000-09-07 00:00:00+00:00 | 2000-09-26 00:00:00+00:00 | 1.404799e+02 | 1.139203e+02 | 1.433934e+02 | Recovered |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 327 | 327 | group | 2022-09-21 00:00:00+00:00 | 2022-09-22 00:00:00+00:00 | 2022-09-26 00:00:00+00:00 | 2022-09-29 00:00:00+00:00 | 3.661402e+10 | 3.311646e+10 | 3.800744e+10 | Recovered |
| 328 | 328 | group | 2022-09-29 00:00:00+00:00 | 2022-09-30 00:00:00+00:00 | 2022-09-30 00:00:00+00:00 | 2022-10-03 00:00:00+00:00 | 3.800744e+10 | 3.601294e+10 | 3.851073e+10 | Recovered |
| 329 | 329 | group | 2022-10-03 00:00:00+00:00 | 2022-10-04 00:00:00+00:00 | 2022-10-05 00:00:00+00:00 | 2022-10-07 00:00:00+00:00 | 3.851073e+10 | 3.488746e+10 | 3.993029e+10 | Recovered |
| 330 | 330 | group | 2022-10-11 00:00:00+00:00 | 2022-10-12 00:00:00+00:00 | 2022-10-12 00:00:00+00:00 | 2022-10-13 00:00:00+00:00 | 4.272210e+10 | 4.262790e+10 | 4.547761e+10 | Recovered |
| 331 | 331 | group | 2022-10-14 00:00:00+00:00 | 2022-10-17 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 4.953220e+10 | 4.353879e+10 | 4.353879e+10 | Active |
332 rows × 10 columns
As another example of how it's possible to play with the data, let's create a dataframe that sorts the percentage drawdowns from biggest to smallest and shows the drawdown start and recovery date.
drawdown_df["Drawdown PCT"] = ((drawdown_df["Peak Value"]-drawdown_df["Valley Value"])/drawdown_df["Peak Value"])*100
biggest_drawdowns = drawdown_df[["Peak Index", "Valley Index", "Drawdown PCT"]].sort_values(by=["Drawdown PCT"], ascending=False)
biggest_drawdowns["Peak Index"] = biggest_drawdowns["Peak Index"].dt.tz_localize(None)
biggest_drawdowns["Valley Index"] = biggest_drawdowns["Valley Index"].dt.tz_localize(None)
biggest_drawdowns
| Peak Index | Valley Index | Drawdown PCT | |
|---|---|---|---|
| 17 | 2001-04-06 | 2001-09-21 | 73.491664 |
| 42 | 2004-01-16 | 2006-07-14 | 65.079365 |
| 2 | 2000-01-06 | 2000-04-14 | 63.773964 |
| 22 | 2001-12-07 | 2002-06-11 | 58.781410 |
| 1 | 1999-04-20 | 1999-10-28 | 54.406845 |
| ... | ... | ... | ... |
| 165 | 2016-03-14 | 2016-03-15 | 0.063342 |
| 300 | 2021-07-12 | 2021-07-13 | 0.053529 |
| 29 | 2003-07-09 | 2003-07-10 | 0.033248 |
| 207 | 2017-11-09 | 2017-11-10 | 0.029929 |
| 284 | 2020-12-29 | 2020-12-30 | 0.016628 |
332 rows × 3 columns
With group_by=False it's possible analize the contribution to returns, drawdowns and all the other metrics for each single ticker in the portfolio separately from the others, for really in-depth analysis of what's contributing to the performance of the portfolio.
portfolio_not_grouped = vbt.Portfolio.from_orders(
close=close_prices,
#bm_close=put_your_benchmark_close_prices_here,
size=allocations, # Here we pass the allocations we calculated earlier
size_type="targetpercent",
group_by=False,
cash_sharing=True,
call_seq="auto",
freq='D', # needed to calculate annualized returns and some metrics like sharpe, sortino etc in portfolio.stats()
fees=0, # can specify a transaction fee here
)
We can get a dataframe with all the stats for each single asset
single_asset_stats = portfolio_not_grouped.stats(agg_func=None)
single_asset_stats
| Start | End | Period | Start Value | Min Value | Max Value | End Value | Total Return [%] | Benchmark Return [%] | Total Time Exposure [%] | ... | Avg Winning Trade [%] | Avg Losing Trade [%] | Avg Winning Trade Duration | Avg Losing Trade Duration | Profit Factor | Expectancy | Sharpe Ratio | Calmar Ratio | Omega Ratio | Sortino Ratio | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| symbol | ||||||||||||||||||||||
| TQQQ | TQQQ | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 100.0 | 74.270866 | 1.045371e+07 | 9.041877e+06 | 9.041777e+06 | -75.795680 | 73.227549 | ... | 12.175537 | -8.342310 | 12 days 00:54:54.915254237 | 13 days 09:48:10.140845070 | 2.303589 | 23184.044678 | 1.336266 | 0.822345 | 1.281332 | 2.092036 |
| SQQQ | SQQQ | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 100.0 | 35.563083 | 1.683875e+03 | 1.480125e+03 | 1.380125e+03 | -99.999973 | 7.241729 | ... | 10.014137 | -7.289226 | 2 days 08:39:35.257731958 | 2 days 10:04:26.666666666 | 2.682986 | 8.123802 | 0.617504 | 0.176093 | 1.382720 | 0.984780 |
| UVXY | UVXY | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 100.0 | 95.815675 | 1.891803e+04 | 1.891803e+04 | 1.881803e+04 | -100.000000 | 3.190412 | ... | 13.570371 | -4.542983 | 2 days 12:40:45.283018867 | 2 days 10:54:32.727272727 | 62.436518 | 247.605630 | 1.086683 | 0.718410 | 3.314147 | 3.587407 |
| TLT | TLT | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 100.0 | 91.964316 | 1.718486e+02 | 1.427350e+02 | 4.273496e+01 | 194.732172 | 15.749494 | ... | 1.293369 | -1.165640 | 4 days 10:15:29.032258064 | 3 days 12:41:32.307692307 | 1.274152 | 0.183412 | 0.384537 | 0.089135 | 1.146790 | 0.568820 |
4 rows × 29 columns
# Alternative way to grab the stats for a specific asset, in this case SQQQ
portfolio_not_grouped.stats(group_by=False, column=2)
Start 1999-04-07 00:00:00+00:00 End 2022-10-18 00:00:00+00:00 Period 5924 days 00:00:00 Start Value 100.0 Min Value 95.815675 Max Value 18918.027881 End Value 18918.027881 Total Return [%] 18818.027881 Benchmark Return [%] -100.0 Total Time Exposure [%] 3.190412 Max Gross Exposure [%] 100.0 Max Drawdown [%] 34.706887 Max Drawdown Duration 323 days 00:00:00 Total Orders 152 Total Fees Paid 0.0 Total Trades 76 Win Rate [%] 69.736842 Best Trade [%] 65.372054 Worst Trade [%] -20.203265 Avg Winning Trade [%] 13.570371 Avg Losing Trade [%] -4.542983 Avg Winning Trade Duration 2 days 12:40:45.283018867 Avg Losing Trade Duration 2 days 10:54:32.727272727 Profit Factor 62.436518 Expectancy 247.60563 Sharpe Ratio 1.086683 Calmar Ratio 0.71841 Omega Ratio 3.314147 Sortino Ratio 3.587407 Name: 2, dtype: object
We can get even more detailed returns stats for each single asset, and then potentially analyzing or plotting them in many different ways.
# Even more detailed returns stats for each single asset
single_asset_returns = portfolio_not_grouped.returns_stats(agg_func=None)
single_asset_returns
| Start | End | Period | Total Return [%] | Benchmark Return [%] | Annualized Return [%] | Annualized Volatility [%] | Max Drawdown [%] | Max Drawdown Duration | Sharpe Ratio | Calmar Ratio | Omega Ratio | Sortino Ratio | Skew | Kurtosis | Tail Ratio | Common Sense Ratio | Value at Risk | Alpha | Beta | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| symbol | |||||||||||||||||||||
| TQQQ | TQQQ | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 9.041777e+06 | -75.795680 | 62.349412 | 71.354278 | 75.819009 | 875 days | 1.336266 | 0.822345 | 1.281332 | 2.092036 | 1.141272 | 17.109108 | 1.024298 | 1.662942 | -0.054903 | 1.098805 | 0.513265 |
| SQQQ | SQQQ | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 1.380125e+03 | -99.999973 | 12.122497 | 39.486874 | 68.841582 | 3217 days | 0.617504 | 0.176093 | 1.382720 | 0.984780 | 1.195679 | 61.734717 | inf | inf | 0.000000 | 0.363114 | 0.156942 |
| UVXY | UVXY | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 1.881803e+04 | -100.000000 | 24.933761 | 34.343159 | 34.706887 | 323 days | 1.086683 | 0.718410 | 3.314147 | 3.587407 | 16.572080 | 401.969725 | inf | inf | 0.000000 | 0.470850 | 0.013698 |
| TLT | TLT | 1999-04-07 00:00:00+00:00 | 2022-10-18 00:00:00+00:00 | 5924 days | 4.273496e+01 | 194.732172 | 1.522337 | 6.200627 | 17.079081 | 1869 days | 0.384537 | 0.089135 | 1.146790 | 0.568820 | 0.613140 | 27.380370 | 1.359131 | 1.379821 | -0.002743 | 0.012374 | 0.144512 |
single_asset_annual_returns = portfolio_not_grouped.get_annual_returns()
single_asset_annual_returns.plot()
# It's also possible to do a yearly bar chart with this data
<AxesSubplot: xlabel='Date'>
Remember the huge number of plots we generated earlier? There's the ability to do the same thing for each of the assets in the portfolioto plot a single asset of the portfolio with orders, trade pnl and returns. It's possible to specify additional subplots. Let's try see all the possibilities.
# We plot all the available plots for column 3 aka TLT
portfolio_not_grouped_plot = portfolio_not_grouped.plot(column=3, subplots="all") # we're plotting all the possible subplots just for reference
portfolio_not_grouped_plot
FigureWidget({
'data': [{'legendgroup': '0',
'line': {'color': '#1f77b4'},
'mo…
# Delete this this when running the notebook yourself
portfolio_not_grouped_plot.write_html("./html/portfolio_not_grouped.html", default_width=800, default_height=500)
IFrame(src='./html/portfolio_not_grouped.html', width=800, height=500)
There is really so much more that can be done, once you get familiar with how things work. Feel free to contact me on discord ( Raekon#7854 ) if you need any help running this demo