经常有朋友说,量化文章大多停在理论,缺实践。今天讲点干的。
这篇不是策略推介,而是一份完整的病历:一个因子从发现、上线、四次迭代、稳定盈利,到最后失效的全过程。四个版本的代码都在,关键部分会贴,但更想说清楚的是每次改动背后的判断——特别是那些当时觉得对、后来发现只对了一半的判断,以及一次做了又主动删掉的功能。
先把时间线摆出来:
| 阶段 | 版本 | 发生了什么 |
|---|---|---|
| 💡 理论 | — | 《海力士套利狂热之后,还有机会藏在 EWY 里》 |
| 🧪 原型 | v1.0 模拟盘 | 回归 + z-score 状态机跑通 |
| 🩸 上实盘 | v1.0 币安版 | 撞上单腿风险,重做整个执行层 |
| 🔧 改造 | v2.0 网格版 | 单点进出改四级网格,行情改 WebSocket |
| ❄️ 定型 | v2.1 冻结版 | 锁定坐标系;发散收益尝试后删除 |
| 📈 辉煌 | — | 亚盘、美盘各一轮,累计约 50% |
| ⚰️ 消亡 | — | z 冲到 6,两天不回归 |
最后那一行才是这篇文章真正想写的。
赚钱的部分没什么可讲的,亏钱和失效的部分才有信息量。
一、这个因子是怎么被发现的
先讲一个没做成的生意
2026 年 6 月 2 日,币安上线了一批股票永续合约:SK 海力士、三星电子、现代汽车。
当时市场热议的是韩股现货与永续之间 3%—5% 的基差。看起来像送钱:一边买、一边卖,等它们靠拢。
但这条路算下来走不通,最核心的障碍是时间。
韩股有开收盘,永续 24 小时不停。KRX 收盘之后,底层股票停在最后一笔成交上,而永续合约还在继续吸收隔夜的宏观数据、费半波动、汇率变化。
这时候你看到的"基差"有两种可能:永续在提前反映隔夜信息(那么现货开盘会追上去,做收敛就是逆势接刀),或者只是流动性稀薄把价格推歪了(那么做收敛才对)。
在盘口上,这两种情况长得一模一样。
分不清就下注,交易的不是收敛,而是在替市场承接一次新的价格发现。再加上换汇、通道、深度、资金费率这些摩擦,3% 的名义价差能落袋的部分很有限。
但这批合约凑齐了另一样东西
放弃跨市场之后,我注意到一件事:这批新合约让一个组合变得可以在同一个账户里执行了。
EWY 是跟踪韩国股市的 ETF。按 iShares 2026 年 7 月 17 日的持仓披露:
- SK 海力士 ≈ 23.17%
- 三星电子 ≈ 21.72%
- 两家合计接近 45%
图 1:EWY 像一个装着多家韩国公司的篮子,三星和海力士是里面最重的两块砝码。
打个比方:EWY 是一个不透明的篮子,你不知道里面装了什么,但你知道有两块最重的砝码可以单独称重。如果篮子的总重量突然和"两块砝码推算出来的重量"对不上了,多出来的那部分是什么?
那就是要交易的东西:
text
ln(EWY) = c + β₁·ln(三星电子) + β₂·ln(SK海力士) + ε
↑
这个才是交易对象
ε 有明确的经济含义——EWY 里三星和海力士解释不了的那部分:现代汽车、KB 金融等其余成分股,加上汇率、时段错位、资金费率基差、流动性噪声。
选这三个标的的理由不是"三条曲线长得像",而是偏离有归因。这一点后面会反复出现——最后正是这个"归因"失效了。
💡 为什么用对数价格?
β 变成弹性系数("三星涨 1%,EWY 涨 β₁%"),与绝对价位无关,可以直接换算成下单金额;残差变成百分比偏离,量纲跨时间可比,标准化出来的 z 才有意义。用原始价格,价格水平一变量纲就变了,z 值失去比较基础。
仓位怎么配
回归给出的 β 直接就是对冲比例。做空 1 单位 EWY,同时买入 β₁ 单位三星 + β₂ 单位海力士——两个共同因子被抵消,只剩下 ε 这块敞口。
javascript
var wr = -reg.betas[j] * wTarget; // 对冲腿的方向和规模,由 β 反向决定
这一步的意义在于:我不预测韩国股市涨跌。三星和海力士的共同涨跌被对冲掉了,赚的只是"篮子和砝码之间的脱节会收敛"。
二、v1.0 模拟盘:跑通不等于可行
第一版是纯模拟盘。400 USDT,3 倍杠杆,15 分钟 K 线,480 根回归窗口(约 5 个交易日)。
信号是个简单的三态机:|z| > 1.0 开仓,|z| < 0.5 平仓,48 小时强制离场。
图 2:残差进极端区建仓,回均值附近离场。箭头表达的是交易假设,不是必然。
💡 进出场阈值为什么不对称?
都设成 1.0 的话,z 在阈值附近抖动会反复开平,手续费先把账户吃光。0.5 的缓冲带保证每笔交易至少走完半个标准差才落袋。
跑了两天,正收益。
但这个正收益什么也证明不了。
模拟引擎按最新价即时全额成交,不计手续费、不计滑点、不模拟排队和部分成交,三条腿是"同时"成交的。真实执行里根本不存在这回事。
它唯一证明的是:数据链通了,z 和 β 没算飞。
第一版最有价值的部分,是那几道"不交易"的开关
新上线的合约历史短,休市时价格可能冻结,两条对冲腿又高度相关。一个异常数据就足以把 β 和 z 推到荒谬的水平,然后让系统以为发现了百年一遇的机会。
四道门槛,不负责赚钱,只负责在数据不可信时拒绝交易:
| 门槛 | 阈值 | 防什么 |
|---|---|---|
| 价格冻结检测 | 窗口内对数极差 < 1e-6 | 休市/标记价不动,残差标准差被压到接近 0 |
| 矩阵奇异检测 | 主元 < 1e-12 | 两条腿完全共线,回归无解 |
| 残差标准差下限 | < 5e-4 | 回归不可靠,z 的分母把噪声放大成信号 |
| z 值合理性上限 | |z| > 15 | 坏数据或病态回归产生的天文数字 |
请记住最后那一行的 15,后面还会遇到它。
三、上实盘的第一课:单腿风险才是套利的命门
从模拟盘转实盘,遇到的杂七杂八的 bug 不少,但那些都是工程问题,改了就好。
真正让我重做整个执行层的,只有一件事:单腿风险。
这一节想讲透,因为它不是这个策略特有的问题——任何多腿套利,成败都在这里。
3.1 问题的本质:套利组合在成交完成之前,不是套利组合
第一版的实盘下单是串行的:
text
下第一条腿 → 等 HTTP 往返 → Sleep(300ms) → 下第二条腿 → 等 → Sleep(500ms) → 下第三条腿
三次网络往返加两次休眠,累计接近 1 秒。
这 1 秒里发生了什么?第一条腿成交后,我持有的是一个裸的方向性头寸。
不是套利,是单边押注韩国半导体。所谓的 beta 中性、所谓的"只对残差敏感",在最后一条腿成交之前统统不存在。
3.2 为什么这对套利是致命的:算笔账
方向性策略也有滑点,为什么套利策略对它格外敏感?
因为目标收益的量级不一样。
趋势策略吃的是几个百分点的方向性行情,0.1% 的滑点是零头。而统计套利吃的是残差的收敛,一次网格循环大约是 1 个残差标准差——这个量级本身就很小。
拿代码里的成本参数做锚:
javascript
var TAKER_FEE_RATE = 0.0005; // 币安 U 本位合约 VIP0 市价单 taker 费率 0.05%
var PROFIT_FEE_MULTIPLE = 3; // 目标:一次开平至少赚回 3 倍手续费
三条腿、开平双边,光手续费就是 0.05% × 2 × 3 = 0.3% 的名义成本。按 3 倍目标算,一次完整交易的毛利目标大约在 0.9% 名义这个量级。
现在把滑点放进来。三条腿裸露接近 1 秒,如果每条腿滑 0.1%,三条腿合计 0.3%——直接吃掉三分之一的目标毛利。
行情快一点,或者赶上韩股开盘的跳空,这个数字会更难看。
⚠️ 这就是套利策略的死穴:执行误差和目标收益是同一个量级。
方向性策略输在方向判断上,套利策略输在执行上。
3.3 还有一层不对称:单腿暴露是负凸性的
假设做空价差,第一条腿(空 EWY)成交了,还差两条多头腿。
- 价格朝不利方向走 → 补腿成本上升,这笔交易的预期收益缩水;
- 价格朝有利方向走 → 这一秒里确实浮盈了,但仍然必须补腿,因为策略假设的是收敛,不是方向。
也就是说,有利的方向你拿不满,不利的方向你全额承担。期望是负的。
暴露时间越长,这个负期望积累得越多。所以问题不是"能不能承受一次滑点",而是每一次开平仓都在交这笔税。
3.4 第一层解法:串行改并发
把三条腿的下单从串行改成异步并发:
javascript
// 分两阶段:
// 阶段1(并发):需要方向切换的腿,先把反向仓位并发平掉
// 阶段2(并发):所有腿的开/加/减仓,并发下单
// 阶段之间有先后(同一条腿"先平反向再开新方向"不能颠倒),阶段内部完全并发
var tasks = {};
for (...) {
tasks[sym] = exchange.Go('CreateOrder', sym, cmd, -1, qty); // 异步发起,不阻塞
}
for (var sym in tasks) {
var orderId = tasks[sym].wait(); // 统一收割
}
暴露窗口从秒级压到了几十毫秒量级。
这是整个项目里性价比最高的一次改动。
但要说清楚:并发不等于原子。 几十毫秒仍然不是零,而且这个方案有个前提——三个订单都能立刻全额成交。深度不够的时候,市价单也会部分成交,此时组合比例失衡,持有的根本不是 beta 中性的东西。
真正的原子性只有交易所提供组合单/价差单才能做到,币安的 TradFi 永续没有这个功能。所以并发只是把问题缩小了,没有消除。
3.5 第二层解法:把执行做成幂等的循环
既然做不到原子,就退而求其次:保证最终一致。
下单失败、部分成交、策略重启——不管中间出什么事,最终三条腿必须精确落在目标上。
javascript
function strictOpenUntilFilled(targets, regCoef) {
while (true) {
cancelAndConfirmNoPendingOrders(); // 先确认没有在途订单
var pq = queryLivePositionsStrict(true); // 再查交易所的真实持仓
if (!pq.ok) { Sleep(ORDER_SETTLE_MS); continue; }
if (positionsExactlyMatchTargets(pq.positions, targets)) {
// 二次复核通过后才认为开仓完成
_G('bh_execState', 'HOLDING');
return true;
}
submitOpeningDifferenceBatch(pq.positions, targets); // 只补差额,不重下全量
}
}
三个要点:
① 撤单在查仓之前。 有在途订单时查到的持仓是不完整的,据此补差额会重复下单。
② 只补差额。 每轮循环重新计算"目标 − 实际",只提交缺口。这样即使某次提交部分成交,下一轮自然会补上剩余部分。
③ 达标后二次复核。 第一次查询满足条件不代表真的完成了,可能还有刚成交未结算的部分。撤单 → 再查 → 再确认,两次都满足才置为 HOLDING。
执行状态机是 RECOVERING → FLAT_CLEAN → OPENING → HOLDING → CLOSING,状态持久化。策略重启后先进 RECOVERING:撤掉所有挂单、核对交易所的真实仓位,再决定继续持有还是清理。
🔧 多腿实盘的最低标准
任何时候策略被杀掉、机器重启、网络断开,重新拉起来之后必须能从交易所的真实状态恢复,而不是相信自己内存里记的东西。单腿策略这样做是加分项,多腿策略这样做是及格线——因为单腿策略最差是"仓位不对",多腿策略最差是"以为自己对冲了,其实没有"。
3.6 第三层:离场时的单腿风险,比入场时更致命
这一点很多人会忽略。
入场时如果一条腿没成交好,最坏的结果是这笔交易的预期收益变差。
离场时如果一条腿平不掉,你会从"套利组合"直接变成"裸敞口"。 而且这种情况偏偏最容易发生在行情剧烈、流动性变差的时候——正是最不想要裸敞口的时候。
所以平仓函数用了和开仓完全对称的死循环写法:撤单 → 查真实仓位 → 确认全部为 0 → 二次复核 → 才允许置为 FLAT_CLEAN。
javascript
function strictCloseUntilFlat(reason) {
while (true) {
cancelAndConfirmNoPendingOrders();
var pq = queryLivePositionsStrict(true);
if (!pq.ok) { Sleep(ORDER_SETTLE_MS); continue; }
if (positionsAreCompletelyFlat(pq.positions)) {
// 二次复核 → 清除所有状态,包括冻结的回归模型
...
return true;
}
submitCloseBatch(pq.positions);
}
}
这个死循环不是代码洁癖,是刚需。 一个"差不多平掉了"的多腿组合,是账户里最危险的东西。
3.7 还没解决的部分
说清楚:单腿风险我只处理到"缩小 + 最终一致"这一层。真正完整的方案还差三件事:
- 入场前的深度检查:三条腿盘口深度不足时应当直接放弃这次信号,而不是硬下;
- 限价单 + 超时回滚:市价单保成交但不保价格,限价单保价格但可能腿不齐,需要一套超时后撤单并回滚已成交腿的机制;
- 时段过滤:只在三条腿流动性都好的窗口开仓。
这三条都写进了待办,一直没做完。没做完的待办最后都会来找你。
四、v2.0:从"开关"改成"网格"
执行层稳定之后,信号层的问题浮上来了:单点进出太粗糙。
|z| > 1.0 一次性开满仓,|z| < 0.5 一次性平光。于是经常出现这种情况:z 刚过 1.0 就满仓了,然后它继续走到 2.0、2.5——既不能加仓摊低成本,也没有止损,只能干看着。等它回到 0.5 平仓,中间最好的位置完全没吃到。
v2.0 改成四级收敛网格:
text
z
+2.5 ├──── 满仓 ────┐
│ │ 这段可以
+1.5 ├──── 半仓 ────┘ 反复循环
│
+0.5 ├──── 清仓
────────┼──────────── 0
-0.5 ├──── 清仓
│
-1.5 ├──── 半仓 ────┐
│ │ 镜像
-2.5 ├──── 满仓 ────┘
正向 z 穿过 +1.5 开半仓、穿过 +2.5 加满仓;回落到 +1.5 减回半仓、回到 +0.5 全平。负向完全镜像。
±1.5 与 ±2.5 之间可以反复循环——这是收益的主要来源,一次错位能吃好几个来回。
三个设计细节,事后看每一个都很关键:
① 判断"穿越",而不是"当前位置"
javascript
if (prevZ < GRID_HALF_Z && z >= GRID_HALF_Z) { ... } // 必须是真实穿越
如果只看当前位置,策略重启一次就会立刻按当时的 z 建仓——而那个位置根本不是想要的入场点。用穿越判断,脚本在区间中途启动时不会凭空开仓。
② ±2.5 之外禁止继续加仓
硬规则,没有例外。极端偏离里无限摊平是网格策略最经典的死法。
这条规则后来救了我一命。
③ 满仓数量在开仓时冻结
每轮先算出"满仓状态下三条腿各是多少数量",存下来,之后的档位切换都是对这个固定基数做缩放。
不这么做的话,每次切换都按当时的权益和 β 重算,三条腿的比例会漂——来回几次之后,对冲结构就散了。 这本质上仍然是执行层那个问题:任何让三条腿比例失衡的机制,都在制造隐性的单腿暴露。
顺带把行情层换成了 WebSocket
网格和单点进出有一个本质区别:它必须时刻盯着 z。
单点进出只关心"z 有没有越过 1.0",晚几秒问题不大。网格有四条边界,还要判断穿越方向,prevZ 和 z 之间隔得越久,判断越不准——极端情况下 z 在两次采样之间穿过两条线又折返,你完全看不见。
REST 轮询 10 秒一次,对这个需求太慢了。
所以换成币安 U 本位合约的 WebSocket 推送,REST 只在启动时装一次历史 K 线,运行中全靠推送更新最新价和当前 K 线。轮询间隔从 10 秒压到 200 毫秒。
配套的守护逻辑必须跟上:行情超过 5 秒没更新视为失效、断线自动重连、重连失败回落到 REST、私有接口异常退避。单独看都很琐碎,但少任何一个,实盘跑几天都会出事。
五、一次做了又删掉的功能:发散方向的收益
这一节讲一个主动放弃的决定。它比前面任何一次改进都更值得写。
动机
均值回归策略在趋势里挨打,这是常识。
网格跑了一段时间后,我盯着日志里那些"z 走到 2.5 之后又继续走到 3.5、4.0"的记录,很自然会想:既然它经常继续走,为什么不顺着做一把?
于是加了手动发散开仓通道——正向开仓 / 负向开仓 两个按钮,仓位用独立的 positionOrigin = 'MANUAL' 标记,与自动网格仓位完全隔离,避免自动逻辑接管人工判断的仓位。
javascript
function weightsForDirection(betas, dir) {
var wTarget = dir * 1.0;
var wRefs = [], gross = Math.abs(wTarget);
for (var j = 0; j < betas.length; j++) {
var wr = -betas[j] * wTarget;
wRefs.push(wr); gross += Math.abs(wr);
}
var scale = gross > 0 ? (GROSS_EXPOSURE / gross) : 0;
// ... 归一化后返回
}
代码写完了,也跑通了。然后我把它删了。
为什么删
第一,发散没有离场依据。
收敛交易有一个客观的目标:z → 0。你知道自己在等什么,也知道等到什么程度该走。
发散呢?z 会走到 3 还是 8?没有任何东西告诉你。一个没有目标价的交易,本质上是在裸赌方向,只不过换了个统计学的外衣。
第二,开仓后很难达到预期。
实盘跑下来的体感很清楚:真正的发散行情是跳跃式的。等你判断出"这是发散不是噪声",最好的位置已经过去了。剩下的部分要么幅度不够覆盖成本,要么正好赶上反转。
第三,也是最根本的一条——收敛和发散用的是同一个信号。
z = 2.5 到底意味着什么?
- 可能是"偏离到了极端,即将回归"→ 应该做收敛;
- 也可能是"这个关系正在断裂"→ 应该做发散。
这两种情况在 z 值上完全不可分辨。 同一个数字,两个相反的解读,而且在下单那一刻没有任何办法区分。
那么在同一个策略里同时放收敛和发散两套逻辑,等于承认自己不知道该信哪一个——最后的结果一定是:该收敛的时候在做发散,该发散的时候在做收敛。
💭 取舍的结论
一个策略只应该有一个假设。想做发散,就应该另起一套独立的信号体系去识别"关系断裂",而不是复用收敛策略的 z。
删掉这个功能,是这个项目里唯一一次做减法。
当时我把"z 无法区分极端偏离与关系断裂"当成了一个功能取舍的理由,没有再往下想一步。这个判断后来被证明是这个策略最根本的风险,而不只是一个功能能不能做的问题。
六、v2.1:冻结坐标系
网格上线一段时间后,对账时发现一个说不通的现象:
- 有几次 z 走完了完整的收敛路径,从 ±2.5 一路回到 ±0.5 全部平掉,结算下来却基本没赚;
- 反过来,有几次账面浮盈已经相当可观,z 却还在 1.8、1.9 徘徊,迟迟不到离场线,只能眼看着行情回吐。
信号和盈亏对不上。
排查结果:尺子本身在变
根子在滚动回归。
β、残差均值、残差标准差每根 K 线都在更新。也就是说,建仓时的 z = 2.0 和一小时后的 z = 2.0,根本不是同一个东西——同样一组三腿价格,因为尺子换了,算出来的 z 就不一样。
于是出现了两种脱钩:
| 现象 | 真实情况 |
|---|---|
| z 回到离场线,但没赚钱 | 残差其实没怎么收敛,是 β 和均值的漂移把 z 拉回去的。我平掉的是一个"看起来收敛了"的仓位 |
| 赚了钱,z 却不到位 | 残差确实收敛了,但窗口内波动率变小、均值移动,把 z 撑在了高位。该落袋的时候,网格不让走 |
盈亏由实际价差变动决定,离场由 z 决定。坐标系一漂移,这两件事就脱钩了。
再往极端想:价格根本没动,仅仅因为 β 更新,z 就可能跨过 0.5 触发全部平仓——一笔完全由计算引起的交易,还要付三条腿的手续费。
解法:开仓那一刻,把整把尺子冻结
javascript
function captureAndFreezeBetas(betas, c, mean, sd) {
_G('bh_frozenRegCoef', JSON.stringify({ c: c, betas: betas, mean: mean, sd: sd }));
Log('❄️ 已冻结回归模型(HOLDING期间坐标系锁定)');
}
冻结的是四个量:截距 c、系数 betas、残差均值 mean、残差标准差 sd。少冻结任何一个,坐标系都不完整——特别是 sd,它是 z 的分母,直接决定所有边界在真实价格空间里的宽度。
持仓期间所有决策用的 z 都基于冻结模型重算,直到完全平仓才解冻。改完之后,信号和盈亏终于对上了:z 从 2.5 走到 0.5,对应的就是实实在在的价差收敛。
同时保留实时回归做监控
这是关键的一步:实时回归继续算,但只用来对比冻结模型,不参与决策。
javascript
// β 漂移监控
var drift = Math.abs(reg.betas[b] - frozenRegCoef.betas[b]) / Math.abs(frozenRegCoef.betas[b]);
if (drift > DRIFT_THRESHOLD) {
Log('⚠️ β' + (b+1) + '漂移' + _N(drift*100,1) + '% (冻结:' + _N(frozenRegCoef.betas[b],4) +
' → 新:' + _N(reg.betas[b],4) + ') | 需人工检查是否重新武装');
}
// 波动率漂移监控
var sdDrift = Math.abs(sd - frozenRegCoef.sd) / frozenRegCoef.sd;
if (sdDrift > DRIFT_THRESHOLD) {
Log('⚠️ 波动率(sd)漂移' + _N(sdDrift*100,1) + '% | 市场波动性可能改变,需人工检查');
}
设计意图是对的:冻结坐标系保证决策稳定,实时回归负责告诉我"这把尺子还准不准"。
但两个监控我都设成了 只告警、不操作,理由是"不能让自动逻辑推翻人工判断"。
❄️ 冻结坐标系,是这个策略赚到钱的直接原因。
它让 z 重新代表真实的价差偏离,网格的四条边界终于变成了真正固定的边界。而"只告警、不操作",是它最后死掉时亏损被放大的直接原因。
七、辉煌期:亚盘一轮,美盘一轮
冻结版定型之后,策略进入了一段很舒服的日子。
规律性非常强:亚盘一轮,美盘一轮。
原因不难理解——KRX 开盘和美股开盘是两个信息注入点,EWY 和两只韩股永续之间的错位主要在这两个窗口形成,然后在流动性恢复后收敛。网格在 ±1.5 和 ±2.5 之间来回吃,一天能走好几个来回。
累计下来大约 50%。
这个数字必须配三个注释,否则是误导:
- 杠杆是 10 倍。 这不是低风险产品。
- 样本只有 40 多天,而且集中在同一种市场状态里。
- 它证明的是"这个因子在这段时间存在",不是"这个因子稳定存在"。
赚钱的时候,所有护栏看起来都是多余的。这就是最危险的时候。
八、死亡:z = 6,两天不回归
某天开始,日志里出现了一个没见过的状态。
z 值冲到 6 附近,然后就不动了。整整两天。
但没有触发任何一道护栏。回头看前面那四道门槛:
| 门槛 | 当时的实际情况 | 结果 |
|---|---|---|
| 价格冻结 | 对冲腿在剧烈波动 | ✅ 通过 |
| 矩阵奇异 | 求解正常返回 | ✅ 通过 |
| 残差标准差 | 不小,反而在变大 | ✅ 通过 |
| z ≤ 15 | z = 6 | ✅ 通过 |
Z_SANITY_CAP = 15 是设计来拦截计算故障的——除零、坏数据、病态回归产生的天文数字。它拦得住 z = 400,拦不住 z = 6。
而 z = 6 恰恰是最危险的区间:数值上完全合理,经济上完全荒谬。
按网格规则,z 穿过 2.5 时已经满仓,之后被"±2.5 之外禁止加仓"的硬规则挡住——这条规则救了我,否则会一路摊平到爆仓。 但仓位就那么挂着,等一个不会来的收敛。48 小时超时后强制平仓,在最深的位置。
尸检:β₂ 变成了负数
停机之后去查 β:
text
β₁ (三星电子) = 0.93
β₂ (SK海力士) = -0.24 ← 这里
海力士占 EWY 约 23% 的权重,回归系数却是负的——这在经济上完全说不通。
而且后果是直接的。对冲腿方向由 -β 决定,β₂ 变负意味着海力士腿翻到了和 EWY 同一侧:做空 EWY 的时候,同时在做空海力士。
那不是对冲,是在韩国半导体上加了一笔方向性空头。
顺着这条线查下去,原因是三层叠加的。
第一层:共线性压垮了回归
三星和海力士这段时间近乎同步:8 月 4 日两者分别跌 8.76% 和 8.79%(Gate 新闻);7 月 31 日更夸张,两只股票单日同涨近 30%,创下各自历史单日最大涨幅(KED Global)。
两个解释变量高度同步时,回归无法把方差归因给谁。系数方差爆炸,符号随机翻转。
注意 β₁ + β₂ = 0.69,这个合计弹性完全合理——只是拆分毫无意义。
原来那道共线护栏是无效的:它只在"完全共线"时才拒绝。高度共线但没到完全共线,会顺利算出一组有限的、垃圾的系数,然后一路通过后面所有检查。
第二层:板块轮动打断了经济归因
EWY 有约 55% 是非半导体成分。而这段时间韩国市场恰好在剧烈轮动:KOSPI 权重股拖累指数,但上涨家数远多于下跌,KOSDAQ 连续三个交易日触发买入 sidecar(KED Global)。
结果是 EWY 跌幅远小于海力士——过去一个月海力士 −35%、三星 −23%(高盛,via 新浪财经)。
这是最本质的一层。
这个因子的全部前提是"篮子和砝码的短期脱节会收敛"。而板块轮动恰恰是让脱节持续下去的力量——残差不再是噪声,它变成了一个持续的结构性偏离。
第三层:杠杆型 ETF 放大了海力士的特异波动
韩国监管正在调查外资高频交易是否放大了市场波动,起因是挂钩三星和海力士的杠杆 ETF 上线后程序化交易量近乎翻倍;当局已将单只个股杠杆 ETF 的个人投资上限设为总资产的 20%(Reuters via KED)。
解释变量的特异方差被人为抬高,回归系数会被向零衰减,在共线配对下就容易压过零点变负。
冻结坐标系在这里起了反作用
冻结的本意是"持仓期间尺子不能变",这在正常市况下完全正确。
但当关系发生结构性断裂时,冻结意味着我一直在用一把"崩盘前的尺子",度量"崩盘后的世界"。
而且 z = 6 这个数字,是分子分母两头同时推出来的:
text
残差(分子)── 结构断裂,真实偏离急剧变大
z = ─────────────
sd(分母)── 冻结的是平静期的小值,市场已进入高波动状态
分子在涨,分母被锁死在一个过期的小数上。z 被系统性放大了。
更要命的是,旧坐标系下的残差永远不会回到旧的均值——因为那个均值所描述的关系已经不存在了。
z = 6 卡住两天,不是市场没回归,是我在用一个已经作废的参照系测量。
告警其实都打过
DRIFT_THRESHOLD = 0.20。β₂ 从正值翻到 −0.24,漂移率远超 20%;崩盘期间的实际波动率相对冻结的 sd,漂移同样远超 20%。
这两条告警一定都打过,而且大概率打了很多遍。
但它们被设计成了"只告警、不操作"。
九、真正的教训:漂移监控才是因子的生命体征
复盘下来,最核心的一条不是"某个阈值设错了",而是:
我把 β 漂移和波动率漂移当成了运维告警,而它们其实是这个因子的生命体征。
心电图不是用来提醒医生看一眼的,是用来触发抢救的。
9.1 β 漂移:分级处置,而不是一刀切
DRIFT_THRESHOLD = 0.20 一个阈值管所有情况,这是设计缺陷。漂移 25% 和符号翻转,是完全不同性质的两件事。
正确的做法是分级:
| 级别 | 判据 | 含义 | 处置 |
|---|---|---|---|
| 一级 | 漂移 > 20% | 关系在变化,但结构还在 | 告警 + 禁止加仓(允许持有和减仓) |
| 二级 | 漂移 > 50%,或两条腿 β 相对比例大幅改变 | 对冲比例已经不可信 | 强制平仓退出,重新回归 |
| 三级 | β 符号翻转 | 对冲腿变成了同向腿,组合不再是套利 | 立即平仓 + 停止开新仓,人工介入 |
三级是硬熔断。因为符号翻转之后,你持有的东西在结构上已经不是你以为的那个组合了——继续持有等于在裸奔,而且自己不知道。
这里必须说清楚一点:这和"最大开仓值"这类仓位限制不是一回事。
仓位上限管的是"最多亏多少",漂移熔断管的是"我现在持有的还是不是我以为的东西"。前者限制损失幅度,后者判断策略假设是否还成立。仓位上限拦不住结构失效——它只会让你在一个已经变质的组合上,亏得慢一点。
9.2 波动率漂移:它决定了网格边界值多少钱
sd 漂移这条我当初写进去了,但完全没想明白它的分量。
z 的分母就是 sd。冻结 sd,等于把网格边界在真实价格空间里的宽度也冻结了。
- 市场进入高波动状态(真实 sd 变大)→ 冻结的小 sd 使 z 被放大 → 明明是正常波动,却显示为极端偏离;
- 市场进入低波动状态(真实 sd 变小)→ 冻结的大 sd 使 z 被压缩 → 真正的机会被压到阈值以下,看不见。
这次死亡就是第一种情况的极端版本。
所以 sd 漂移的处置逻辑应该和 β 分开:
sd 漂移超过阈值 → 当前冻结模型的所有 z 阈值都已失真 → 平仓解冻,用新的波动率水平重新武装。
而不是继续拿着一把过期的尺子等收敛。
9.3 最该补而一直没补的:残差半衰期
上面两条都是事后指标——它们告诉你"关系已经变了"。
有没有事前指标?有,就是残差半衰期:对残差拟合均值回归过程,估计它回到均值需要多久。
半衰期的价值在于它直接对应策略参数:
- 半衰期 ≪ 最大持仓时限 → 策略健康;
- 半衰期开始拉长 → 收敛变慢,这是因子衰减的早期信号;
- 半衰期 > 最大持仓时限 → 策略在系统性地"未收敛先强平",此时应该自动降杠杆或停机。
这一条在第一篇文章的"未实现清单"里就写了,43 天里一直没补。
清单上写着的东西,市场迟早会来收账。
9.4 一条没被看见的线
回过头看,放弃发散功能时遇到的问题,和最后杀死策略的问题,其实是同一个。
当时因为"z 无法区分极端偏离与关系断裂",我否掉了发散功能。判断是对的。
但我只把它当成一个功能取舍,没有意识到:这个无法区分性,同时也是收敛策略本身的根本风险。
当 z = 6 出现时,它恰恰就是"关系断裂"而不是"极端偏离"。而这个策略,只会按后者去理解它。
十、还有优化方向吗?有
这个因子暂时死了,但思路没死。留几个方向给大家继续挖:
① 把两条腿合成一条,从根上消灭共线性。
按 23.17 : 21.72 构造一个固定权重的合成腿,只回归一个 β。共线问题彻底消失,而且更贴合"用成分股复制 ETF"的经济叙事。代价是丢掉两者之间的相对信息。
② 或者干脆换个配对,绕开 ETF。
直接做三星 vs 海力士两腿配对。这一步同时解决三个问题:只有一个解释变量,谈不上共线;两只都是半导体,不受板块轮动干扰(EWY 那 55% 的非半导体成分正是这次的杀手);而且从三条腿减到两条腿,单腿风险的暴露面直接少了三分之一。
这可能是最实际的一条路。
③ 带约束的回归。
坚持保留两条腿的话,用非负最小二乘或岭回归来稳定拆分,而不是裸 OLS。β 被约束在合理区间,符号翻转不会发生——这比事后检测符号更根本。
④ 把平稳性检验做成"因子存活开关"。
滚动检验残差是否平稳,不平稳就不开新仓。这比任何 z 阈值都更根本——z 有意义的前提是残差平稳,而这个前提本身需要被持续验证。
⑤ 独立的"关系断裂"识别体系。
这是发散功能留下的作业。如果能用一套独立于 z 的信号(比如成分股之间的相关结构变化、行业内部轮动强度)判断"关系正在断裂",那么它既可以作为收敛策略的熔断开关,也可以作为发散策略的入场信号。
这一件事做成了,前面所有问题都能一并解决。
写在最后
从发现到消亡,43 天。四个版本,赚了大概 50%,最后在一个没预料到的地方还回去一部分。
这不是一个失败的故事,也不是一个成功的故事。
这就是因子的正常生命周期——发现、优化、盈利、环境改变、失效。区别只在于,有的人在失效时知道发生了什么,有的人不知道。
如果这篇东西有什么用,大概是这四句:
模拟盘只能验证代码没写错,验证不了策略能不能赚钱。
多腿套利的成败在执行层,不在信号层——执行误差和目标收益是同一个量级。
一个策略只应该有一个假设。
β 漂移和波动率漂移不是运维告警,是因子的生命体征——它们该触发的是抢救,不是提醒。
四个版本的源码都在,欢迎继续挖。有做出改进的,回来交流。
⚠️ 风险提示
本文为个人实盘记录与复盘,不构成任何投资建议。文中策略使用 10 倍杠杆,属高风险交易,历史收益不代表未来表现。统计套利不等于无风险套利——本文记录的正是一次协整关系失效的完整过程。任何人在实盘运行前,请务必完成文中提到的验证项。
- 1


