In [1]:
from vectorbtpro import *

from datetime import datetime
from dateutil.relativedelta import relativedelta

from pypfopt.objective_functions import transaction_cost
from pypfopt.expected_returns import mean_historical_return
from pypfopt.risk_models import CovarianceShrinkage
from pypfopt.efficient_frontier import EfficientFrontier
In [2]:
vbt.settings.set_theme("dark")

Portfolio Optimization¶

No description has been provided for this image
Acknowledgements:

We will be using vector-bt, a (semi-)professional vectorized backtesting library written in Python, written by Oleg Polakow.

Note: For detailed documentation see documentation

Financial markets are highly nondeterministic. We want to find the portfolio that offers us high returns at low risk under the following market assumptions.

  1. Rational decision-makers: Investors want to maximise return while reducing the risks associated with their investment
  2. No arbitrage : cannot make a costless, riskless profit
  3. Risky securities : $\{S_1,\ldots,S_n\}:\, n\geq 2$ be risky securities whose future returns are uncertain. There is no risk-free asset $S_0$ in our portfolio
  4. Equilibrium : supply equals demand for securities
  5. Liquidity : any # of units of a security can be bought and sold quickly
  6. Access to information : rapid availability of accurate information
  7. Price is efficient : Price of security adjusts immediately to new information, and current price reflects past information and expected further behaviour
  8. No transaction costs and taxes : transaction costs are assumed to be negligible compared to value of trades (we may relax this assumption and assume cost $K$ per transaction) and are ignored. No taxes (capital-gains etc.) on transactions.

We want to select a portfolio $\mathbf{x}=(x_1,\ldots,x_n)\in\mathbb{R}^n$ where $x_i$ is the amount (fraction of budget) invested in security $S^i$. We work in discrete-time, so we only consider now ($t=0$) and a moment in time in the future ($t=1$)

The return of asset $i$ is a random variable $r_i:\Omega \rightarrow [0,\infty)$ where $\Omega$ is the sample space (set of scenarios for the future). If $S_t^i:\Omega\rightarrow\mathbb{R}_+$ is the random variable denoting the future value of asset $i$ at time $t$. Then under scenario $\omega\in\Omega$, we have that $r_i(\omega)=S^i_1(\omega)/S_0^i$. So for instance, a $5\%$ return on asset $i$ corresponds to $r_i=1.05$ and $-2%$ return corresponds to $r_i=0.98$

$\newcommand{\x}{\mathbf{x}}$ Let $r = (r_1,\ldots, r_n)$ denote the random vector of returns with mean $\mu=\mathbf{E}[r]\in\mathbb{R}^n$ and covariance matrix $\Sigma\in\mathbb{R}^{n\times n}$. Let the set of admissible portfolios be denoted by $X=\{\x\in\mathbb{R}^n:\sum_{i=0}^n x_i=1,\, \x\geq 0\}\subset\mathbb{R}^n$. In other words, we do not allow short-selling (for now), and must always be fully-allocated.

Then the return of portfolio $\mathcal{R}:X\rightarrow \mathbb{R}$ is given by $\mathcal{R}(\x)=r^T\x \implies \mathbf{E}[\mathcal{R}] = \mu^T\x$.

The risk of portfolio $\x\in X$ is denoted $\text{Risk}(\x)\equiv\text{Risk}(\mathcal{R}(\x))$ where $\text{Risk}:X\rightarrow\mathbb{R}$ is the risk measure.

Data Fetching¶

In [3]:
END_DATE = datetime.today().strftime("%Y-%m-%d")
START_DATE = (datetime.today() - relativedelta(years=3)).strftime("%Y-%m-%d")
In [4]:
data = vbt.YFData.pull(
    ["BTC-USD", "ETH-USD", "XMR-USD", "LINK-USD"], 
    start=f"{START_DATE} UTC", 
    end=f"{END_DATE} UTC",
    timeframe="1d"
)

#data.to_hdf("data/BinanceData.h5") # save data to disk
#data = vbt.HDFData.pull("data/BinanceData.h5") # retrieve data from disk

