
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.
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:
Sounds great, right? But in practice, I discovered that even the “opening positions” step alone has countless details to handle.
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.
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:
Finally, accumulate the actual P&L from each trade into the total P&L:
accumulatedProfit += actualTotalPnl;
_G('accumulatedProfit', accumulatedProfit);
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.
Arbitrage isn’t a fairy tale where both positions open simultaneously. The reality is:
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.
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.
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.
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.
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.
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:
What about trying market orders instead? The results were even worse:
What on earth was going on? After repeatedly comparing with live exchange data, I finally discovered the root cause:
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 49850⁄50150.
If you use the Ticker price of 50000 to calculate arbitrage opportunities and place limit orders, here’s what happens:
Futures contracts have much worse liquidity than spot markets:
For low-liquidity contracts, the deviation between Ticker and actual order book prices can reach:
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.
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:
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
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):
This way, you try to execute at favorable spread positions while not deviating too far from the order book, which would prevent execution.
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.
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:
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.
Arbitrage strategies sound wonderful, but after actually building one, I discovered countless pitfalls between theory and reality:
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
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