
最近有小伙伴问能不能做一个套利策略,恰好发明者平台也缺少相关策略。想着应该不难,结果这一做就是一个月左右,才算是勉强跑通了基本逻辑。现在回过头来看,这个策略从想法到落地,踩的坑比想象中多得多。
本文记录了开发这个套利策略过程中遇到的实际问题和解决思路,仅供学习参考,不具有任何实践投资价值。
交割合约和现货之间存在价差,这个价差在正常情况下会围绕某个均值波动。当价差偏离过大时,理论上就存在套利机会。
最初的想法很简单: - 监测现货和交割合约的价差 - 当价差超过2倍标准差时开仓 - 等价差回归时平仓获利
听起来很美好对吧?但实际做下来发现,光是”开仓”这一步就有无数细节要处理。
一开始直接用价差偏离来判断,结果发现有时候价差会持续扩大,根本不回归。后来才意识到,不是所有的价差都是平稳的。
特别是临近交割日,价差的行为会发生变化。所以加入了ADF检验来判断价差序列是否平稳:
function adfTest(series, maxLag=null){
const n = series.length;
if(n<10) throw new Error('series too short');
if(maxLag===null) maxLag = Math.floor(12*Math.pow(n/100, 1/4));
// ... ADF检验的核心计算逻辑
const res = ols(rows, Ys);
const tstat = tStat(res.beta, res.cov, 1);
return { tStat: tstat, pValue: pval, usedLag: p };
}
最开始还加了方差比检验、半衰期检验、KS检验等一堆东西,结果发现检验太多导致开仓机会大幅减少。最后大道至简,只保留了ADF检验,设置p值阈值为0.1。
更重要的是,加了一个连续失败计数器:
if (!adfPass) {
stationarityFailCount[deliverySymbol] = (stationarityFailCount[deliverySymbol] || 0) + 1;
} else {
stationarityFailCount[deliverySymbol] = 0;
}
let consecutiveFails = stationarityFailCount[deliverySymbol];
let canTrade = consecutiveFails < CONFIG.consecutiveFailThreshold;
连续3次检验不通过就禁止交易,这样可以避免在市场状态异常时盲目开仓。
期货账户统计盈亏很简单,就看USDT的变化。但现货账户不一样,既有USDT又有币,怎么统计?
这里有个容易忽略的细节:现货的持仓成本会随着交易而变化。比如在100 USDT买入1个币,后来在90 USDT又卖出了,再在85 USDT买回来,这时候持仓成本已经不是最初的100了。如果简单地用开仓时冻结的价格来计算币的价值,就无法反映真实的盈亏情况。
正确的做法是从订单对象中提取实际成交均价:
let openSpotPrice = (openSpotOrder && openSpotOrder.AvgPrice) ?
openSpotOrder.AvgPrice : record.openSpotPrice;
let closeSpotPrice = closeSpotOrder.AvgPrice || currentPair.spotPrice;
然后基于真实成交价计算收益率:
// 正套:买现货+卖期货
if (record.direction === 'positive') {
spotReturnRate = (closeSpotPrice - openSpotPrice) / openSpotPrice;
deliveryReturnRate = (openDeliveryPrice - closeDeliveryPrice) / openDeliveryPrice;
} else {
// 反套:卖现货+买期货
spotReturnRate = (openSpotPrice - closeSpotPrice) / openSpotPrice;
deliveryReturnRate = (closeDeliveryPrice - openDeliveryPrice) / openDeliveryPrice;
}
let totalReturnRate = spotReturnRate + deliveryReturnRate;
let requiredUSD = openSpotPrice * record.spotAmount;
let actualTotalPnl = totalReturnRate * requiredUSD;
注意这里的盈亏计算逻辑: - 正套时,现货做多、期货做空,所以现货盈亏 = (平仓价 - 开仓价) / 开仓价,期货盈亏 = (开仓价 - 平仓价) / 开仓价 - 反套时,现货做空、期货做多,方向相反
最后将每笔交易的实际盈亏累加到总盈亏:
accumulatedProfit += actualTotalPnl;
_G('accumulatedProfit', accumulatedProfit);
这才是让人真正头疼的地方。为什么发明者量化平台运营10年,交割合约套利策略却寂寥无几?答案很简单:交割合约市场流动性不足。
套利不是同时开仓的童话故事。实际情况是: - 现货单成交了,期货单还在挂着 - 或者期货单成交了,现货单被撤了
这就是典型的”单腿风险”。一条腿迈进去了,另一条腿还在门外,这时候价格一波动,就不是套利而是单边持仓了。
解决方案是加入回滚机制:
if (!deliveryOrder) {
Log('❌ 期货卖单失败,回滚现货');
exchanges[0].CreateOrder(pair.spotSymbol, 'sell', -1, spotAmount);
addCooldown(pair.deliverySymbol, pair.coin, '期货卖单失败,已回滚现货');
return false;
}
只要有一条腿失败,立刻市价单把已成交的腿平掉,减少单腿的风险敞口。
更离谱的是,有时候市价单也会失败。可能是因为交易所风控,可能是因为市场深度不够,总之就是下不进去。
所以做了市价单+限价单的双重机制:
function createOrderWithFallback(exchange, symbol, direction, amount, limitPrice, orderType, maxRetry = 3) {
let useMarketOrder = (limitPrice === -1);
// 先尝试限价单
if (!useMarketOrder) {
orderId = exchange.CreateOrder(symbol, direction, limitPrice, amount);
if (!orderId) {
Log(`❌ 限价单提交失败,改用市价单`);
useMarketOrder = true;
}
}
// 限价单失败则用市价单
if (useMarketOrder && !orderId) {
orderId = exchange.CreateOrder(symbol, direction, -1, marketAmount);
}
// ... 检查订单状态,失败则重试
}
最多重试3次,每次都用市价单兜底。
这个坑特别隐蔽,需要特别注意。期货市价单下单数量是币的数量,但现货市价单买入时下单数量是USDT金额!
这里专门加了转换逻辑:
function getActualAmount(useMarket) {
if (isSpotBuy && useMarket) {
// 现货市价买单:需要用USDT金额
let currentPrice = getDepthMidPrice(exchange, symbol);
let usdtAmount = amount * currentPrice;
Log(` 💡 现货买单转换: ${amount.toFixed(6)} 币 → ${usdtAmount.toFixed(4)} USDT`);
return usdtAmount;
}
return amount;
}
限价单用币数量,市价单自动转换成USDT金额。
还有一个让人崩溃的问题:检测到套利信号,准备开仓了,结果下单的时候机会已经消失了。
价格在实时波动,从信号检测到实际开仓执行之间,市场状况可能已经发生变化。价差可能已经收窄,套利机会不复存在。如果这时候还傻傻地开仓,那就是白送手续费。
所以加了二次确认机制:
// 开仓前重新获取实时价格并验证套利机会
Log('🔄 重新获取实时Depth盘口价格并验证套利机会...');
let realtimeSpotPrice = getDepthMidPrice(exchanges[0], pair.spotSymbol, true);
let realtimeDeliveryPrice = getDepthMidPrice(exchanges[1], pair.deliverySymbol, true);
let realtimeSpread = realtimeDeliveryPrice - realtimeSpotPrice;
let realtimeSpreadRate = realtimeSpread / realtimeSpotPrice;
// 重新计算实时Z-Score
let realtimeZScore = (realtimeSpreadRate - mu) / (sigma || 1e-6);
// 验证:实时Z-Score是否仍然满足开仓条件
if (absRealtimeZ < CONFIG.zScoreEntry) {
Log('❌ 套利机会已消失!');
Log(' 取消开仓,避免亏损');
return false;
}
在真正下单之前,重新抓取实时价格,重新计算Z-Score,确认机会还在才执行。
有时候策略重启,或者上次平仓失败,期货账户里会有残留持仓。如果不处理,新开仓就会和旧仓位叠加,导致仓位失控。
所以加了开仓前强制平仓的逻辑:
// 检查期货现有仓位并平仓
let existingPosition = getPositionBySymbol(pair.deliverySymbol);
if (existingPosition && Math.abs(existingPosition.Amount) > 0) {
Log('⚠️ 检测到该合约的现有仓位,执行平仓操作...');
let closeDirection = existingPosition.Type === PD_LONG ? 'closebuy' : 'closesell';
let closeAmount = Math.abs(existingPosition.Amount);
let closeOrder = createOrderWithFallback(
exchanges[1],
pair.deliverySymbol,
closeDirection,
closeAmount,
-1,
'期货'
);
if (!closeOrder) {
Log('❌ 平仓现有持仓失败,终止开仓');
addCooldown(pair.deliverySymbol, pair.coin, '平仓现有持仓失败');
return false;
}
}
每次开仓前先检查,有残留就先平掉,确保账户干净。
这是整个策略开发过程中最隐蔽也最致命的一个问题。
一开始策略跑起来后,发现了一个诡异的现象: - 明明检测到了套利信号,准备开仓 - 用Ticker价格加减差价计算出最优限价单价格 - 结果订单一直挂在那里,等待成交 - 最后等了很久都没成交,订单被撤销
改用市价单试试?结果更糟糕: - 市价单倒是成交了 - 但成交价格和预期的套利价差完全不一样 - 本来算好的套利机会,实际成交后根本不赚钱
这到底是怎么回事?经过反复对比交易所实盘数据,终于发现了问题所在:
Ticker数据反映的是最近一次实际成交价格。这听起来没问题,但对于交割合约这种流动性较差的市场,问题就来了:
时间轴:
10:00:00 - 有人以50000成交了1张合约 → Ticker价格更新为50000
10:00:05 - 盘口挂单:买49800 / 卖50200(但没有成交)
10:00:10 - 盘口挂单:买49850 / 卖50150(但没有成交)
...
10:05:00 - Ticker价格仍然是50000(因为5分钟内没有新的成交)
你看出问题了吗?Ticker价格50000已经是5分钟前的历史了,而当前真实的市场价格(盘口价格)可能已经变成了49850/50150。
如果你用50000这个Ticker价格去计算套利机会,去挂限价单,那就是: 1. 判断错误:套利信号是基于过时的价格计算的 2. 挂单失败:限价单价格和真实市场价差太远,根本成交不了 3. 市价亏损:用市价单强行成交,实际价格和预期完全不同
交割合约的流动性比现货差很多: - 现货市场:每秒都有大量成交,Ticker价格几乎实时更新,滞后性很小 - 交割合约:可能几分钟甚至更长时间才有一笔成交,Ticker价格严重滞后
流动性差的合约,Ticker和实际盘口价格的偏离可能达到: - 正常情况:0.1% - 0.5% - 波动时期:1% - 3%甚至更多
对于套利策略来说,这个偏差是致命的。本来预期的价差优势可能只有0.5%,结果实际成交时发现根本就不是那个价。
既然Ticker不靠谱,那就用Depth(订单簿深度)数据:
function getDepthMidPrice(exchange, symbol, logDetail = false) {
let depth = exchange.GetDepth(symbol);
if (!depth || !depth.Bids || depth.Bids.length === 0 ||
!depth.Asks || depth.Asks.length === 0) {
Log(`❌ 获取${symbol}盘口失败`);
return null;
}
let bestBid = depth.Bids[0].Price; // 最优买价
let bestAsk = depth.Asks[0].Price; // 最优卖价
let midPrice = (bestBid + bestAsk) / 2; // 中间价
if (logDetail) {
let spread = bestAsk - bestBid;
let spreadRate = spread / midPrice * 100;
Log(`📊 ${symbol} 盘口: Bid=${bestBid.toFixed(2)}, Ask=${bestAsk.toFixed(2)}, Mid=${midPrice.toFixed(2)}, Spread=${spread.toFixed(2)} (${spreadRate.toFixed(3)}%)`);
}
return midPrice;
}
Depth数据的优势: - 实时性:反映当前订单簿的真实状态,没有滞后 - 准确性:你的订单会和这些盘口订单撮合,这才是真实的市场价格 - 可操作性:基于盘口价格计算限价单,成交概率更高
最终采用了Ticker + Depth的混合方案:
1. 用Ticker维护历史数据序列
// 用Ticker更新历史价差序列(保持连续性)
let spotTicker = exchanges[0].GetTicker(pair.spotSymbol);
let deliveryTicker = exchanges[1].GetTicker(pair.deliverySymbol);
pair.spotPrice = spotTicker.Last;
pair.deliveryPrice = deliveryTicker.Last;
pair.spread = pair.deliveryPrice - pair.spotPrice;
// 历史序列用于ADF检验、Z-Score计算
priceHistory[pair.deliverySymbol].push({
time: Date.now(),
spreadRate: pair.spread / pair.spotPrice,
spread: pair.spread,
spotPrice: pair.spotPrice,
deliveryPrice: pair.deliveryPrice
});
为什么历史数据还用Ticker?因为需要数据连续性。如果历史数据也用Depth,盘口价格的跳动会导致历史序列不连续,影响统计分析的准确性。
2. 用Depth进行实时判断和开仓验证
// 开仓前用Depth重新验证套利机会
let realtimeSpotPrice = getDepthMidPrice(exchanges[0], pair.spotSymbol, true);
let realtimeDeliveryPrice = getDepthMidPrice(exchanges[1], pair.deliverySymbol, true);
// 基于Depth价格重新计算Z-Score
let realtimeSpread = realtimeDeliveryPrice - realtimeSpotPrice;
let realtimeSpreadRate = realtimeSpread / realtimeSpotPrice;
let realtimeZScore = (realtimeSpreadRate - mu) / (sigma || 1e-6);
// 二次验证:套利机会是否仍然存在
if (Math.abs(realtimeZScore) < CONFIG.zScoreEntry) {
Log('❌ 套利机会已消失(基于Depth实时价格)');
return false;
}
3. 用Depth计算限价单价格
// 基于Depth价格和平均价差计算限价单价格
let spreadDeviation = realtimeSpread - avgSpread;
let adjustmentRatio = Math.min(
Math.abs(spreadDeviation) * CONFIG.limitOrderSpreadRatio,
spreadStd * 0.5
);
if (direction === 'positive') {
spotLimitPrice = realtimeSpotPrice + adjustmentRatio;
deliveryLimitPrice = realtimeDeliveryPrice - adjustmentRatio;
} else {
spotLimitPrice = realtimeSpotPrice - adjustmentRatio;
deliveryLimitPrice = realtimeDeliveryPrice + adjustmentRatio;
}
这样计算出的限价单价格,是基于真实盘口的,成交概率大大提高。
4. 用Depth计算实时盈亏
function calculateUnrealizedPnL(record, currentPair) {
// 优先用Depth价格计算实时盈亏
let currentSpotPrice = getDepthMidPrice(exchanges[0], currentPair.spotSymbol);
let currentDeliveryPrice = getDepthMidPrice(exchanges[1], currentPair.deliverySymbol);
// Depth获取失败才回退到Ticker
if (!currentSpotPrice || !currentDeliveryPrice) {
currentSpotPrice = currentPair.spotPrice;
currentDeliveryPrice = currentPair.deliveryPrice;
}
// 计算盈亏...
}
使用Ticker的问题:
检测到套利信号(基于Ticker)
→ 计算限价单价格
→ 下单等待
→ 长时间不成交(价格已经不对了)
→ 改用市价单
→ 成交价格和预期差很多
→ 套利失败或微利
使用Depth后的改善:
检测到套利信号(基于Ticker历史)
→ 用Depth重新验证(机会仍在)
→ 基于Depth计算限价单价格
→ 下单,价格贴近盘口
→ 较快成交
→ 成交价格符合预期
→ 套利成功
既然要用限价单,那价格怎么定?定得太激进成交不了,定得太保守又吃不到好价格。
基于Depth价格,这里的思路是:用当前价差和平均价差的偏离来动态调整。
let spreadDeviation = realtimeSpread - avgSpread;
let adjustmentRatio = Math.min(
Math.abs(spreadDeviation) * CONFIG.limitOrderSpreadRatio,
spreadStd * 0.5
);
// 限制调整幅度在合理区间
let minAdjustment = realtimeSpotPrice * 0.0005;
let maxAdjustment = realtimeSpotPrice * 0.005;
adjustmentRatio = Math.max(minAdjustment, Math.min(maxAdjustment, adjustmentRatio));
如果是正套(价差过大): - 现货买入价 = Depth中间价 + 调整幅度 - 期货卖出价 = Depth中间价 - 调整幅度
这样可以尽量在价差有利的位置成交,同时又不至于偏离盘口太远导致不成交。
任何开仓失败都说明市场有问题,可能是流动性不足,可能是波动太大。这时候不应该立刻重试,而是应该冷静一下。
所以给每个失败的交易对都加了10分钟冷却期:
function addCooldown(deliverySymbol, coin, reason) {
pairCooldowns[deliverySymbol] = Date.now() + CONFIG.cooldownDuration;
Log(`⏸️ ${deliverySymbol} 进入10分钟冷却期`);
Log(` 原因: ${reason}`);
_G('pairCooldowns', pairCooldowns);
}
在冷却期内,这个交易对不会尝试开仓,避免反复失败浪费手续费。
这个策略做到现在还只是个半成品,还有很多可以优化的地方:
1. 延迟问题 现在用的是轮询方式获取价格,延迟比较大。如果改用WebSocket实时推送价格,响应速度会快很多。
2. 风控优化 现在的止损比较简单粗暴,可以考虑: - 动态止损(根据波动率调整) - 时间止损(持仓时间过长强制平仓) - 最大回撤控制
3. 滑点管理 限价单的定价策略还可以更智能,比如根据订单簿深度、最近成交量等因素动态调整。
4. Depth数据的进一步应用 可以分析订单簿的不平衡程度,预判价格走势,提高套利成功率。
套利策略听起来很美好,但实际做下来才发现,从理想到现实之间隔着无数个坑:
特别是最后这个Ticker数据滞后的问题,是整个策略开发中最容易被忽视但影响最大的陷阱。对于流动性较差的交割合约市场:
核心原则:用Ticker维护历史连续性,用Depth把握实时机会
这篇文章记录了探索过程中遇到的问题和解决思路,希望可以给大家提供一些参考。再次强调,本文仅供学习交流,代码还不成熟,千万不要直接用于实盘交易。
如果你也在做类似的策略,欢迎交流探讨。市场很复杂,但正是这种复杂性,才让量化交易充满挑战。