avatar of 发明者量化-小小梦 发明者量化-小小梦
关注 私信
4
关注
1282
关注者

Crypto Spot-Futures Arbitrage in Practice: Lessons Learned from Theory to Reality

创建于: 2025-12-16 13:42:55, 更新于: 2025-12-16 15:28:38
comments   0
hits   9

Crypto Spot-Futures Arbitrage in Practice: Lessons Learned from Theory to Reality

Preface

Recently, a friend asked if I could develop an arbitrage strategy, and it so happened that the FMZ platform was also lacking such strategies. I thought it shouldn’t be too difficult, but it ended up taking about a month just to get the basic logic running. Looking back now, this strategy encountered far more pitfalls from conception to implementation than I had imagined.

This article documents the practical problems encountered and solutions devised during the development of this arbitrage strategy. It is intended for educational reference only and holds no practical investment value.

Basic Logic of the Strategy

There exists a price spread between futures contracts and spot markets. Under normal circumstances, this spread fluctuates around a certain mean. When the spread deviates significantly, arbitrage opportunities theoretically exist.

The initial idea was simple:

  • Monitor the price spread between spot and futures contracts
  • Open positions when the spread exceeds 2 standard deviations
  • Close positions for profit when the spread reverts

Sounds great, right? But in practice, I discovered that even the “opening positions” step alone has countless details to handle.

First Pitfall: The Stationarity Problem of Price Spreads

Initially, I used spread deviation directly to make judgments, only to find that sometimes the spread would continue widening without reverting at all. I later realized that not all price spreads are stationary.

Especially as the delivery date approaches, the spread’s behavior changes. So I added an ADF test to determine whether the spread series is stationary:

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));
  
  // ... Core calculation logic for ADF test
  
  const res = ols(rows, Ys);
  const tstat = tStat(res.beta, res.cov, 1);
  return { tStat: tstat, pValue: pval, usedLag: p };
}

Initially, I also added a bunch of tests including variance ratio tests, half-life tests, KS tests, and so on, only to find that too many tests drastically reduced the opportunities to open positions. In the end, simplicity prevailed—I kept only the ADF test with a p-value threshold set at 0.1.

More importantly, I added a consecutive failure counter:

if (!adfPass) {
  stationarityFailCount[deliverySymbol] = (stationarityFailCount[deliverySymbol] || 0) + 1;
} else {
  stationarityFailCount[deliverySymbol] = 0;
}
let consecutiveFails = stationarityFailCount[deliverySymbol];
let canTrade = consecutiveFails < CONFIG.consecutiveFailThreshold;

If the test fails three times consecutively, trading is prohibited. This prevents blindly opening positions when market conditions are abnormal.

Second Pitfall: The Confusion of Real-Time P&L Tracking

Tracking profit and loss for futures accounts is simple—just look at the change in USDT. But spot accounts are different: they have both USDT and coins. How do you track that?

There’s an easily overlooked detail here: the cost basis of spot holdings changes with each trade. For example, if you buy 1 coin at 100 USDT, later sell it at 90 USDT, then buy it back at 85 USDT, the cost basis is no longer the original 100. If you simply use the price frozen at position opening to calculate the coin’s value, it won’t reflect the actual profit and loss.

The correct approach is to extract the actual average fill price from the order object:

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

Then calculate the return rate based on the actual fill price:

// Cash-and-carry arbitrage: buy spot + sell futures
if (record.direction === 'positive') {
  spotReturnRate = (closeSpotPrice - openSpotPrice) / openSpotPrice;
  deliveryReturnRate = (openDeliveryPrice - closeDeliveryPrice) / openDeliveryPrice;
} else {
  // Reverse arbitrage: sell spot + buy futures
  spotReturnRate = (openSpotPrice - closeSpotPrice) / openSpotPrice;
  deliveryReturnRate = (closeDeliveryPrice - openDeliveryPrice) / openDeliveryPrice;
}
let totalReturnRate = spotReturnRate + deliveryReturnRate;
let requiredUSD = openSpotPrice * record.spotAmount;
let actualTotalPnl = totalReturnRate * requiredUSD;

