avatar of ianzeng123 ianzeng123
关注 私信
2
关注
329
关注者

现货-交割套利策略的实践探索:从理想到现实的那些坑

创建于: 2025-11-28 14:22:49, 更新于: 2025-12-16 11:31:06
comments   0
hits   23

现货-交割套利策略的实践探索:从理想到现实的那些坑

前言

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

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

策略的基本逻辑

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

最初的想法很简单: - 监测现货和交割合约的价差 - 当价差超过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年,交割合约套利策略却寂寥无几?答案很简单:交割合约市场流动性不足

问题1:单腿成交

套利不是同时开仓的童话故事。实际情况是: - 现货单成交了,期货单还在挂着 - 或者期货单成交了,现货单被撤了

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

解决方案是加入回滚机制

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

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

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

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

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

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

这里专门加了转换逻辑:

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价格加减差价计算出最优限价单价格 - 结果订单一直挂在那里,等待成交 - 最后等了很久都没成交,订单被撤销

改用市价单试试?结果更糟糕: - 市价单倒是成交了 - 但成交价格和预期的套利价差完全不一样 - 本来算好的套利机会,实际成交后根本不赚钱

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

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(订单簿深度)数据

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

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

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

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

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

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

相关推荐