动态品种选择
到目前为止,每个策略交易的都是在回测开始前就已选定的固定股票代码列表。而我们有时希望策略能够瞄准当下表现最好的品种,例如流动性最高的品种,或者动量或价值最高的品种。
PyBroker v2 现已通过 SymbolSelector 支持动态品种选择。
加载候选品种池
下面将从 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 |
按流动性选择品种
动态品种选择通过 SymbolSelector 实现,它可以是任意接受 Pandas DataFrame 并返回品种序列的可调用对象。它会被传递给 Strategy.add_execution,以代替固定的品种列表。
下面的示例按平均成交额对品种池进行排名,并保留排名前三的品种:
[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
运行策略
下面的示例实现了一个简单的突破策略:当某个品种收盘价高于其前 20 日高点时买入,收盘价低于其前 20 日低点时卖出。该策略会将资金平均分配到排名前三的已选股票上:
[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]
)
对于每个 walkforward 窗口,top_dollar_volume 选择器会在训练集划分上运行,并选出排名前三的股票,用于在后续的测试集划分中进行交易:
[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 |