Note the P&L calculation logic here:

  • For cash-and-carry arbitrage, spot is long and futures is short, so spot P&L = (close price - open price) / open price, and futures P&L = (open price - close price) / open price
  • For reverse arbitrage, spot is short and futures is long—the directions are reversed

Finally, accumulate the actual P&L from each trade into the total P&L:

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

Third Major Pitfall: The Liquidity Trap

This is where the real headaches begin. Why has the FMZ quantitative trading platform been running for 10 years, yet futures arbitrage strategies remain so scarce? The answer is simple: insufficient liquidity in the futures contract market.

Problem 1: Single-Leg Execution

Arbitrage isn’t a fairy tale where both positions open simultaneously. The reality is:

  • The spot order gets filled, but the futures order is still pending
  • Or the futures order gets filled, but the spot order was canceled

This is the classic “single-leg risk.” One leg steps in, while the other is still outside the door. When prices fluctuate at this moment, it’s no longer arbitrage but a directional position.

The solution is to add a rollback mechanism:

if (!deliveryOrder) {
  Log('❌ Futures sell order failed, rolling back spot');
  exchanges[0].CreateOrder(pair.spotSymbol, 'sell', -1, spotAmount);
  addCooldown(pair.deliverySymbol, pair.coin, 'Futures sell order failed, spot position rolled back');
  return false;
}

As long as one leg fails, immediately close the filled leg with a market order to reduce single-leg risk exposure.

Problem 2: Market Orders Can Also Be Rejected

What’s even more absurd is that sometimes market orders fail too. It could be due to exchange risk controls, insufficient market depth, or other reasons—the order simply won’t go through.

So I implemented a dual mechanism of market orders plus limit orders:

function createOrderWithFallback(exchange, symbol, direction, amount, limitPrice, orderType, maxRetry = 3) {
  let useMarketOrder = (limitPrice === -1);
  
  // First try limit order
  if (!useMarketOrder) {
    orderId = exchange.CreateOrder(symbol, direction, limitPrice, amount);
    if (!orderId) {
      Log('❌ Limit order submission failed, switching to market order');
      useMarketOrder = true;
    }
  }
  
  // If limit order fails, use market order
  if (useMarketOrder && !orderId) {
    orderId = exchange.CreateOrder(symbol, direction, -1, marketAmount);
  }
  
  // ... Check order status, retry if failed
}

Retry up to 3 times, with market orders as the fallback each time.

Problem 3: The Amount Trap for Spot Buy Orders

This pitfall is particularly subtle and requires special attention. For futures market orders, the order quantity is the amount of coins, but for spot market buy orders, the order quantity is the USDT amount!

I specifically added conversion logic for this:

function getActualAmount(useMarket) {
  if (isSpotBuy && useMarket) {
    // Spot market buy order: requires USDT amount
    let currentPrice = getDepthMidPrice(exchange, symbol);
    let usdtAmount = amount * currentPrice;
    Log(`  💡 Spot buy order conversion: ${amount.toFixed(6)} coins → ${usdtAmount.toFixed(4)} USDT`);
    return usdtAmount;
  }
  return amount;
}

Limit orders use coin quantity, while market orders are automatically converted to USDT amounts.

Fourth Pitfall: The Illusion of Arbitrage Opportunities

There’s another maddening problem: an arbitrage signal is detected, you’re ready to open positions, but by the time the order is placed, the opportunity has already vanished.

Prices fluctuate in real-time. Between signal detection and actual order execution, market conditions may have already changed. The spread may have narrowed and the arbitrage opportunity no longer exists. If you blindly open positions at this point, you’re just throwing away trading fees.

So I added a secondary confirmation mechanism:

