Dynamic Symbol Selection

Every strategy we’ve seen has traded a fixed list of ticker symbols that were chosen beforehand. Alternatively, we may want a strategy to target whichever symbols look best at any given time. These could be the most liquid names, or the ones with the highest momentum or value.

PyBroker v2 now enables dynamic symbol selection with SymbolSelector.

Loading the Candidate Universe

Below, data is downloaded for twenty liquid large-caps from YFinance:

[1]:
import pandas as pd
import numpy as np
import pybroker
from pybroker import Strategy, YFinance, highv, lowv

pybroker.enable_data_source_cache("dynamic_symbol_selection")

UNIVERSE = [
    "AAPL",
    "AMZN",
    "AVGO",
    "COST",
    "CRM",
    "GOOG",
    "JNJ",
    "JPM",
    "KO",
    "LLY",
    "META",
    "MSFT",
    "NFLX",
    "NVDA",
    "PG",
    "PLTR",
    "QCOM",
    "TSLA",
    "WMT",
    "XOM",
]
start_date = "1/1/2021"
end_date = "1/1/2026"
yfinance = YFinance()
df = yfinance.query(UNIVERSE, start_date=start_date, end_date=end_date)
df.head()
Loading bar data...
[*********************100%***********************]  20 of 20 completed
Loaded bar data: 0:00:01

[1]:
date symbol open high low close volume adj_close
0 2021-01-04 AAPL 133.520004 133.610001 126.760002 129.410004 143301900 125.632523
1 2021-01-04 AMZN 163.500000 163.600006 157.201004 159.331497 88228000 159.331497
2 2021-01-04 AVGO 43.932999 44.223999 42.124001 42.521999 24171000 38.112709
3 2021-01-04 COST 377.429993 381.549988 374.809998 380.149994 3322200 358.108002
4 2021-01-04 CRM 222.639999 223.750000 215.720001 220.309998 10319900 216.563354

Selecting Symbols by Liquidity

Dynamic symbol selection is handled using a SymbolSelector, which can be any callable that takes a Pandas DataFrame and returns a sequence of symbols. It is passed to Strategy.add_execution instead of a fixed list of symbols.

The example below ranks the universe by average dollar volume and keeps the top three symbols:

[2]:
TOP_N = 3


def top_dollar_volume(df: pd.DataFrame):
    dollar_volume = (df["close"] * df["volume"]).groupby(df["symbol"]).mean()
    selected = dollar_volume.nlargest(TOP_N).index
    return selected

Running the Strategy

The example below implements a simple breakout strategy. It buys when a symbol closes above its previous 20-day high, and then sells when the it closes below its previous 20-day low. The strategy splits capital equally across the top three selected stocks:

[3]:
from pybroker import highest, lowest


high_20 = highest("high_20", "high", 20)
low_20 = lowest("low_20", "low", 20)

POS_SIZE = 1.0 / TOP_N


def breakout(ctx):
    highs = ctx.indicator("high_20")
    lows = ctx.indicator("low_20")
    if len(highs) < 2 or np.isnan(highs[-2]):
        return
    if not ctx.long_pos():
        if ctx.close[-1] > highs[-2]:
            ctx.buy_shares = ctx.calc_target_shares(POS_SIZE)
    elif ctx.close[-1] < lows[-2]:
        ctx.sell_all_shares()


strategy = Strategy(df, start_date=start_date, end_date=end_date)
strategy.add_execution(
    breakout, top_dollar_volume, indicators=[high_20, low_20]
)

For each walkforward window, the top_dollar_volume selector runs on the train split and selects the top three stocks to trade during the subsequent test split:

[4]:
result = strategy.walkforward(windows=4, train_size=0.5)
result.metrics_df.head(10)
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00

Computing indicators...
100% (6 of 6) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2022-01-06 00:00:00 to 2023-01-04 00:00:00
100% (250 of 250) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Computing indicators...
100% (6 of 6) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2023-01-05 00:00:00 to 2024-01-03 00:00:00
100% (250 of 250) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Computing indicators...
100% (6 of 6) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2024-01-04 00:00:00 to 2024-12-31 00:00:00
100% (250 of 250) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Computing indicators...
100% (6 of 6) |##########################| Elapsed Time: 0:00:00 Time:  0:00:00

Test split: 2025-01-02 00:00:00 to 2025-12-31 00:00:00
100% (250 of 250) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Finished backtest: 0:00:00
[4]:
name value
0 trade_count 31
1 initial_market_value 100000.0
2 end_market_value 243986.97
3 total_pnl 136797.66
4 unrealized_pnl 7189.31
5 total_return_pct 136.79766
6 total_profit 201003.09
7 total_loss -64205.43
8 total_fees 0.0
9 max_drawdown -49493.9