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.

Rotate monthly into the five ETFs with the strongest 12-1 momentum
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.90

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

Backtest the five momentum quintiles in one simulation
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.87
Value of five ETF portfolios formed by monthly 12-1 momentum quintile from 2008 to 2024 Figure data (JSON)

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

Measure the rank information coefficient of momentum
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.52

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

Select up to two eligible assets and turn the rankings into allocations
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.0

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

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.