// Re-fetch real-time prices and verify arbitrage opportunity before opening positions
Log('🔄 Re-fetching real-time order book prices to verify arbitrage opportunity...');
let realtimeSpotPrice = getDepthMidPrice(exchanges[0], pair.spotSymbol, true);
let realtimeDeliveryPrice = getDepthMidPrice(exchanges[1], pair.deliverySymbol, true);
let realtimeSpread = realtimeDeliveryPrice - realtimeSpotPrice;
let realtimeSpreadRate = realtimeSpread / realtimeSpotPrice;
// Recalculate real-time Z-Score
let realtimeZScore = (realtimeSpreadRate - mu) / (sigma || 1e-6);
// Verification: check if real-time Z-Score still meets position opening criteria
if (absRealtimeZ < CONFIG.zScoreEntry) {
  Log('❌ Arbitrage opportunity has vanished!');
  Log('  Canceling position opening to avoid losses');
  return false;
}

Before actually placing the order, re-fetch real-time prices, recalculate the Z-Score, and only execute after confirming the opportunity still exists.

Fifth Pitfall: Legacy Futures Position Issues

Sometimes when the strategy restarts, or when a previous close position fails, residual positions remain in the futures account. If left unhandled, new positions will stack on top of old ones, causing position sizes to spiral out of control.

So I added forced position closing logic before opening new positions:

// Check for existing futures positions and close them
let existingPosition = getPositionBySymbol(pair.deliverySymbol);
if (existingPosition && Math.abs(existingPosition.Amount) > 0) {
  Log('⚠️ Existing position detected for this contract, executing close operation...');
  
  let closeDirection = existingPosition.Type === PD_LONG ? 'closebuy' : 'closesell';
  let closeAmount = Math.abs(existingPosition.Amount);
  
  let closeOrder = createOrderWithFallback(
    exchanges[1],
    pair.deliverySymbol,
    closeDirection,
    closeAmount,
    -1,
    'Futures'
  );
  
  if (!closeOrder) {
    Log('❌ Failed to close existing position, aborting new position opening');
    addCooldown(pair.deliverySymbol, pair.coin, 'Failed to close existing position');
    return false;
  }
}

Before each position opening, check first. If there are residual positions, close them first to ensure the account is clean.

Sixth Major Pitfall: The Latency Trap of Ticker Data

This was the most insidious and fatal issue in the entire strategy development process.

After initially getting the strategy running, I noticed a bizarre phenomenon:

  • An arbitrage signal was clearly detected, ready to open positions
  • Calculated the optimal limit order price using Ticker price plus/minus a spread
  • But the order just sat there, waiting to be filled
  • Eventually waited a long time without execution, and the order was canceled

What about trying market orders instead? The results were even worse:

  • The market order did get filled
  • But the fill price was completely different from the expected arbitrage spread
  • The arbitrage opportunity I had calculated turned out to be unprofitable after actual execution

What on earth was going on? After repeatedly comparing with live exchange data, I finally discovered the root cause:

Ticker vs Depth: The Fundamental Difference in Data

Ticker data reflects the most recent actual transaction price. This sounds fine, but for low-liquidity markets like futures contracts, problems arise:

Timeline:

10:00:00 - Someone executes 1 contract at 50000 → Ticker price updates to 50000
10:00:05 - Order book: Bid 49800 / Ask 50200 (but no execution)
10:00:10 - Order book: Bid 49850 / Ask 50150 (but no execution)
...
10:05:00 - Ticker price is still 50000 (because no new transactions in 5 minutes)

Do you see the problem? The Ticker price of 50000 is already 5-minute-old history, while the current real market price (order book price) may have already become 4985050150.

If you use the Ticker price of 50000 to calculate arbitrage opportunities and place limit orders, here’s what happens:

  • Wrong judgment: The arbitrage signal is calculated based on outdated prices
  • Order failure: The limit order price is too far from the real market price and simply won’t get filled
  • Market order losses: Forcing execution with a market order results in an actual price completely different from expected

Why Is Futures Contract Ticker Data Particularly Unreliable?

Futures contracts have much worse liquidity than spot markets:

  • Spot market: Massive volume of transactions every second, Ticker prices update almost in real-time with minimal latency
  • Futures contracts: Transactions may occur only every few minutes or even longer, causing Ticker prices to lag significantly

For low-liquidity contracts, the deviation between Ticker and actual order book prices can reach:

  • Normal conditions: 0.1% - 0.5%
  • Volatile periods: 1% - 3% or even more

