动态品种选择

到目前为止,每个策略交易的都是在回测开始前就已选定的固定股票代码列表。而我们有时希望策略能够瞄准当下表现最好的品种,例如流动性最高的品种,或者动量或价值最高的品种。

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