@腼腆的虫虫 这是合理的:
第一次/第二次回测虽然“统计区间”都覆盖了 2026-03-15 ~ 2026-04-25,但这份策略的ETF筛选不是只看当日数据的纯函数,它依赖一堆在策略运行过程中会累积/切换的全局状态;回测起点不同,状态就不同,所以同一时段的入选ETF也会变。
1) 最关键原因:正常期/震荡期滤波器状态会因为起点不同而不同
策略在 initialize() 里把滤波器状态初始化,然后只初始化一次:
def init_range_bound_status(context):
...
if should_enter_range_bound:
g.current_filter = '震荡期'
...
else:
g.current_filter = '正常期'
...
并且在日常打分时,是否“通过动态滤波”取决于 g.current_filter:
# 根据当前模式选择使用的滤波器
if g.current_filter == '正常期':
passed_filter = passed_laplace
else:
passed_filter = passed_gaussian
g.current_filter 又会在每天 13:10 通过“进入/退出震荡期”的条件继续演化(依赖 g.last_switch_date / g.range_bound_start_date / g.stable_days 等历史累计状态),所以你从 1/1 跑到 4/25 时,到 3/15 这一天的状态往往和你直接从 3/15 开始跑时不一致;状态一不一致,就会导致 passed_filter、最终候选池与排序发生变化。
你可以直接对照两次回测日志里这两行:【首次运行】初始化震荡期状态... 和之后每天的 【当前滤波器】 是否一致:
log.info(f"【当前滤波器】{'拉普拉斯(正常期)' if g.current_filter == '正常期' else '高斯(震荡期)'}")
2) 次要原因:滚动窗口的“可用历史数据/成交量有效性”也会因起点不同而导致ETF被跳过
在 get_final_ranked_etfs() 里,它会先拉历史并做 raw_volumes > 0 的有效过滤,之后要求长度够才计算:
valid_mask = (~np.isnan(raw_volumes)) & (raw_volumes > 0)
...
hist_closes = hist_closes[-lookback:]
hist_volumes = hist_volumes[-lookback:]
if len(hist_closes) < max(g.lookback_days, g.short_momentum_lookback):
continue
第二次回测如果从 3/15 才开始,早期日期的可用历史(尤其是部分ETF因为停牌/成交量为0导致被 valid_mask 过滤后)可能不足,就会出现“某些ETF在某天根本没进入 all_metrics”,从而最终入选集合不同。这个影响通常在回测热身窗口内更明显,但也可能持续到 4/25(取决于哪些ETF在有效成交量上缺不缺数据)。
2026-04-29