写在前面
这篇文章记录了我开发一个黑色类期货交易策略的全过程,包括各种试错、推翻重来、半夜改bug的真实经历。如果你也在搞量化交易,希望这些弯路能帮你少走几步。
第一步:发现有趣的现象
最初的观察
前段时间在看期货行情的时候,发现了一个挺有意思的事:铁矿石、焦煤、螺纹钢、热卷这四个品种,大部分时候都是一起涨一起跌的。想想也对,毕竟都是一条产业链上的:
铁矿石 + 焦煤 → 炼钢 → 螺纹钢/热卷
就像一家人,有福同享有难同当。
突然的想法
但是有时候会出现这种情况:比如铁矿石突然暴涨,而其他三个还在原地踏步。这时候我就在想,这种"不听话"的现象是不是意味着什么?
有一天看到铁矿石单独拉了个大阳线,其他三个品种还在磨蹭,我就琢磨:会不会其他三个也会跟着涨?
果然,第二天螺纹钢和热卷就开始补涨了。
这让我产生了一个想法:能不能写个程序,专门抓这种"带头大哥"出现的时候,然后跟着买其他几个品种?
第二步:把想法变成逻辑
最初的思路
刚开始我的想法很简单粗暴:
- 看这四个品种的价格变化
- 如果有一个突然涨得特别猛(比其他的明显多),那它就是"带头大哥"
- 马上买其他三个品种,等着它们跟涨
- 涨够了就卖掉
听起来很简单,但是真要写成代码,问题就来了。
第一个难题:怎么定义"突然涨得特别猛"?
这个问题困扰了我好几天。最开始想用简单的价格涨幅,比如某个品种今天涨了3%,其他的只涨1%,那就算突破。
但试了一下发现不行,因为:
- 有时候市场整体都在涨,3%可能不算什么
- 有时候市场很平静,1%的差异可能就很大了
后来想到用相对偏离的概念。就是看某个品种相对于"大家的平均表现"偏离了多少。
第二个难题:怎么判断是"正常波动"还是"真突破"?
这个更头疼。市场每天都有波动,不能说稍微有点不一样就算突破吧?
想来想去,决定用统计学的方法:
- 计算过去一段时间(比如30天)每个品种的"正常偏离范围"
- 如果某天的偏离超过了这个范围,比如超过了2个标准差,那就算突破
- 这时候其他品种就跟着这个"带头大哥"的方向操作
第三步:写代码实现
技术方案选择
因为要接期货交易,最后选择了发明者量化平台,主要是因为:
- 支持期货CTP接口
- 有现成的框架,不用从头搞
- JavaScript写起来比较顺手
第一版代码:简单粗暴
第一版代码逻辑很简单:
// 伪代码
if (某个品种偏离 > 阈值) {
买其他三个品种();
}
if (盈利 > 止盈点 || 亏损 > 止损点) {
全部平仓();
}
第一次测试:惨败
回测结果惨不忍睹,胜率不到30%。仔细分析后发现几个问题:
- 假突破太多:很多时候看起来突破了,结果第二天就回去了
- 追高杀跌:经常在高点买入,在低点止损
- 没有耐心:突破后立即开仓,没有确认
第四步:反思和改进
引入"平衡-突破-跟随"循环
经过反思,我意识到应该更加尊重市场的节奏。于是设计了一个状态机:
- 寻找平衡:先等市场进入相对平衡的状态
- 等待突破:平衡状态下,等待某个品种突破
- 跟随开仓:确认突破后,其他品种跟随开仓
- 止盈止损:达到条件后平仓
- 等待恢复平衡:平仓后等待市场重新平衡
- 重复循环:回到第2步
这个想法看起来简单,但实现起来需要一个完整的状态机。让我用代码来说明这个过程:
// 状态变量定义
var hasFoundInitialBalance = false; // 是否找到初始平衡
var waitingForBalance = false; // 是否在等待平衡恢复
var isInPosition = false; // 是否有持仓
// 平衡检测函数
function detectLeaderAndFollowers(ironOreR, cokeR, rebarR, hotRollR) {
// 计算各品种的同步得分
var syncScores = calculateSyncScores(ironOreR, cokeR, rebarR, hotRollR);
// 找出最大偏离的品种
var maxAbsScore = 0;
var leader = null;
Object.keys(syncScores).forEach(function(commodity) {
var absScore = Math.abs(syncScores[commodity]);
if (absScore > maxAbsScore) {
maxAbsScore = absScore;
if (absScore > syncThreshold) { // 超过2σ算突破
leader = commodity;
}
}
});
return {
hasLeader: leader !== null,
leader: leader,
maxDeviation: maxAbsScore,
isBalanced: maxAbsScore < balanceThreshold, // 小于1σ算平衡
syncScores: syncScores
};
}
// 核心状态机逻辑
if (!isInPosition) {
if (!hasFoundInitialBalance) {
// 状态1: 寻找初始平衡
if (leaderResult.isBalanced) {
hasFoundInitialBalance = true;
Log("✅ 找到初始平衡,开始监控突破机会", "#00FF00");
} else {
Log(`🔍 寻找平衡中...最大偏离: ${leaderResult.maxDeviation.toFixed(2)}σ`, "#FFAA00");
}
} else if (!waitingForBalance) {
// 状态2: 等待突破
if (leaderResult.hasLeader) {
// 发现突破!开仓其他三个品种
Log(`💥 ${leaderResult.leaderName}突破!跟随开仓`, "#FF0000");
// 跟随开仓逻辑
followers.forEach(function(follower) {
if (direction === 'BUY') {
p.OpenLong(contractMap[follower], Amount);
} else {
p.OpenShort(contractMap[follower], Amount);
}
});
isInPosition = true;
} else {
Log(`⚖️ 等待突破...最大偏离: ${leaderResult.maxDeviation.toFixed(2)}σ`, "#00AAFF");
}
} else {
// 状态4: 等待平衡恢复
if (leaderResult.isBalanced) {
Log("✅ 平衡恢复!准备下次机会", "#00FF00");
waitingForBalance = false; // 回到状态2
} else {
Log(`⏳ 等待平衡恢复...偏离: ${leaderResult.maxDeviation.toFixed(2)}σ`, "#FFAA00");
}
}
} else {
// 状态3: 持仓中,监控止盈止损
if (totalProfit > profitTarget || totalProfit < -stopLoss) {
Log("止盈止损,平仓!", "#0000FF");
p.CoverAll();
isInPosition = false;
waitingForBalance = true; // 进入状态4
}
}
这个状态机的精妙之处在于:
强制等待平衡:程序启动后不会立即交易,而是先观察市场,等到四个品种都相对平静(最大偏离<1σ)才开始监控。
确认真突破:只有在平衡状态下的突破(>2σ)才认为是有效信号,避免在震荡行情中被假突破骗。
防止频繁交易:平仓后必须等平衡恢复才能开新仓,给市场足够的"冷静时间"。
状态可视化:每个状态都有明确的日志输出,方便调试和监控。
实际运行时,你会看到这样的日志:
🔍 寻找平衡中...最大偏离: 1.8σ
✅ 找到初始平衡,开始监控突破机会
⚖️ 等待突破...最大偏离: 0.5σ
💥 铁矿石突破!跟随开仓
📊 持仓中...当前盈亏: +1200
止盈止损,平仓!
⏳ 等待平衡恢复...偏离: 1.5σ
✅ 平衡恢复!准备下次机会
这样的设计大大提高了策略的稳定性和胜率。
关键改进点
1. 严格的开仓条件
不是随便偏离一点就开仓,而是:
- 首先要确认市场处于平衡状态
- 然后等待明确的突破信号(超过2个标准差)
- 最后才跟随开仓
2. 更合理的平仓机制
改成纯粹的止盈止损,不再考虑"平衡恢复"平仓,因为发现这样经常过早离场。
3. 防止频繁交易
平仓后必须等待市场重新平衡才能开下一次仓,避免来回打脸。
第五步:代码实现的坑
坑1:变量作用域问题
JavaScript的变量作用域真的是个坑,写着写着就报错:ReferenceError: xxx is not defined。
最后发现是全局变量和局部变量搞混了,索性把所有状态变量都放到main函数里面定义,问题解决。
坑2:数据不足的处理
刚开始没考虑数据不足的情况,结果程序一启动就报错。后来加了各种边界条件判断:
if (minLength < syncWindow + 5) {
return {
hasLeader: false,
message: "数据不足,无法检测"
};
}
坑3:合约切换问题
期货有主力合约切换的问题,之前的代码没处理,导致持仓莫名其妙地变了。后来专门写了个posTrans函数处理合约转换。
function posTrans(p, mainList) {
var codeList = mainList.map(item => item.match(/[A-Za-z]+/g)[0]);
var prePos = exchange.GetPosition();
prePos.forEach(function(pos) {
var mainCode = pos.ContractType.match(/[A-Za-z]+/g)[0];
if (mainList.indexOf(pos.ContractType) === -1) {
var index = codeList.indexOf(mainCode);
var mainID = index !== -1 ? mainList[index] : null;
if (mainID) {
Log('旧合约', pos.ContractType, '需要被更换为', mainID);
p.Cover(pos.ContractType);
if (pos.Type === PD_LONG || pos.Type === PD_LONG_YD) {
p.OpenLong(mainID, pos.Amount);
} else {
p.OpenShort(mainID, pos.Amount);
}
}
}
});
var afterPos = exchange.GetPosition();
if (afterPos.every(pos => mainList.indexOf(pos.ContractType) !== -1)) {
Log("所有合约都是主力合约", "#00FF00");
}
}
第六步:最终策略逻辑
核心算法
最终的策略核心是这样的:
-
计算同步得分:
- 计算四个品种30天的收益率
- 计算每个品种相对于组合平均的偏离
- 用Z-Score标准化,得到同步得分
-
状态判断:
- 平衡状态:所有品种偏离 < 1σ
- 突破状态:某个品种偏离 > 2σ
-
交易决策:
- 发现突破 → 其他三个品种跟随开仓
- 止盈止损 → 全部平仓
- 等待平衡恢复 → 准备下一轮
关键参数
经过大量回测调优,最终确定的参数:
var syncWindow = 30; // 30天计算窗口
var syncThreshold = 2.0; // 2σ突破阈值
var balanceThreshold = 1.0; // 1σ平衡阈值
var profitTarget = 3000; // 止盈点
var stopLoss = 1500; // 止损点
第七步:回测结果和思考
回测表现
使用近3年的数据进行回测,表现还是相当比较稳定的,大家可以参考一下。
策略优势
- 逻辑清晰:基于产业链内在联系,有经济学基础
- 风险可控:有明确的止损机制
- 适应性强:不依赖特定市场环境
- 交易频率适中:不会过度交易
需要注意的问题
- 数据质量:需要稳定的实时数据源
- 滑点成本:实盘交易要考虑滑点和手续费
- 极端行情:黑天鹅事件可能导致策略失效
- 资金管理:单次仓位不宜过大
写在最后
这个策略从想法到实现,前前后后折腾了大概两个月。期间推翻重来了好几次,也踩了不少坑。最大的感受是:量化交易看起来高大上,其实就是把交易直觉用代码实现出来。关键是要保持耐心,不断测试和改进。另外,没有完美的策略。市场在变,策略也要跟着调整。重要的是建立一套完整的开发和测试流程,这样才能持续改进。
如果你也在搞量化交易,希望这个经历对你有帮助。有问题可以一起交流,毕竟一个人折腾太孤单了😄
交流与分享
写这篇文章主要是想和大家分享一下开发策略的真实过程,包括那些踩过的坑和走过的弯路。如果对你有帮助,或者你有不同的看法,欢迎在评论区多多交流!
如果大家对这个策略比较感兴趣,评论多的话,我可以考虑把完整的源码开源出来,包括:
- 完整的策略代码
- 详细的参数调优过程
- 回测报告和风险分析
- 实盘运行的注意事项
量化交易这条路不好走,但一群人走总比一个人走要有趣得多。期待和大家的交流!
声明:本文仅为技术分享,不构成投资建议。期货交易有风险,入市需谨慎。

