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 |