The object returned inherits from the vectorbtpro.data base class, which is very extensible, but for modeling purposes we only really care about OHLC data.

In [5]:
df = data.get("close")
df.tail()
Out[5]:
symbol BTC-USD ETH-USD XMR-USD LINK-USD
Date
2026-02-21 00:00:00+00:00 68003.765625 1973.737183 326.570953 8.876016
2026-02-22 00:00:00+00:00 67659.390625 1957.805908 327.830200 8.672669
2026-02-23 00:00:00+00:00 64616.738281 1855.502808 307.265533 8.265229
2026-02-24 00:00:00+00:00 64080.042969 1852.969482 321.333679 8.203102
2026-02-25 00:00:00+00:00 67960.125000 2054.627930 344.504822 9.256296

Optimization¶

$\newcommand{\x}{\mathbf{x}}$ $\newcommand{\R}{\mathbb{R}}$

We want to maximize expected return while minimizing risk

optimizing risk-return tradeoff

Here we consider the risk-return trade-off:$\quad \max\; \{\mu^T\x -\delta\text{Risk}(x)\, |\, \x\in X \}\quad \text{where}\quad X=\{\x\in(-1,1)^n\, |\, \x^T\mathbf{1}=1 \}$

In [6]:
def mean_var(x, mu, cov_matrix, delta):
    var = x.T @ (cov_matrix * np.eye(cov_matrix.shape[0])) @ x
    return -((mu.T @ x) - delta * var)

def optimize_func(price, index_slice):
    price_filt = price.iloc[index_slice]
    mu = mean_historical_return(price_filt)
    S = CovarianceShrinkage(price_filt).ledoit_wolf()
    ef = EfficientFrontier(mu, S, weight_bounds=(0.01, 0.3))
    ef.convex_objective(mean_var, mu=mu.values, cov_matrix=S.values, delta=0.1)
    weights = ef.clean_weights()
    return weights
In [7]:
pfo = vbt.PortfolioOptimizer.from_optimize_func(
    data.symbol_wrapper,
    optimize_func,
    data.get("Close"),
    vbt.Rep("index_slice"),
    every="M"
)
pf = pfo.simulate(data, freq="1d")
In [8]:
pf.stats()
Out[8]:
Start Index                     2023-02-26 00:00:00+00:00
End Index                       2026-02-25 00:00:00+00:00
Total Duration                         1096 days 00:00:00
Start Value                                         100.0
Min Value                                       85.068166
Max Value                                      376.818576
End Value                                      222.172847
Total Return [%]                               122.172847
Benchmark Return [%]                            91.198686
Position Coverage [%]                            96.89781
Max Gross Exposure [%]                              100.0
Max Drawdown [%]                                47.942168
Max Drawdown Duration                   256 days 00:00:00
Total Orders                                          140
Total Fees Paid                                       0.0
Total Trades                                           71
Win Rate [%]                                     83.58209
Best Trade [%]                                 109.456133
Worst Trade [%]                                -23.995042
Avg Winning Trade [%]                           39.721564
Avg Losing Trade [%]                            -9.148827
Avg Winning Trade Duration    556 days 20:34:17.142857144
Avg Losing Trade Duration     418 days 10:54:32.727272728
Profit Factor                                    8.428469
Expectancy                                       2.221654
Sharpe Ratio                                      0.76592
Calmar Ratio                                     0.635229
Omega Ratio                                      1.120244
Sortino Ratio                                    1.087971
Name: group, dtype: object

