Features
Cross-sectional strategies
Rank a universe each period, rotate into the leaders, and test factors by quintile
How do you rank a universe and trade the best and worst names? This page backtests cross-sectional momentum rotation in Python: rank 37 ETFs every month by their past year's return, hold the leaders, and check the factor across quintiles on a universe whose members change over time.
symbols = [
"XLB", "XLE", "XLF", "XLI", "XLK", "XLP", "XLU", "XLV", "XLY", "XLRE", "XLC",
"EWA", "EWC", "EWG", "EWH", "EWJ", "EWU", "EWZ", "EWW", "EWY", "EWT", "EWS",
"EWL", "EWQ", "EWP", "EWI", "EWD", "EWN", "FXI", "INDA", "EIDO",
"TLT", "IEF", "GLD", "SLV", "DBC", "VNQ",
]
close = vbt.YFData.pull(symbols, start="2007-01-01", end="2025-01-01").close
month = pd.Series(close.index.month, index=close.index)
dates = close.index[month != month.shift(-1)]
momentum = (close.shift(21) / close.shift(252) - 1).loc[dates]
top = momentum.rank(axis=1, ascending=False) <= 5
eligible = momentum.notna()
def simulate(weights):
return vbt.PF.from_orders(
close,
size=weights.loc["2008":].reindex(close.index),
size_type="targetpercent",
group_by=True,
cash_sharing=True,
call_seq="auto",
fees=0.001,
)
pfs = {
"Top 5 momentum": simulate(top.div(top.sum(axis=1), axis=0)),
"Equal weight": simulate(eligible.div(eligible.sum(axis=1), axis=0)),
}
metrics = ["total_return", "sharpe_ratio", "max_dd"]
pd.DataFrame({k: pf.stats(metrics) for k, pf in pfs.items()}).astype(float).round(2) Top 5 momentum Equal weight
Total Return [%] 84.61 152.43
Sharpe Ratio 0.35 0.45
Max Drawdown [%] 37.41 51.90Over 2008 to 2024, the five strongest ETFs each month returned 85% against 152% for holding every eligible ETF equally, with a smaller drawdown. Momentum lowered risk here but did not add return.
The same workflow accepts your own scores: technical indicators, fundamental factors, or model predictions. Supply a table of scores by date and symbol, choose how many names to hold, and compare the resulting portfolios. Momentum is one example.
Ranking and selection
Ranking is a pandas operation on a table with one column per symbol, so rank(axis=1) ranks every
date at once and rank <= 5 selects the leaders. Symbols without data on a date get NaN and drop
out of the ranking. The selection becomes weights, which vbt.PF.from_orders trades as target
percentages with shared cash, as above, or signals for vbt.PF.from_signals. The
Portfolio optimization page computes the same
weights with vbt.PFO.from_allocate_func on any schedule. Negative weights short a name, so a
long-short book is the leaders' weights minus the laggards'. For thousands of symbols, split the
score table by time, rank each period, and join the results with split_apply. Each period keeps
the full universe, so a stock is still ranked against all its peers while the intermediate ranking
tables use less memory.
A ranking table works when the whole selection is known before the bar. When entries come from each symbol's own signals and the limit depends on what is already held, such as at most five open positions, the decision needs the portfolio's state, as in the Event-driven backtesting page.
Factor research
A factor works if its ranks sort future returns, so split the universe into quintiles by rank and compare them. Each quintile is a group of columns in one portfolio:
quintile = np.ceil(momentum.rank(axis=1, pct=True) * 5)
members = {q: quintile == q for q in range(1, 6)}
q_weights = pd.concat(
{q: m.div(m.sum(axis=1), axis=0) for q, m in members.items()},
axis=1,
names=["quintile"],
)
q_close = pd.concat({q: close for q in range(1, 6)}, axis=1, names=["quintile"])
q_pf = vbt.PF.from_orders(
q_close,
size=q_weights.loc["2008":].reindex(close.index),
size_type="targetpercent",
group_by="quintile",
cash_sharing=True,
call_seq="auto",
fees=0.001,
)
q_pf.stats(metrics, agg_func=None).astype(float).round(2) Total Return [%] Sharpe Ratio Max Drawdown [%]
quintile
1 132.68 0.38 57.79
2 67.94 0.29 55.03
3 100.37 0.36 58.13
4 136.40 0.42 51.06
5 118.17 0.42 40.87A working factor would show returns rising steadily from quintile 1, the weakest momentum, to quintile 5, the strongest. Here the returns did not line up, and only the drawdown fell with higher momentum. The rank information coefficient, the correlation between each month's ranks and the next month's returns, puts a number on it:
fwd = close.loc[dates].pct_change().shift(-1)
ic = momentum.corrwith(fwd, axis=1, method="spearman").loc["2008":].dropna()
t_stat = ic.mean() / ic.std() * np.sqrt(ic.count())
print(round(ic.mean(), 3), round(t_stat, 2), round((ic > 0).mean(), 2))0.006 0.29 0.52An average IC of 0.006, positive in 52% of months, is indistinguishable from zero. A long-short book that buys quintile 5 and shorts quintile 1, half the capital on each side, lost about a quarter of its value over the same period. VBT has no built-in factor tear sheet like Alphalens, but the ranks, forward returns, and quintile portfolios are ordinary tables, so each statistic is a line of pandas.
To test a factor on its own, remove its exposure to other characteristics first: regress it on them
within each date and keep the residual, or subtract the average within each group, such as a sector.
Indicator expressions provide cs_rank, cs_demean, and cs_rescale for ranking, group demeaning,
and rescaling. cs_rank produces percentile ranks, which are useful when the number of eligible
symbols changes. These functions also power the WorldQuant 101 Alphas on the
Technical indicators page.
Changing universes
Symbols that join or leave a universe stay in the table for the whole period, with NaN before they join and after they leave, so the asset axis never changes and every simulation stays vectorized. Momentum here also requires a year of history, which excludes new ETFs until they qualify. For stock indexes, a point-in-time membership table masks each stock outside its membership dates, which avoids testing only on today's survivors, as the Realistic backtests page explains. A strategy that holds one symbol at a time can also stitch the chosen symbols' prices into a single series, closing the position at each switch.
Rank only eligible assets
Eligibility can combine index membership, enough price history, and your own liquidity filters. Apply it before ranking so an unavailable asset cannot take a place in the selection. Here are made-up scores for three assets on four rebalancing dates:
dates = pd.to_datetime(["2025-01-31", "2025-02-28", "2025-03-31", "2025-04-30"])
scores = pd.DataFrame(
{"A": [8, 9, 7, 8], "B": [6, 5, 8, 9], "C": [4, 10, 9, 7]},
index=dates,
)
eligible = pd.DataFrame(
{"A": [True, False, False, False], "B": [True, True, False, False], "C": [True, True, True, False]},
index=dates,
)
ranks = scores.where(eligible).rank(axis=1, ascending=False, method="first")
selected = ranks <= 2
weights = selected.div(selected.sum(axis=1).replace(0, np.nan), axis=0).fillna(0.0)
pfo = vbt.PFO.from_allocations(scores.vbt.wrapper, weights)
pfo.allocations A B C
2025-01-31 0.5 0.5 0.0
2025-02-28 0.0 0.5 0.5
2025-03-31 0.0 0.0 1.0
2025-04-30 0.0 0.0 0.0A drops out in February even though its score beats B's. In March, C is the only eligible asset and
gets the full target allocation. April has no eligible assets, so the target is all cash. Ties go to
the earlier column with method="first". The allocation table can then be simulated against prices
with fees and slippage, as shown on the
Portfolio optimization
page.
Compare rotation rules
Does the result depend on holding five names, using a one-year lookback, or rebalancing monthly? Wrap the score, selection, and simulation steps in a function and use Parameter optimization to compare those choices. Keep an equal-weight portfolio alongside the ranked portfolios, as in the opening example, to see what the selection adds over holding the universe.
Take a factor from rank IC to a traded portfolio. Compare performance after costs, then inspect its orders to see how often positions change and how much the rotation costs. The quintile portfolios help separate a factor that sorts returns across the universe from one whose result depends on a few top names.
Tutorial
The members-only Portfolio optimization tutorial allocates across a universe with custom functions and optimizer libraries.
Related pages
- Multi-strategy portfoliosStack strategies as one portfolio, weight them, and compare recurring investing plans
- Optimization and validation › Portfolio optimizationAllocate with your own functions or optimizer libraries and backtest rebalancing
- Backtesting › Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Research toolkit › Multidimensional researchRun assets, parameters, and strategies as labeled columns, then index and stack them
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.