← 返回目录
p4.3动手⏱ 约 14 分钟

信号层:职责边界与架构级防线

不靠"记得写 shift(1)",靠让错误的写法根本写不出来

🔎 最后验证 2026-07📚 来源:架构防线验证与策略函数泄漏检测于 2026-07-31 在 ECS 实测,输出见 code/outputs/stdout.txt🧰 Python 3.12.13、qlab.engine(本课自研)
为什么学这个

到目前为止,防未来函数我们用了两把尺子: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 切片,则必须藏在引擎里,不给调用方任何机会。

区别在于:前者是策略的选择(什么时候生效),后者是物理事实(你不可能知道未来)。

选择要暴露,事实要封死。

❓ 测验
团队规定「所有策略函数必须自己写 shift(1)」,并在 code review 时检查。这个做法的最大问题是?
✏️ 填空
本引擎的策略接口只返回一个 float,类型上就不允许它下单——这叫用 ___ 约束职责。

📌 免责:本课为技术教学,所有回测为历史模拟,不构成投资建议。

✅ 小结

这一节讲的是结构,不是技巧:

  • 三层分工:indicators 不知道仓位、signals 不知道钱、orders 不知道策略——未来函数几乎总发生在接缝处
  • 架构级防线:间谍测试证明策略函数拿到的 hist 比当前日期晚 1 天;这不是靠作者自觉,是 hist = d.iloc[:i] 让它不可能
  • 扰动检测法的第二次使用:同一把尺子测策略函数,全样本 z-score 17/20 命中、center=True 18/20 命中
  • 三种越界各被一道防线挡住:架构切片、接口签名、职责分离

一条贯穿的判断:选择要暴露,事实要封死。

"信号什么时候生效"是策略的选择,所以 p0.3 故意把 shift 留在明面上;"你不可能知道未来"是物理事实,所以这一节把它封死在引擎里。

下一节讲撮合层:把 p4.1 里"一次都没绑定"的三条约束,推到会真正绑定的场景——七条撮合规则逐条单元测试(不依赖行情数据),再看一段极端行情:滑点从 0 到 50bp,同一条策略的累计收益会从 +35% 翻成 -60%。

下一节 → 撮合层:把约束推到会绑定的场景
🔎 来源与核验· 2 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「间谍策略测试:回测 50 天(最后一天 2010-04-02),策略函数最后一次调用能看到的最新日期为 2010-04-01,相差 1 天;每次调用拿到的历史行数严格递增」
📚 本节 code/signal_boundary.py,2026-07-31 于 ECS 实测,输出见 code/outputs/stdout.txt✓ 已核验 2026-07
「扰动检测法测策略函数:滚动窗口+shift(1) 干净(0/20);全样本 z-score 泄漏(17/20 命中);rolling(21, center=True) 泄漏(18/20 命中)」
📚 同上实测✓ 已核验 2026-07
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p4.4
撮合层:把约束推到会绑定的场景
继续读下一节 →