$\newcommand{\x}{\mathbf{x}}$ We choose to maximize the sharpe ratio which $S_a=\mathbf{E}[R^i-R^0]/\sqrt{\mathbf{V}[R^i-R^0]}$ where $R^0$ is the risk-free asset return (like the U.S treasury bond - it has essentially no risk - backed by US military / country's stability, this could also be the interest gained by making a bank deposit) and $R^i$ is asset $i$'s return. The portfolio with maximum sharpe ratio is the tangency portfolio.

No description has been provided for this image

A portfolio is efficient if it has the maximum expected return among all admissible portfolios of the same (or smaller) risk: $\max \{\mu^T\x : \text{Risk}(\x)\leq \sigma^2,\, \x\in X\}$ or if it has the minimum variance among all admissible portfolios of the same (or greater) expected return: $\min\{ \text{Risk}(\x) : \mu^T\x\geq R,\, x\in X\}$

In [17]:
pfo1 = vbt.PortfolioOptimizer.from_pypfopt(
    prices=data.get("Close"),
    every="1M",
    weight_bounds=(0, 0.8),
    target=vbt.Param([
        "max_sharpe", 
        "min_volatility", 
        "max_quadratic_utility"
        # optimize for a set of targets separately 
        # for more target parameters see: https://vectorbt.pro/pvt_74d2a4ff/tutorials/portfolio-optimization/integrations/#periodically
    ])
)
In [18]:
# get portfolio performance for each target
pf1 = pfo1.simulate(data, freq="1d")
print(f"{pf1.sharpe_ratio}\n\n{pf1.max_drawdown}\n\n{pf1.annualized_return}")
target
max_sharpe               1.241508
min_volatility           0.832098
max_quadratic_utility    1.001517
Name: sharpe_ratio, dtype: float64

target
max_sharpe              -0.549641
min_volatility          -0.426625
max_quadratic_utility   -0.566519
Name: max_drawdown, dtype: float64

target
max_sharpe               0.793812
min_volatility           0.321258
max_quadratic_utility    0.551526
Name: annualized_return, dtype: float64
In [19]:
fig = vbt.make_subplots(rows=1, cols=2)
fig.update_layout(width=1000,height=400)
pf1.plot_cumulative_returns(column=1, add_trace_kwargs=dict(row=1, col=1), fig=fig)
pfo1.plot(column=1, add_trace_kwargs=dict(row=1, col=2), fig=fig)
No description has been provided for this image

So the Sharpe ratio $S_a=1.24$ is not ideal if we're chasing alpha (i.e. profitable strategy whether the market is bearish/bullish), but if we're just looking to increase our beta (increasing exposure to the market swings to capture the most price action) then this is starting to look better.

In [20]:
initial_weights = np.array([1 / len(data.symbols)] * len(data.symbols))
pfo2 = vbt.PortfolioOptimizer.from_pypfopt(
    prices=data.get("Close"),
    every="1M",
    weight_bounds=(-1, 1),
    objectives=["transaction_cost"],
    w_prev=initial_weights, 
    k=0.001,
    target=vbt.Param([
        "max_sharpe", 
        "min_volatility", 
        "max_quadratic_utility"
    ])
)
In [22]:
pf2 = pfo2.simulate(data, freq="1d")
print(f"{pf2.sharpe_ratio}\n\n{pf2.max_drawdown}\n\n{pf2.annualized_return}")
target
max_sharpe               0.577411
min_volatility           0.716948
max_quadratic_utility    1.337973
Name: sharpe_ratio, dtype: float64

target
max_sharpe              -0.409129
min_volatility          -0.342790
max_quadratic_utility   -0.407796
Name: max_drawdown, dtype: float64

target
max_sharpe               0.176048
min_volatility           0.235928
max_quadratic_utility    0.719952
Name: annualized_return, dtype: float64

If we consider a per-trade transction cost of $0.1\%$ of the amount traded, the story changes; we now are actually less profitable with an atrocious decrease of $> 60\%$ in annualized returns (for a max_sharpe optimization objective).

However, in optimizing for minimum volatility, including transaction fees only shifts the annualized returns down by about $10\%$.

Conclusion¶

Optimizing portfolios for certain target metrics periodically and rebalancing can lead to promising results for beta-seeking strategies. However this all depends on the optimization constraints, the target parameter, and of course the assets considered.

For those of you interested, there is web-app service called Portfolio Visualizer where you can optimize portfolios subject to different constraints.

No description has been provided for this image
Disclaimer:

Please do not distribute this notebook.

The analysis in this material is provided for information only and should not be construed as advice to buy any cryptocurrency

In [ ]: