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.

Backtest monthly max Sharpe, inverse volatility, and equal weight on 10 ETFs
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.83
Portfolio value of equal weight, inverse volatility, and max Sharpe allocations on 10 ETFs from 2017 to 2024 Figure data (JSON)

The 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.

Backtest a supplied weight table
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.7
portfolio.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.679
round(portfolio.orders.fees.sum(), 2)
1.66

The 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:

ConstructorWeights come from
vbt.PFO.from_allocate_funcYour function, called at each rebalancing date
vbt.PFO.from_optimize_funcYour function, called on a window of past data before each date
vbt.PFO.from_uniform, vbt.PFO.from_randomEqual weights, or seeded random weights
vbt.PFO.from_initial, vbt.PFO.from_allocationsFixed weights you supply, applied once or on a schedule
vbt.PFO.from_filled_allocationsA 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:

Scale equal weights down to target 8% annual volatility
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.23

The 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:

Rebalance equal weights weekly, monthly, quarterly, and yearly
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      80

Rebalancing 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.allocations holds the weights at each rebalancing date, and pfo.alloc_records links each one to the window of data it came from.
  • pfo.mean_allocation, pfo.stats(), and pfo.plot() summarize and chart the weights.
  • rescale_to maps positive and negative weights into a range, such as -1 to 1 for a long-short book, and pf.get_allocations() shows how the actual weights drift between rebalancing dates.
  • pfo.replace(allocations=...) swaps in edited weights, and vbt.PFO.column_stack combines 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

✅ 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.

Run hierarchical risk parity (HRP) with Riskfolio-Lib on a monthly basis
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()
Monthly asset weights from Riskfolio-Lib hierarchical risk parity from 2020 through 2022 Figure data (JSON)

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 🥧

Allocate assets inversely to their total return in the last month
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()
Monthly regime-change portfolio allocations across eleven assets from 2020 through 2022 Figure data (JSON)

Tutorial

Learn more in the Portfolio optimization tutorial.

PyPortfolioOpt

✅ 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.

Run hierarchical risk parity (HRP) with PyPortfolioOpt on a monthly basis
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()
Monthly asset weights from PyPortfolioOpt hierarchical risk parity from 2020 through 2022 Figure data (JSON)

Tutorial

Learn more in the Portfolio optimization tutorial.

✅ Universal Portfolios is a package that brings together various Online Portfolio Selection (OLPS) algorithms.

Simulate an online minimum-variance portfolio on a weekly timeframe
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()
Weekly online minimum-variance portfolio weights across eleven assets from 2020 through 2022 Figure data (JSON)

Tutorial

Learn more in the Portfolio optimization tutorial.

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.