本节摘要:Strategy API V2 是 QuantDinger 策略的骨架(官方模块口径)。本节先看一个最小策略类的全貌——它继承什么、覆写哪几个方法、每个方法在什么时机被调用;再把生命周期画成一张图:数据到达、产生 intent、sizing 定仓、risk 校验、订单落地、钩子收尾。最重要的分界线在 intent 与订单之间:intent 只说「我想以某方向调整某标的的敞口」,不说「下一张什么类型的单」——把成交细节交给运行时,同一份策略代码才能在回测、paper、实盘三种运行时里不改一行地跑。本节最后立两条纪律:状态最小化与幂等,它们决定策略重启后能不能恢复;配套状态自查脚本与五条排查速查表,把纪律落成工具。
# my_strategy.py —— Strategy API V2 最小骨架(接口写法示意,以官方文档为准) from quantdinger.strategy import BaseStrategy # 基类名示意 class SqueezeBreakout(BaseStrategy): params = {"atr_window": 14, "qtl_low": 0.2, "risk_per_bar": 0.001} # 超参集中声明 def on_start(self, ctx): # 开盘前钩子:预热数据、注册指标(第 4 章产物在此接入) self.squeeze = ctx.register_indicator("indicators_volatility") self.state = {"in_position": False, "entry_bar": None} def on_bar(self, ctx, bar): # 每根 K 线到达:产生 intent(本节核心,5.2 展开) if not self.state["in_position"] and self.squeeze.value(bar) == 1: ctx.intent_open(symbol=bar.symbol, direction="long") # 只表达意图 elif self.state["in_position"] and self.take_profit_hit(bar): ctx.intent_close(symbol=bar.symbol) # 平仓也是意图 def on_timer(self, ctx, tick): # 定时钩子:再平衡、日报,不塞进 on_bar(5.3 展开) pass def on_stop(self, ctx): # 收盘后钩子:状态落盘、对账(5.3 展开) ctx.persist(self.state)
骨架只有四个覆写点:启动、每根 K 线、定时、停止。策略作者的战场在 on_bar——它每根 K 线被调用一次,拿到的是已经过第 3 章数据层质检的 bar 与第 4 章算好的指标值。
on_bar 里的一次完整流转(写法示意) on_bar(bar) │ 1. 指标更新(第 4 章:一处定义的指标在此出值) │ 2. 判定条件 → ctx.intent_open(...) ← intent:方向+标的,无价格无数量 │ 3. sizing:按算法把 intent 折成目标仓位 ← 5.2 节 │ 4. risk 校验:单笔限额→组合限额→熔断 ← 5.3 节;任一不过则降级或丢弃 │ 5. 运行时下单:回测撮合 / paper 记账 / 实盘单 ← 三种运行时只在这一步不同 ▼ on_stop / on_timer 收尾:状态落盘、对账、日报
第 5 步是理解整个 API 的钥匙:回测的撮合器、paper 的记账器、实盘的适配器(第 8 章)实现同一个接口。策略代码停在第 4 步之前,所以三种运行时共用一份代码——这就是「intent 是唯一能共享的东西」的含义。
排查 bug 时按图索骥:信号没出现查第 1~2 环(指标与条件,回第 4 章);仓位不对查第 3 环;订单没发出去查第 4 环(风控拦截日志);成交价不对查第 5 环(回测撮合假设,第 6.1 节)。
| 维度 | intent(策略的词汇) | 订单(运行时的词汇) |
|---|---|---|
| 表达 | 方向、标的、紧迫度 | 类型、价格、数量、有效期 |
| 谁关心 | 策略作者 | 回测撮合器/paper 记账/交易所适配器 |
| 回测实盘差异 | 无 | 全部差异集中于此 |
| 例子 | 「做多 BTC 敞口」 | 「买 0.03 BTC,限价,只挂 5 分钟」 |
这条分界线的工程收益是三级复用:同一策略进回测(第 6 章)、进 paper(第 7 章)、进实盘(第 8 章);成本是策略作者要克制——不要在 on_bar 里琢磨「挂单还是市价」,那是运行时与 5.3 节风控的事。
trading-worker 会重启(第 2 章),策略必须活过重启。两条纪律:
其一,状态最小化。策略自己保存的状态越少,恢复越可靠。仓位、成交、余额这些事实由运行时持有并按需查询,策略只留无法重建的最小集合(如「本轮是否持仓」)。判断标准:写下你的每个状态字段,问一句「重启后能不能从运行时查回来」——能,就删掉。
其二,幂等。on_bar 可能被重放(恢复时补跑漏掉的 K 线),同一段数据跑两遍必须得到同样的 intent。落地做法:判定条件只用「当前 bar 及之前」的数据(不要偷看下一根);有状态字段时先判再改;外部调用(查余额、查持仓)放在判定之后、动作之前,避免重放时产生两次副作用。
状态最小化可以脚本化自查(示意实现):
# state_audit.py —— 策略状态最小化自查(纯标准库) RUNTIME_QUERYABLE = {"position", "equity", "balance", "open_orders"} # 运行时可查(示意集合) def audit_state(state_fields): """对策略自持状态逐字段判级:可删 / 需解释 / 必须保留""" report = [] for field in state_fields: if field in RUNTIME_QUERYABLE: report.append((field, "可删:运行时持有,重启后查得回")) elif field.endswith("_cache"): report.append((field, "可删:缓存可由历史数据重建")) else: report.append((field, "必须保留:写下重启后如何恢复它")) return report if __name__ == "__main__": for field, verdict in audit_state(["position", "entry_bar", "atr_cache", "regime"]): print(f"{field}: {verdict}")
判级为「必须保留」的字段要能回答两问:它何时被写入(on_stop 落盘了吗)、重启后从哪里读回(on_start 恢复了吗)。答不上任何一问,它就不是状态,是隐患。
幂等的自检问句压缩成三行:
| 自检问 | 通过标准 |
|---|---|
| 判定是否只用当前 bar 及之前的数据 | 是 |
| 同一根 K 线重放两次,intent 是否相同 | 相同 |
| 外部调用(查持仓、查余额)的位置 | 在判定之后、动作之前 |
把「按生命周期图定位 bug」的对应关系补全成速查表:
| 症状 | 先查哪一环 | 去哪章 |
|---|---|---|
| on_bar 迟迟不被调用 | 数据没到:闭合 K 线落库了吗 | 第 3 章 |
| 指标值一直是 None | 预热不足:窗口没攒够 | 第 4 章与 5.3 节 on_start |
| 有信号但没成交 | 风控拦截或运行时未消费 | 5.3 节拦截日志、第 2.3 节 |
| 重启后仓位「失忆」 | 状态过重或没走 persist | 本节第四部分自查脚本 |
| 回测与实盘行为不同 | 先怀疑数据口径,再怀疑代码 | 第 3 章质检、第 6.1 节撮合假设 |
骨架有了,接下来填前两环:intent 到底有哪几种说法,sizing 怎么把「想做多」折成具体仓位。