For arbitrage strategies, this deviation is fatal. Your expected spread advantage might only be 0.5%, but upon actual execution, you discover the price isn’t what you thought at all.

Solution: Replace Ticker with Depth Order Book Data

Since Ticker is unreliable, use Depth (order book) data instead:

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(`❌ Failed to get order book for ${symbol}`);
        return null;
    }
    
    let bestBid = depth.Bids[0].Price;  // Best bid price
    let bestAsk = depth.Asks[0].Price;  // Best ask price
    let midPrice = (bestBid + bestAsk) / 2;  // Mid price
    
    if (logDetail) {
        let spread = bestAsk - bestBid;
        let spreadRate = spread / midPrice * 100;
        Log(`📊 ${symbol} Order Book: Bid=${bestBid.toFixed(2)}, Ask=${bestAsk.toFixed(2)}, Mid=${midPrice.toFixed(2)}, Spread=${spread.toFixed(2)} (${spreadRate.toFixed(3)}%)`);
    }
    
    return midPrice;
}

Advantages of Depth Data:

  • Real-time: Reflects the true current state of the order book with no latency
  • Accuracy: Your orders will be matched against these order book orders—this is the real market price
  • Actionability: Limit orders calculated based on order book prices have a higher probability of execution

The Strategy’s Dual Price System

Ultimately, I adopted a hybrid Ticker + Depth approach:

1.Use Ticker to maintain historical data series

// Use Ticker to update historical spread series (maintain continuity)
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;
// Historical series used for ADF test and Z-Score calculation
priceHistory[pair.deliverySymbol].push({
    time: Date.now(),
    spreadRate: pair.spread / pair.spotPrice,
    spread: pair.spread,
    spotPrice: pair.spotPrice,
    deliveryPrice: pair.deliveryPrice
});

Why still use Ticker for historical data? Because data continuity is needed. If Depth were used for historical data as well, the fluctuations in order book prices would cause discontinuities in the historical series, affecting the accuracy of statistical analysis.

2.Use Depth for real-time judgment and position opening verification

// Re-verify arbitrage opportunity using Depth before opening positions
let realtimeSpotPrice = getDepthMidPrice(exchanges[0], pair.spotSymbol, true);
let realtimeDeliveryPrice = getDepthMidPrice(exchanges[1], pair.deliverySymbol, true);
// Recalculate Z-Score based on Depth prices
let realtimeSpread = realtimeDeliveryPrice - realtimeSpotPrice;
let realtimeSpreadRate = realtimeSpread / realtimeSpotPrice;
let realtimeZScore = (realtimeSpreadRate - mu) / (sigma || 1e-6);
// Secondary verification: does the arbitrage opportunity still exist
if (Math.abs(realtimeZScore) < CONFIG.zScoreEntry) {
    Log('❌ Arbitrage opportunity has vanished (based on real-time Depth prices)');
    return false;
}

3.Use Depth to calculate limit order prices

// Calculate limit order prices based on Depth prices and average spread
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;
}

Limit order prices calculated this way are based on the real order book, significantly increasing the probability of execution.

4.Use Depth to calculate real-time P&L

function calculateUnrealizedPnL(record, currentPair) {
    // Prioritize Depth prices for real-time P&L calculation
    let currentSpotPrice = getDepthMidPrice(exchanges[0], currentPair.spotSymbol);
    let currentDeliveryPrice = getDepthMidPrice(exchanges[1], currentPair.deliverySymbol);
    
    // Fall back to Ticker only if Depth retrieval fails
    if (!currentSpotPrice || !currentDeliveryPrice) {
        currentSpotPrice = currentPair.spotPrice;
        currentDeliveryPrice = currentPair.deliveryPrice;
    }
    
    // Calculate P&L...
}

Real-World Performance Comparison

Problems when using Ticker:

Arbitrage signal detected (based on Ticker)
→ Calculate limit order price
→ Place order and wait
→ Long time without execution (price is already wrong)
→ Switch to market order
→ Fill price differs significantly from expected
→ Arbitrage fails or yields minimal profit

