# Cross-sectional strategies (/features/strategies/cross-sectional-strategies)

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.

```python title="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)]  # (1)
>>> momentum = (close.shift(21) / close.shift(252) - 1).loc[dates]  # (2)
>>> top = momentum.rank(axis=1, ascending=False) <= 5
>>> eligible = momentum.notna()  # (3)

>>> def simulate(weights):
...     return vbt.PF.from_orders(
...         close,
...         size=weights.loc["2008":].reindex(close.index),  # (4)
...         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
```

1.  The last trading day of each month.
2.  The return from twelve months ago to one month ago. Skipping the latest month avoids its
    short-term reversals, and every value is known before the rebalancing day.
3.  An ETF becomes eligible once it has a year of history. XLRE launched in 2015 and XLC in 2018, so
    the universe grows from 33 to 37 funds.
4.  Weights apply on the rebalancing days only. Between them, the holdings drift with prices.

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-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](/features/optimization/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](/features/backtesting/event-driven-backtesting/#stateful-trading-rules)
page.

## Factor research \[#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:

```python title="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",  # (1)
...     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
```

1.  Five portfolios of 37 columns each, one per quintile, each with its own cash.

Value of five ETF portfolios formed by monthly 12-1 momentum quintile from 2008 to 2024. [Figure data (JSON)](/assets/figures/features/strategies/momentum-quintiles.fc5d3c32b26b.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:

```python title="Measure the rank information coefficient of momentum"
>>> fwd = close.loc[dates].pct_change().shift(-1)  # (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
```

1.  Each ETF's return over the month after each rebalancing day.

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](/features/indicators/technical-indicators/) page.

## Changing universes \[#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](/features/backtesting/backtest-realism/#point-in-time-universes) 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 \[#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:

```python title="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](/features/optimization/portfolio-optimization/#backtest-your-own-weights)
page.

## Compare rotation rules \[#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](/features/optimization/strategy-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](/features/backtesting/orders-and-execution/) 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.

!!! info "Tutorial"
    The members-only [Portfolio optimization](https://members.vectorbt.pro/tutorials/portfolio-optimization/)
    tutorial allocates across a universe with custom functions and optimizer libraries.


## Related pages

*   [Multi-strategy portfolios](/features/strategies/multi-strategy-portfolios/): Stack strategies as one portfolio, weight them, and compare recurring investing plans
*   [Portfolio optimization](/features/optimization/portfolio-optimization/): Allocate with your own functions or optimizer libraries and backtest rebalancing
*   [Backtesting engine](/features/backtesting/backtesting-engine/): Simulate orders, signals, and callbacks across many assets and parameters at once
*   [Multidimensional research](/features/tooling/multidimensional-research/): Run assets, parameters, and strategies as labeled columns, then index and stack them