Features
Portfolio optimization
Allocate with your own functions or optimizer libraries and backtest rebalancing
How should you weight assets over time, and what does rebalancing cost? VBT runs portfolio optimization in Python on any schedule: write your own allocation function or call PyPortfolioOpt, Riskfolio-Lib, or Universal Portfolios, then backtest the weights with fees.
symbols = ["SPY", "QQQ", "IWM", "EFA", "EEM", "TLT", "IEF", "GLD", "VNQ", "DBC"]
data = vbt.YFData.pull(symbols, start="2016-01-01", end="2025-01-01")
def inverse_vol(returns):
inv = 1 / returns.std()
return inv / inv.sum()
wrapper = data.symbol_wrapper
returns = vbt.RepEval("data.returns.iloc[index_slice]", context=dict(data=data))
schedule = dict(every="M", lookback_period="365D")
optimizers = {
"Equal weight": vbt.PFO.from_uniform(wrapper, every="M", start="2017"),
"Inverse volatility": vbt.PFO.from_optimize_func(
wrapper, inverse_vol, returns, **schedule
),
"Max Sharpe": vbt.PFO.from_pypfopt(
prices=data.close, target="max_sharpe", **schedule
),
}
pfs = {
name: pfo.simulate(data, init_cash=10_000, fees=0.001).loc["2017":]
for name, pfo in optimizers.items()
}
metrics = ["total_return", "sharpe_ratio", "max_dd", "total_fees_paid"]
stats = {name: pf.stats(metrics) for name, pf in pfs.items()}
pd.DataFrame(stats).astype(float).round(2) Equal weight Inverse volatility Max Sharpe
Total Return [%] 85.49 60.56 58.00
Sharpe Ratio 0.85 0.79 0.59
Max Drawdown [%] 23.17 21.80 28.11
Total Fees Paid 46.63 53.14 479.83The max Sharpe optimizer picks the weights with the best Sharpe ratio on each trailing year, yet it finished last on the months that followed: the lowest Sharpe ratio, the deepest drawdown, and ten times the fees, because its weights jump between a few assets. Equal weight, the simplest allocation, did best. Results like this are why a baseline belongs next to every optimizer.
Backtest your own weights
Already have allocations from a model, a spreadsheet, or another optimizer? Pass them as a pandas DataFrame, with assets in columns and rebalancing dates in the index. VBT turns those targets into a portfolio simulation, so you can inspect the resulting trades, fees, and performance.
This example uses made-up prices and two weight changes supplied in advance. The portfolio starts 60% in A and 40% in B, then switches to 30% and 70% on January 3.
close = pd.DataFrame(
{"A": [100, 120, 110, 121], "B": [100, 100, 110, 110]},
index=pd.date_range("2025-01-01", periods=4),
)
weights = pd.DataFrame(
[[0.6, 0.4], [0.3, 0.7]],
index=close.index[[0, 2]],
columns=close.columns,
)
pfo = vbt.PFO.from_allocations(close.vbt.wrapper, weights)
portfolio = pfo.simulate(close, init_cash=1000, fees=0.001, freq="1D")
pfo.allocations A B
2025-01-01 0.6 0.4
2025-01-03 0.3 0.7portfolio.get_allocations().round(3) A B
2025-01-01 0.600 0.400
2025-01-02 0.642 0.358
2025-01-03 0.300 0.700
2025-01-04 0.321 0.679round(portfolio.orders.fees.sum(), 2)1.66The target weights change only on the two supplied dates. Actual weights drift with prices between
them, and the simulation records 1.66 in fees. For a basket you buy once and then hold, use
from_initial with one set of weights. To keep returning to fixed weights, use from_allocations
with a rebalancing schedule.
Allocation and optimization functions
VBT separates when to rebalance from how to weight:
| Constructor | Weights come from |
|---|---|
vbt.PFO.from_allocate_func | Your function, called at each rebalancing date |
vbt.PFO.from_optimize_func | Your function, called on a window of past data before each date |
vbt.PFO.from_uniform, vbt.PFO.from_random | Equal weights, or seeded random weights |
vbt.PFO.from_initial, vbt.PFO.from_allocations | Fixed weights you supply, applied once or on a schedule |
vbt.PFO.from_filled_allocations | A weight table you computed in advance, one row per bar, rebalancing whenever a row changes |
An optimization function receives whatever arguments you pass, with templates such as index_slice,
index_start, and index_end filled in for the current window. It returns one weight per asset, so
volatility targeting, risk parity, or weights proportional to a forecast are each a few lines of
pandas. lookback_period sets how much history each window holds, separately from the schedule, and
alloc_wait controls how many bars after the window the new weights take effect. Wrap any argument
in vbt.Param to test several values in one call, and use jitted_loop=True with a Numba function
to run thousands of windows quickly.
Your function can also screen the asset universe inside each window, such as excluding assets with too little history or volume before calculating the remaining weights. Return a weight for each asset, including zero for assets you want to leave out.
Each window starts without history, so a rolling indicator computed inside the function loses its
first bars in every window. Compute indicators once on the full history and slice them with
index_slice instead.
Weights are fractions of the portfolio's value and do not have to sum to one. Whatever is not allocated stays in cash, which turns volatility targeting into a few lines:
def vol_target(returns, target=0.08):
vol = returns.mean(axis=1).std() * np.sqrt(252)
weight = min(1, target / vol) / returns.shape[1]
return np.full(returns.shape[1], weight)
pfo = vbt.PFO.from_optimize_func(wrapper, vol_target, returns, **schedule)
pf_target = pfo.simulate(data, init_cash=10_000, fees=0.001).loc["2017":]
compare = {"Equal weight": pfs["Equal weight"], "Vol target 8%": pf_target}
pd.DataFrame({
name: {
"invested": pf.get_allocations().sum(axis=1).mean(),
"volatility": pf.annualized_volatility,
"sharpe": pf.sharpe_ratio,
"max_dd": pf.max_drawdown,
}
for name, pf in compare.items()
}).T.round(2) invested volatility sharpe max_dd
Equal weight 1.00 0.14 0.85 -0.23
Vol target 8% 0.75 0.11 0.57 -0.23The target held about a quarter of the portfolio in cash and lowered volatility, but missed 8% because the trailing year lags the market. It also gave up a third of the Sharpe ratio and none of the drawdown: the COVID crash came after a calm year, when the target was fully invested, and the cash was held through the recovery that followed.
Optimizer libraries
vbt.PFO.from_pypfopt, vbt.PFO.from_riskfolio, and vbt.PFO.from_universal_algo run
PyPortfolioOpt, Riskfolio-Lib, and
Universal Portfolios on the same schedule as your own functions. Each
library's models, constraints, and objectives are passed as keyword arguments, so they can be
parameterized like any other argument. When an optimizer returns no weights for a window, VBT drops
that window with a warning and the portfolio keeps its current holdings until the next date. That
happened once in the max Sharpe run above. An optimizer that raises an error stops the run unless
you pass ignore_errors=True, which drops that window the same way.
The PyPortfolioOpt integration also accepts custom objectives, weight bounds, and sector limits. For example, you can compare a default allocation with one that caps individual assets or sectors, then measure the difference after rebalancing costs. The library chooses weights under those constraints, while VBT evaluates the resulting trades over time.
Universal Portfolios works differently: it runs its algorithm once over the whole price history and
returns weights for every bar, so VBT rebalances whenever those weights change, unless you pass
rebalancing dates through on. The library iterates bar by bar in plain Python, which makes it much
slower than the other two on long histories.
Rebalancing schedules
every takes a pandas frequency such as weekly, monthly, or quarterly. Allocation functions also
accept exact dates through on or index_points, and optimization functions accept exact windows
through index_ranges. Testing several schedules shows what rebalancing costs:
uniform = vbt.PFO.from_uniform(
data.symbol_wrapper, every=vbt.Param(["W", "M", "Q", "Y"]), start="2017"
)
pf = uniform.simulate(data, init_cash=10_000, fees=0.001).loc["2017":]
pd.DataFrame({
"sharpe": pf.sharpe_ratio,
"fees": pf.orders.fees.sum(),
"orders": pf.orders.count(),
}).round(2) sharpe fees orders
every
W 0.85 87.74 4180
M 0.85 46.63 960
Q 0.88 32.06 320
Y 0.86 20.07 80Rebalancing every week instead of every quarter nearly tripled fees without improving the Sharpe
ratio. To rebalance only when weights drift past a threshold, or to compute weights from the current
state of the simulated portfolio, write the rebalancing logic as a simulation callback with
vbt.PF.from_order_func, as the members-only
Dynamic portfolio optimization tutorial
shows.
From allocations to a portfolio
pfo.simulate(data) turns the allocation history into target-percent orders with shared cash, using
vbt.PF.from_orders by default. Pass pf_method="from_signals" to add stop losses and other exits
from the Stop loss and take profit page. Before
simulating, the optimizer itself can be inspected:
pfo.allocationsholds the weights at each rebalancing date, andpfo.alloc_recordslinks each one to the window of data it came from.pfo.mean_allocation,pfo.stats(), andpfo.plot()summarize and chart the weights.rescale_tomaps positive and negative weights into a range, such as -1 to 1 for a long-short book, andpf.get_allocations()shows how the actual weights drift between rebalancing dates.pfo.replace(allocations=...)swaps in edited weights, andvbt.PFO.column_stackcombines optimizers built separately on different columns, such as one per strategy, into one simulation.
Compare and combine allocation methods
Allocation histories are available before simulation, so you can average the weights from two models or apply your own adjustments, then backtest the revised targets. Match the assets and rebalancing dates before combining them. This is useful when you want to compare each model with a blended allocation under the same fees and schedule.
When comparing lookback periods, evaluate the portfolios over a common period after every method has enough history, as the opening example does. Inspect the orders to see how often the targets actually trade, and compare drawdowns and fees alongside returns. The Walk-forward and cross-validation page shows how to repeat selection and evaluation across time.
New weights take effect alloc_wait bars after their window ends, one by default. A window that
ends on the last bar of the data therefore has nowhere to place its weights, and VBT warns about it.
To get the weights for the next trade in live use, pass alloc_wait=0 so they land on the last bar.
Tutorial
The members-only Portfolio optimization tutorial covers allocation and optimization functions, index points and ranges, the library integrations, and dynamic rebalancing.
✅ Riskfolio-Lib is another increasingly popular library for portfolio optimization that has been integrated into VBT. Integration was done by automating typical workflows inside Riskfolio-Lib and putting them into a single function, so many portfolio optimization problems can be expressed using a single set of keyword arguments and easily parameterized.
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_riskfolio(
returns=data.close.vbt.to_returns(),
port_cls="hc",
every="M"
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
✅ Portfolio optimization is the process of creating a portfolio of assets that aims to maximize return and minimize risk. Usually, this process is performed periodically and involves generating new weights to rebalance an existing portfolio. As with most things in VBT, the weight generation step is implemented as a callback by the user, while the optimizer calls that callback periodically. The final result is a collection of returned weight allocations that can be analyzed, visualized, and used in actual simulations 🥧
def regime_change_optimize_func(data):
returns = data.returns
total_return = returns.vbt.returns.total()
weights = data.symbol_wrapper.fill_reduced(0)
pos_mask = total_return > 0
if pos_mask.any():
weights[pos_mask] = total_return[pos_mask] / total_return.abs().sum()
neg_mask = total_return < 0
if neg_mask.any():
weights[neg_mask] = total_return[neg_mask] / total_return.abs().sum()
return -1 * weights
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_optimize_func(
data.symbol_wrapper,
regime_change_optimize_func,
vbt.RepEval("data[index_slice]", context=dict(data=data)),
every="M"
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
✅ PyPortfolioOpt is a popular financial portfolio optimization package that includes both classical methods (Markowitz 1952 and Black-Litterman), suggested best practices (such as covariance shrinkage), and many recent developments and novel features, like L2 regularization, shrunk covariance, and hierarchical risk parity.
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_pypfopt(
returns=data.returns,
optimizer="hrp",
target="optimize",
every="M"
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
✅ Universal Portfolios is a package that brings together various Online Portfolio Selection (OLPS) algorithms.
data = vbt.YFData.pull(
["SPY", "TLT", "XLF", "XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV"],
start="2020",
end="2023",
missing_index="drop"
)
pfo = vbt.PFO.from_universal_algo(
"MPT",
data.resample("W").close,
window=52,
min_history=4,
mu_estimator='historical',
cov_estimator='empirical',
method='mpt',
q=0
)
pfo.plot().show()Tutorial
Learn more in the Portfolio optimization tutorial.
Related pages
- Strategies › Multi-strategy portfoliosStack strategies as one portfolio, weight them, and compare recurring investing plans
- Backtesting › Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Strategies › Cross-sectional strategiesRank a universe each period, rotate into the leaders, and test factors by quintile
- Backtesting › Portfolio accountingTrack shared cash, deposits, dividends, positions, records, and exposure per bar
Copyright © 2021–2026 Oleg Polakow. All rights reserved.
Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.