Stateful Step 示例详解
本文档详细说明仓库内这个示例是如何运作的:
examples/run_stateful_step_limit_up_then_limit_up.py
它演示的是一种最小但完整的 stateful step 策略模式:
- 第一次涨停时,不立刻买入
- 先把股票放进待入池列表
- 下一交易日只做待确认,收盘后才正式进入观察池
- 后续某天再次涨停时,才进入当天买入池
- 买入和卖出仍由统一回测执行内核负责
这个示例的重点不是策略收益,而是帮助理解:
run_stateful_step_backtest(...)到底做了什么- 策略对象应该负责什么
- 回测引擎应该负责什么
- 为什么这种模式比“每个策略都自己写逐日主循环”更适合复用
1. 这个模式解决什么问题
对于简单 selector,策略只需要回答:
- 今天选谁
但对于复杂一点的日频策略,往往还需要记住前几天的状态,例如:
- 某只股票之前已经触发过一次信号
- 某只股票已经在观察池里
- 某只股票在观察池里待太久了,需要移除
- 某只股票已经买过,不能重复买
这类策略的关键不只是“今天选谁”,而是:
- 今天状态怎么推进
- 今天基于这个状态应该买谁
这就是 stateful step 的适用场景。
2. 示例文件里的几个角色
这个示例文件里主要有四类对象。
2.1 参数对象
StrategyParams
职责:
- 收拢示例策略用到的参数
- 让策略对象初始化时更清晰
当前参数包括:
watch_windowclose_rate_thresholdmax_candidatesmax_positions
2.2 当日候选筛选函数
select_limit_up_hits_from_market_frame(...)
职责:
- 从某个交易日的全市场日线里找出“收盘涨停”的股票
- 返回结构化候选列表
这里它只做“当天筛选”,不维护任何跨日状态。
也就是说:
- 这是策略里的纯函数部分
- 它负责把市场数据变成当天候选
2.3 状态策略对象
LimitUpThenLimitUpStrategy
这是整个示例的核心。
它自己维护:
watchlistwatchlist_added_indexpending_watchlistpending_confirmation_watchlistdebug_by_date
这几个字段共同表达了策略自己的状态机。
其中:
pending_watchlist表示今天第一次涨停、当天刚命中的股票pending_confirmation_watchlist表示次日处于待确认阶段、当天不是买点的股票watchlist表示已经在观察池里、等待再次涨停的股票watchlist_added_index用来控制观察池超期移除debug_by_date用来保存逐日调试输出
2.4 统一 runner
run_stateful_step_backtest(...)
它不负责定义策略规则,它负责:
- 逐日驱动策略对象
- 调用统一回测执行内核
- 汇总成标准
BacktestResult
也就是说:
- 示例策略决定今天买入池是什么
- runner 决定如何按交易日调用策略并执行回测
3. 一天是怎么推进的
这个模式下,一个交易日的处理顺序可以概括成:
- 进入策略的
on_day(...) - 策略先推进自己的内部状态
- 策略读取当天市场数据
- 策略产出当天的
pool_codes - runner 调用
step_backtest_day(...) - 引擎完成买卖、持仓、权益更新
- 如果策略实现了
on_after_step(...),再把真实成交结果回写给策略
可以把它理解成:
on_day(...)负责“策略决策前半段”step_backtest_day(...)负责“交易执行”on_after_step(...)负责“策略状态回写”
4. 这个示例里 on_day(...) 做了什么
示例策略的 on_day(...) 主要做五件事。
4.1 把昨天完成待确认的股票转入观察池
如果 pending_confirmation_watchlist 里有股票:
- 说明这些股票昨天已经走完“次日待确认”
- 今天开始正式进入观察池
于是它们会被加入:
watchlist
并记录加入观察池的交易日索引。
4.2 清理超期观察池
如果某只股票进入观察池后超过 watch_window 天仍未再次涨停买入:
- 就从观察池移除
这一步说明策略对象完全可以维护自己的时间窗口规则。
4.3 读取当天市场并筛出“再次涨停”的标的
策略通过:
context.get_market_bars_polars()
拿到当天全市场日线,再交给:
select_limit_up_hits_from_market_frame(...)
筛出当天收盘涨停的标的。
然后再和观察池求交集:
- 在观察池里
- 且今天再次涨停
这些股票就构成:
today_buy_pool
4.4 把今天第一次涨停的新股票记为待入池
当天涨停候选里,如果某只股票:
- 既不在观察池
- 也不在待入池列表
- 也不在待确认列表
那就说明这是它“第一次涨停被策略捕捉到”。
这些股票会进入:
pending_watchlist
注意这里不是立即进入 watchlist。
这个示例的完整时序是:
T日第一次涨停:进入pending_watchlistT+1日:转入pending_confirmation_watchlist,当天不是买点T+1日收盘后:完成待确认T+2日开始:正式进入watchlist
4.5 产出 StrategyDayDecision
最后策略返回:
pool_codes=today_buy_pool
这表示:
- 今天真正允许引擎去尝试买入的股票列表
此时策略并没有自己下单,也没有自己算资金。
5. 这个示例里 on_after_step(...) 做了什么
当引擎完成当天执行后,runner 会把 step_result 回传给策略。
这个示例里,on_after_step(...) 主要做一件事:
- 如果某只股票今天真的买成了,就从观察池里移除
为什么要在这里做,而不是在 on_day(...) 里做?
因为 on_day(...) 只能知道:
- 今天想买谁
但它还不知道:
- 有没有真的成交
真正的成交结果只有在 step_backtest_day(...) 执行完以后才知道。
所以:
on_day(...)面向“策略意图”on_after_step(...)面向“执行结果回写”
这就是这个模式很关键的一个分界点。
6. runner 内部做了什么
run_stateful_step_backtest(...) 的内部逻辑可以理解成:
for trade_date in trade_dates:
context = StrategyStepContext(...)
decision = strategy.on_day(context)
step_result = step_backtest_day(..., pool_codes=decision.pool_codes)
strategy.on_after_step(context, decision, step_result)
它额外还统一处理了这些公共工作:
- 初始化
BacktestState - 构造
BacktestStepConfig - 生成
date_to_index - 传递
on_day回调 - 汇总
BacktestResult
所以相比手写脚本主循环,优化点在于:
- 每个 stateful 策略不需要重复写逐日驱动外壳
- 回测执行层和策略状态层的职责更清晰
- 测试和未来 loader 都更容易复用这条标准链路
7. 为什么这个示例适合教学
这个示例比更复杂的真实策略更适合讲解 stateful step,因为它同时具备:
- 明确的跨日状态
- 简单可理解的规则
- 逐日推进的可视化输出
- 与引擎职责的清晰边界
它没有引入太多额外复杂性,例如:
- 多阶段历史窗口判断
- 太多数据结构转换
- 太多策略特有术语
所以它更适合作为:
run_stateful_step_backtest(...)的第一份教学样板
8. 如何运行和观察输出
运行方式:
uv run --python .venv\Scripts\python.exe examples/run_stateful_step_limit_up_then_limit_up.py
你会看到逐日输出,包括:
observe_poolpending_newpending_confirmexpiredbuy_poolbuyssellsequity
这个示例当前采用的是:
- 每个交易日执行完成后,立即通过
on_day回调实时打印
而不是:
- 等整段回测结束后再统一打印
可以这样理解这些字段:
observe_pool:今天开始时,策略正在观察哪些股票pending_new:今天第一次涨停、刚进入待入池列表的股票pending_confirm:今天处于待确认阶段、收盘后下一交易日才会进入观察池的股票buy_pool:今天允许交给引擎尝试买入的股票buys/sells:引擎真实执行出来的成交结果
9. 一句话总结
这个示例说明了 stateful step 模式的核心心智:
- 策略对象负责维护自己的跨日状态机
- runner 负责逐日驱动策略并调用统一回测执行内核
- 引擎负责成交、持仓、资金和权益
也就是说,stateful step 不是“策略自己包办一切”,而是:
- 把“策略状态”
- 和“账户执行”
拆成两层,再用统一的逐日 runner 把它们连接起来。