信号层:职责边界与架构级防线
不靠"记得写 shift(1)",靠让错误的写法根本写不出来
到目前为止,防未来函数我们用了两把尺子:p0.3 的大涨探测器、p1.2 的扰动检测法。
它们都是事后检查——写完了再测一遍。
这一节做的是另一件事:让未来函数在架构上不可能发生。
实测:我往引擎里塞了一个"间谍"策略,它什么都不做,只记录自己能看到什么。结果——
回测跑了 50 天,最后一天是 2010-04-02
策略函数能看到的最新日期是 2010-04-01
两者相差 1 天 → 今日数据不在 history 里
不是策略作者记得写 shift(1),是引擎的 hist = d.iloc[:i] 让他想写错都写不出来。
防止有人在考场作弊,有两种办法。
办法一:贴一张"禁止作弊"的告示,考完再查监控。
办法二:把答案锁在另一个房间,考生物理上拿不到。
第一种依赖自觉与事后检查,第二种依赖结构。
好的架构做第二种。
一、三层分工:每层只做一件事
| 层 | 做什么 | 不知道什么 |
|---|---|---|
indicators | 纯函数,只算数值 | 不知道"仓位"是什么 |
signals | 把指标翻译成目标仓位 | 不知道"钱"是什么 |
orders | 把目标仓位翻译成委托 | 不知道"策略"是什么 |
为什么分这么细?因为未来函数几乎总是发生在层与层的接缝处:
- 在
indicators里用了全样本统计(p1.2 的全样本 z-score) - 在
signals里读了今天的收盘价 - 在
orders里用当天的最低价当成交价
二、架构级防线:间谍测试
本引擎的策略接口只有一种形态:
def target_weight(hist: pd.DataFrame) -> float:
"""hist 只含到「昨日」为止的数据 —— 引擎保证这一点"""
我写了一个"间谍"策略验证它:什么都不做,只记录自己每次被调用时能看到什么。
回测跑了 50 天,最后一天是 2010-04-02
策略函数在最后一次调用时,能看到的最新日期是 2010-04-01
两者相差 1 天 → **今日数据不在 history 里**
每次调用拿到的行数依次是 [1, 2, 3, 4, 5] … [47, 48, 49](严格递增)
引擎里对应的就是一行:
hist = d.iloc[:i] # 不含今日
w = float(target_weight(hist))
架构级防线的价值,在于它不依赖于"谁来写策略"。
靠自觉的防线有一个共同问题:它保护不了下一个人。
你写代码时记得 shift(1),三个月后的你可能不记得;你的同事可能根本不知道这条规矩;你从网上抄来的策略片段更不会带着它。
而 hist = d.iloc[:i] 这一行,对所有人一视同仁。换一百个策略作者,防线依然在。
这就是"用接口约束职责"与"用文档提醒纪律"的区别。
🔧 动手做:亲手试着作弊——然后发现作弊不了(6 分钟)
架构级防线到底靠不靠谱?你来攻击它。 打开 signal_boundary.py,在策略函数里试着用"今天"的收盘价决策(你以为 hist["close"].iloc[-1] 是今天)。重跑 python signal_boundary.py。
你会看到(实测):回测正常完成、没报错,但 hist 里最新一行永远是昨天(引擎跑 50 天,策略最后一次能看到的是第 49 天,行数严格递增 1→49)——你想读今天,物理上就拿不到。
想明白:防未来函数不是靠"策略作者记得写 shift(1)",是靠引擎 hist = d.iloc[:i] 让它不可能。换个作者、换个策略,防线照样在。好的架构不是提醒你别犯错,是让你犯不了错。
三、扰动检测法的第二次使用
p1.2 的扰动检测法是用来测特征的。它同样能测策略函数——只要策略是"输入价格序列、输出仓位序列"的纯函数:
| 策略写法 | 检测结果 |
|---|---|
✅ 滚动窗口 + shift(1) | 干净 |
| ❌ 全样本 z-score | 泄漏(17/20 个切点命中) |
❌ rolling(21, center=True) | 泄漏(18/20 个切点命中) |
同一把尺子,换个对象。这也说明了 p1.2 那个方法的通用性:它测的是"输出对未来输入的依赖",而不关心中间发生了什么。
四、三种越界写法,各被哪道防线抓住
越界一:策略里读"今天的收盘价"
def try_today(hist):
return 0.95 if hist["close"].iloc[-1] > hist["close"].tail(20).mean() else 0.0
结果:合法。因为 hist 最新的一行就是昨日——架构层已经把今日拿掉了。
这行代码看起来危险,实际上安全。这正是架构防线的意义:它把"危险的写法"变成了"无害的写法"。
越界二:策略里直接下单
本引擎的 target_weight 只返回一个 float。
类型上就不允许它下单。这是"用接口约束职责":不是靠文档提醒,是靠签名让越界写不出来。
越界三:策略指定成交价
成交价由撮合层决定(次日开盘 + 滑点),策略层碰不到价格。
如果允许策略指定成交价,它迟早会指定成一个当时不可能拿到的价格——比如"用当天最低价买入"。那是回测里最经典的作弊之一,而且极难自查,因为代码看起来完全合理。
五、这一章的设计哲学
三条边界的共同点:不是靠自觉,是靠接口。
好的分层不是为了好看,是为了让错误的写法根本写不出来。
这也是为什么 p0.3 我们故意不让 signal() 内部自动 shift——那个决定必须留在明面上,由测试守住;而这一节的 hist 切片,则必须藏在引擎里,不给调用方任何机会。
区别在于:前者是策略的选择(什么时候生效),后者是物理事实(你不可能知道未来)。
选择要暴露,事实要封死。
📌 免责:本课为技术教学,所有回测为历史模拟,不构成投资建议。
这一节讲的是结构,不是技巧:
- 三层分工:indicators 不知道仓位、signals 不知道钱、orders 不知道策略——未来函数几乎总发生在接缝处
- 架构级防线:间谍测试证明策略函数拿到的
hist比当前日期晚 1 天;这不是靠作者自觉,是hist = d.iloc[:i]让它不可能 - 扰动检测法的第二次使用:同一把尺子测策略函数,全样本 z-score 17/20 命中、
center=True18/20 命中 - 三种越界各被一道防线挡住:架构切片、接口签名、职责分离
一条贯穿的判断:选择要暴露,事实要封死。
"信号什么时候生效"是策略的选择,所以 p0.3 故意把 shift 留在明面上;"你不可能知道未来"是物理事实,所以这一节把它封死在引擎里。
下一节讲撮合层:把 p4.1 里"一次都没绑定"的三条约束,推到会真正绑定的场景——七条撮合规则逐条单元测试(不依赖行情数据),再看一段极端行情:滑点从 0 到 50bp,同一条策略的累计收益会从 +35% 翻成 -60%。
🔎 来源与核验· 2 条,点开核对
