Type/to search
2
Follow
495
Followers
现货-交割套利策略的实践探索:从理想到现实的那些坑
Discussions
Created 2025-11-28 14:22:49  Updated 2025-12-16 11:31:06
 0
 573

img

前言

最近有小伙伴问能不能做一个套利策略,恰好发明者平台也缺少相关策略。想着应该不难,结果这一做就是一个月左右,才算是勉强跑通了基本逻辑。现在回过头来看,这个策略从想法到落地,踩的坑比想象中多得多。

本文记录了开发这个套利策略过程中遇到的实际问题和解决思路,仅供学习参考,不具有任何实践投资价值

策略的基本逻辑

交割合约和现货之间存在价差,这个价差在正常情况下会围绕某个均值波动。当价差偏离过大时,理论上就存在套利机会。

最初的想法很简单:

  • 监测现货和交割合约的价差
  • 当价差超过2倍标准差时开仓
  • 等价差回归时平仓获利

听起来很美好对吧?但实际做下来发现,光是"开仓"这一步就有无数细节要处理。

第一个坑:价差的平稳性问题

一开始直接用价差偏离来判断,结果发现有时候价差会持续扩大,根本不回归。后来才意识到,不是所有的价差都是平稳的

特别是临近交割日,价差的行为会发生变化。所以加入了ADF检验来判断价差序列是否平稳:

javascript
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。

更重要的是,加了一个连续失败计数器

javascript
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了。如果简单地用开仓时冻结的价格来计算币的价值,就无法反映真实的盈亏情况。

正确的做法是从订单对象中提取实际成交均价

javascript
let openSpotPrice = (openSpotOrder && openSpotOrder.AvgPrice) ? openSpotOrder.AvgPrice : record.openSpotPrice; let closeSpotPrice = closeSpotOrder.AvgPrice || currentPair.spotPrice;

然后基于真实成交价计算收益率:

javascript
// 正套:买现货+卖期货 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;

注意这里的盈亏计算逻辑:

  • 正套时,现货做多、期货做空,所以现货盈亏 = (平仓价 - 开仓价) / 开仓价,期货盈亏 = (开仓价 - 平仓价) / 开仓价
  • 反套时,现货做空、期货做多,方向相反

最后将每笔交易的实际盈亏累加到总盈亏:

javascript
accumulatedProfit += actualTotalPnl; _G('accumulatedProfit', accumulatedProfit);

第三个大坑:流动性陷阱

这才是让人真正头疼的地方。为什么发明者量化平台运营10年,交割合约套利策略却寂寥无几?答案很简单:交割合约市场流动性不足

问题1:单腿成交

套利不是同时开仓的童话故事。实际情况是:

  • 现货单成交了,期货单还在挂着
  • 或者期货单成交了,现货单被撤了

这就是典型的"单腿风险"。一条腿迈进去了,另一条腿还在门外,这时候价格一波动,就不是套利而是单边持仓了。

解决方案是加入回滚机制

javascript
if (!deliveryOrder) { Log('❌ 期货卖单失败,回滚现货'); exchanges[0].CreateOrder(pair.spotSymbol, 'sell', -1, spotAmount); addCooldown(pair.deliverySymbol, pair.coin, '期货卖单失败,已回滚现货'); return false; }

只要有一条腿失败,立刻市价单把已成交的腿平掉,减少单腿的风险敞口。

问题2:市价单也会被拒绝

更离谱的是,有时候市价单也会失败。可能是因为交易所风控,可能是因为市场深度不够,总之就是下不进去。

所以做了市价单+限价单的双重机制

javascript
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次,每次都用市价单兜底。

问题3:现货买单的金额陷阱

这个坑特别隐蔽,需要特别注意。期货市价单下单数量是币的数量,但现货市价单买入时下单数量是USDT金额

这里专门加了转换逻辑:

javascript
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金额。

第四个坑:套利机会的幻觉

还有一个让人崩溃的问题:检测到套利信号,准备开仓了,结果下单的时候机会已经消失了。

价格在实时波动,从信号检测到实际开仓执行之间,市场状况可能已经发生变化。价差可能已经收窄,套利机会不复存在。如果这时候还傻傻地开仓,那就是白送手续费。

所以加了二次确认机制

javascript
// 开仓前重新获取实时价格并验证套利机会 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,确认机会还在才执行。

第五个坑:期货持仓的遗留问题

有时候策略重启,或者上次平仓失败,期货账户里会有残留持仓。如果不处理,新开仓就会和旧仓位叠加,导致仓位失控。

所以加了开仓前强制平仓的逻辑:

javascript
// 检查期货现有仓位并平仓 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价格加减差价计算出最优限价单价格
  • 结果订单一直挂在那里,等待成交
  • 最后等了很久都没成交,订单被撤销

改用市价单试试?结果更糟糕:

  • 市价单倒是成交了
  • 但成交价格和预期的套利价差完全不一样
  • 本来算好的套利机会,实际成交后根本不赚钱

这到底是怎么回事?经过反复对比交易所实盘数据,终于发现了问题所在:

Ticker vs Depth:数据本质的区别

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价格严重滞后

流动性差的合约,Ticker和实际盘口价格的偏离可能达到:

  • 正常情况:0.1% - 0.5%
  • 波动时期:1% - 3%甚至更多

对于套利策略来说,这个偏差是致命的。本来预期的价差优势可能只有0.5%,结果实际成交时发现根本就不是那个价。

解决方案:用Depth盘口数据替代Ticker

既然Ticker不靠谱,那就用Depth(订单簿深度)数据

javascript
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维护历史数据序列

javascript
// 用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进行实时判断和开仓验证

javascript
// 开仓前用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计算限价单价格

javascript
// 基于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计算实时盈亏

javascript
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价格,这里的思路是:用当前价差和平均价差的偏离来动态调整

javascript
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分钟冷却期

javascript
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数据滞后的问题,是整个策略开发中最容易被忽视但影响最大的陷阱。对于流动性较差的交割合约市场:

核心原则:用Ticker维护历史连续性,用Depth把握实时机会

  • Ticker适合历史数据分析(ADF检验、Z-Score计算)
  • Depth适合实时判断和交易执行(开仓验证、限价单定价、盈亏计算)

这篇文章记录了探索过程中遇到的问题和解决思路,希望可以给大家提供一些参考。再次强调,本文仅供学习交流,代码还不成熟,千万不要直接用于实盘交易

如果你也在做类似的策略,欢迎交流探讨。市场很复杂,但正是这种复杂性,才让量化交易充满挑战。

策略源码: https://www.fmz.com/strategy/519280

Comment
All comments (0)
No data
No data
  • 1
Forums
PINE Language
Get the app
iPhone Download
© 2015 - ∞ INVENTOR PTE LTD (SG)