Improvements after using Depth:

Arbitrage signal detected (based on Ticker history)
→ Re-verify with Depth (opportunity still exists)
→ Calculate limit order price based on Depth
→ Place order, price close to order book
→ Faster execution
→ Fill price matches expectations
→ Arbitrage succeeds

Optimizing Limit Order Pricing

Since we’re using limit orders, how do we set the price? Too aggressive and it won’t get filled; too conservative and you miss out on better prices.

Based on Depth prices, the approach here is: dynamically adjust based on the deviation between the current spread and the average spread.

let spreadDeviation = realtimeSpread - avgSpread;
let adjustmentRatio = Math.min(
  Math.abs(spreadDeviation) * CONFIG.limitOrderSpreadRatio,
  spreadStd * 0.5
);
// Limit the adjustment range to a reasonable interval
let minAdjustment = realtimeSpotPrice * 0.0005;
let maxAdjustment = realtimeSpotPrice * 0.005;
adjustmentRatio = Math.max(minAdjustment, Math.min(maxAdjustment, adjustmentRatio));

For cash-and-carry arbitrage (spread too large):

  • Spot buy price = Depth mid price + adjustment ratio
  • Futures sell price = Depth mid price - adjustment ratio

This way, you try to execute at favorable spread positions while not deviating too far from the order book, which would prevent execution.

Cooldown Mechanism

Any position opening failure indicates market issues—it could be insufficient liquidity or excessive volatility. In such cases, you shouldn’t retry immediately; instead, you should cool down.

So I added a 10-minute cooldown period for each failed trading pair:

function addCooldown(deliverySymbol, coin, reason) {
  pairCooldowns[deliverySymbol] = Date.now() + CONFIG.cooldownDuration;
  Log(`⏸️ ${deliverySymbol} entering 10-minute cooldown period`);
  Log(`   Reason: ${reason}`);
  _G('pairCooldowns', pairCooldowns);
}

During the cooldown period, this trading pair won’t attempt to open positions, avoiding repeated failures that waste trading fees.

Current Limitations and Areas for Improvement

This strategy is still only half-finished at this point, with many areas that can be optimized:

1.Latency Issues

Currently using a polling approach to fetch prices, which has significant latency. Switching to WebSocket for real-time price streaming would greatly improve response speed.

2.Risk Control Optimization

The current stop-loss is quite crude. Consider:

  • Dynamic stop-loss (adjusting based on volatility)
  • Time-based stop-loss (forced closing if position held too long)
  • Maximum drawdown control

3.Slippage Management

The limit order pricing strategy could be smarter, such as dynamically adjusting based on order book depth, recent trading volume, and other factors.

4.Further Application of Depth Data

Could analyze order book imbalance to predict price movements and improve arbitrage success rates.

Conclusion

Arbitrage strategies sound wonderful, but after actually building one, I discovered countless pitfalls between theory and reality:

  • The stationarity test pitfall
  • The P&L tracking pitfall
  • The insufficient liquidity pitfall
  • The single-leg execution pitfall
  • The market order failure pitfall
  • The spot buy order amount pitfall
  • The vanishing arbitrage opportunity pitfall
  • The residual position pitfall
  • The Ticker latency pitfall (the most fatal)

Especially this last issue of Ticker data latency—it’s the most easily overlooked yet most impactful trap in the entire strategy development. For low-liquidity futures contract markets:

Core Principle: Use Ticker to maintain historical continuity, use Depth to capture real-time opportunities

  • Ticker is suitable for historical data analysis (ADF test, Z-Score calculation)
  • Depth is suitable for real-time judgment and trade execution (position opening verification, limit order pricing, P&L calculation)

This article documents the problems encountered and solutions devised during the exploration process. I hope it provides some reference for everyone. Let me emphasize again: this article is for educational exchange only. The code is still immature—do not use it directly for live trading.

If you’re working on similar strategies, feel free to discuss and exchange ideas. The market is complex, but it’s precisely this complexity that makes quantitative trading so challenging.

Strategy source code: https://www.fmz.com/strategy/519280

相关推荐