本节摘要:生命周期最后两环。risk 部分是两道闸门:单笔层(止损、止盈、最大单笔仓位)在 intent 产生的瞬间校验,组合层(总敞口上限、同向集中度、回撤熔断)在折算成目标仓位后校验;拦截不是丢弃那么简单——按「降级、拆分、拒绝」三档处置,并且每次拦截都落日志,因为「被拦掉的交易」也是回测报告的一部分。运行时部分是三个钩子的分工:开盘前预热数据、定时钩子做再平衡与日报、收盘后对账与状态落盘;凡是「不是针对当前 K 线做决策」的事,一律离开 on_bar。本节末尾把断线重连、部分成交、重启恢复三个实战场景过一遍,附钩子判定器与高频排查表,策略至此才算完整。
intent 的风控路径(拦截点示意) intent_open("BTC", long) │ ▼ ┌─────────────────────────┐ 不过 → 处置三档之一 + 拦截日志 │ 单笔层:止损距离可接受? │ │ 止盈比例合理? │ │ 单笔仓位 ≤ 上限? │ └───────────┬─────────────┘ ▼ 通过 ┌─────────────────────────┐ 不过 → 处置三档之一 + 拦截日志 │ 组合层:总敞口 ≤ 上限? │ │ 同向集中度 ≤ 上限? │ │ 回撤中?熔断开关状态? │ └───────────┬─────────────┘ ▼ 通过 → 运行时下单(回测撮合 / paper / 实盘)
两层的分工用一句话记:单笔层管「这一笔会不会要命」,组合层管「很多笔加起来会不会要命」。参数示例(全部示意值,须按品种与账户规模重估):单笔风险不超过权益的 1%、总敞口不超过权益的 100%、同方向不超过总敞口的 60%、组合回撤超过 10% 触发熔断(暂停开新仓、只允许平仓)。度量口径见《量化投资方法论》第 14 章《风险控制与绩效度量》。
| 处置 | 含义 | 适用 |
|---|---|---|
| 降级 | 把仓位缩到限额内放行 | 超出单笔上限但方向仍有效 |
| 拆分 | 拆成多笔分批建仓 | 流动性或风控上限约束 |
| 拒绝 | 丢弃 intent | 熔断期间的开仓、止损无效的单 |
两条配套纪律:其一,处置规则要确定(同一场景永远同一处置),否则回测无法复现实盘行为;其二,拦截日志与成交日志同级保存——第 6.1 节回测报告里「信号出现但被拦截」的统计就来自这里,它也是排查「策略变慢变钝」的第一现场。
# risk_gates.py —— 两层风控的最小实现(示意) def pass_risk(intent, target_qty, account_state, limits): # 单笔层 if target_qty > limits["max_per_order"]: target_qty = limits["max_per_order"] # 处置:降级 log("降级", intent, reason="单笔上限") # 组合层 projected = account_state["gross_exposure"] + target_qty * intent.price if projected > limits["max_gross"]: if account_state["circuit_breaker"]: log("拒绝", intent, reason="熔断期间禁止开仓") return None allowed = (limits["max_gross"] - account_state["gross_exposure"]) / intent.price if allowed <= 0: log("拒绝", intent, reason="总敞口已满") return None log("降级", intent, reason="总敞口约束") target_qty = allowed return target_qty
on_start(开盘前):预热历史 K 线让指标出值(4.2 节的分位数需要 120 根历史)、校验配置、声明本交易日将用的数据源。原则:凡是为「今天的第一根 K 线就能决策」做的准备都在这里。
on_timer(定时):再平衡检查、日报生成、波动率估计的定期更新。原则:周期性但不随 K 线走的动作放这里,避免 on_bar 越来越胖。
on_stop(收盘后):对账(本地状态与运行时核对)、状态落盘(5.1 节的最小状态集合)、次日参数生效检查。原则:一天结束前把「重启也不丢」的东西写完。
一句话分工:on_bar 只做决策,其余一切进钩子。判断某个逻辑放哪,就问「它需要当前这根 K 线吗」——不需要,就不属于 on_bar。
「放哪」的判断可以写成一个小函数当团队速查(示意):
# hook_placement.py —— 「这段逻辑放哪个钩子」判定器(纯标准库) def place(needs_bar, is_periodic, is_lifecycle): """三个问句依次回答 True / False(示意判定顺序)""" if needs_bar: return "on_bar:逐 K 线决策" if is_periodic: return "on_timer:周期性维护" if is_lifecycle: return "on_start / on_stop:启动准备或收尾" return "重新考虑:可能不属于策略,交给运行时或外部任务" if __name__ == "__main__": print(place(True, False, False)) # 止损判定 print(place(False, True, False)) # 波动率估计更新 print(place(False, False, True)) # 指标预热 print(place(False, False, False)) # 发通知
最后一行是判定器的隐藏价值:它逼你承认有些逻辑根本不该住在策略里——通知、扩容、外部对账这类事,属于平台与运维层。策略类保持「只做决策」,是它能在三种运行时里不改一行跑起来的前提之一。
场景一,断线重连。行情断流期间 K 线缺失,重连后运行时会补发(第 3.3 节的数据回填),策略侧要保证幂等(5.1 节):补发的旧 K 线重放不产生重复 intent。切忌在策略里自己写「断线就立刻平仓」——这是组合层熔断的职责(配合第 10 章的应急开关),策略层自作主张会让回测与实盘行为分叉。
场景二,部分成交。实盘中 intent 折算的订单可能只成交一部分。处置原则:成交事实以运行时为准,策略下一次 on_bar 拿到的 position 已经反映部分成交;不要在策略里追踪「我还有多少没成交」——那是重放不幂等的状态。
场景三,重启恢复。trading-worker 重启(第 2 章)后 on_start 重新预热,策略从落盘的最小状态与运行时持仓恢复。恢复后第一件事不是交易,是对账:落盘状态、运行时持仓、交易所实际持仓三方核对(第 8.3 节),对得上才继续。
重启恢复后的核对清单(示意,对得上才继续交易):
| 核对项 | 左侧 | 右侧 |
|---|---|---|
| 持仓 | 落盘的最小状态 | 运行时 position |
| 敞口 | 策略视角的组合敞口 | 风控视角的组合敞口 |
| 挂单 | 本地订单表待处理记录 | 交易所(或 paper 记账)侧在途单 |
| 时间 | 本地时钟与数据时间戳 | 数据源时间口径(3.1 节) |
| 疑问 | 排查方向 |
|---|---|
| 怎么知道 intent 被拦了 | 拦截日志与成交日志同级保存,先查它;报告里的拦截统计同源 |
| 熔断触发后怎么解除 | 复位是人工确认动作,不建议做成自动解除(示意纪律) |
| 部分成交后仓位对不上 | 以运行时 position 为准;策略自记成交量即不幂等 |
| on_timer 迟迟不触发 | 查运行时定时配置与第 2 章节拍器链路(beat 投递、worker 领取) |
| 降级和拆分怎么选 | 流动性约束选拆分,限额约束选降级;规则要事先写死 |
策略会做了、也会自我保护了。但它到底行不行,要交给数据说话——第 6 章回测与实验,把这份代码放进历史行情里检验。