
Hace poco, un amigo me preguntó si podía crear una estrategia de arbitraje, algo que faltaba en la plataforma Inventor. Pensé que no sería difícil, pero me llevó casi un mes conseguir que la lógica básica funcionara. Mirando hacia atrás, los obstáculos que encontré desde la idea inicial hasta su implementación fueron mucho más numerosos de lo que imaginaba.
Este artículo documenta los problemas prácticos encontrados y las soluciones desarrolladas durante el desarrollo de esta estrategia de arbitraje.Sólo para fines de aprendizaje y referencia; no tiene valor práctico de inversión.。
Existe una diferencia de precio entre el contrato de entrega y el precio spot, que normalmente fluctúa en torno a un promedio determinado. Cuando la diferencia de precio se desvía demasiado, teóricamente existen oportunidades de arbitraje.
La idea inicial era sencilla:
Suena genial, ¿verdad? Pero en la práctica, descubrirás que hay innumerables detalles que gestionar simplemente al abrir una posición.
Inicialmente, utilizamos la desviación del diferencial de precios como indicador directo, pero descubrimos que, en ocasiones, este seguía ampliándose y nunca volvía a su estado anterior. Más tarde, nos dimos cuenta de que…No todos los diferenciales de precios son estables.。
Especialmente a medida que se acerca la fecha de entrega, el comportamiento del spread puede cambiar. Por lo tanto, se añadió la prueba ADF para determinar si la serie de spreads es estacionaria.
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 };
}
Inicialmente, añadimos varias pruebas, como la prueba de razón de varianza, la prueba de vida media y la prueba KS, pero descubrimos que un exceso de pruebas reducía significativamente el número de oportunidades para abrir posiciones. Finalmente, simplificamos el proceso a solo la prueba ADF y establecimos el umbral del valor p en 0,1.
Lo más importante es que se agregó uno extra.Contador de fallos continuos:
if (!adfPass) {
stationarityFailCount[deliverySymbol] = (stationarityFailCount[deliverySymbol] || 0) + 1;
} else {
stationarityFailCount[deliverySymbol] = 0;
}
let consecutiveFails = stationarityFailCount[deliverySymbol];
let canTrade = consecutiveFails < CONFIG.consecutiveFailThreshold;
Se prohíbe operar si la prueba falla tres veces consecutivas. Esto puede evitar la apertura de posiciones a ciegas cuando el mercado se encuentra en un estado anormal.
Calcular las ganancias y pérdidas de una cuenta de futuros es sencillo; basta con observar las fluctuaciones en USDT. Pero las cuentas al contado son diferentes; contienen tanto USDT como criptomonedas. ¿Cómo se calcula esto?
He aquí un detalle que se pasa por alto fácilmente:El costo de mantener bienes al contado cambiará con el comercio.Por ejemplo, si compra una moneda por 100 USDT, la vende por 90 USDT y luego la recompra por 85 USDT, su costo base ya no es el precio inicial de 100 USDT. Usar simplemente el precio congelado al abrir la posición para calcular el valor de la moneda no reflejará la verdadera situación de ganancias y pérdidas.
El enfoque correcto esExtraiga el precio de transacción promedio real del objeto de pedido.:
let openSpotPrice = (openSpotOrder && openSpotOrder.AvgPrice) ?
openSpotOrder.AvgPrice : record.openSpotPrice;
let closeSpotPrice = closeSpotOrder.AvgPrice || currentPair.spotPrice;
Luego, la tasa de retorno se calcula en función del precio real de la transacción:
// 正套:买现货+卖期货
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;
Tenga en cuenta la lógica del cálculo de ganancias y pérdidas aquí:
Finalmente, la ganancia o pérdida real de cada transacción se suma a la ganancia o pérdida total:
accumulatedProfit += actualTotalPnl;
_G('accumulatedProfit', accumulatedProfit);
Este es el verdadero problema. ¿Por qué, tras 10 años de funcionamiento, la Plataforma Cuantitativa Inventor cuenta con tan pocas estrategias de arbitraje para contratos de futuros? La respuesta es sencilla:Liquidez insuficiente en el mercado de contratos de entrega。
El arbitraje no es un cuento de hadas donde se abren posiciones simultáneamente. La realidad es:
Este es un ejemplo clásico de “riesgo unilateral”. Una pierna está dentro, mientras que la otra permanece fuera. En este punto, cualquier fluctuación de precio ya no es arbitraje, sino una posición unilateral.
La solución es unirseMecanismo de reversión:
if (!deliveryOrder) {
Log('❌ 期货卖单失败,回滚现货');
exchanges[0].CreateOrder(pair.spotSymbol, 'sell', -1, spotAmount);
addCooldown(pair.deliverySymbol, pair.coin, '期货卖单失败,已回滚现货');
return false;
}
Si alguna de las piernas falla, coloque inmediatamente una orden de mercado para cerrar la pierna ya ejecutada, reduciendo así la exposición al riesgo de esa pierna individual.
Aún más absurdo, a veces las órdenes de mercado también fallan. Esto podría deberse a controles de riesgo cambiario o a una profundidad de mercado insuficiente; en resumen, la orden simplemente no se ejecuta.
Así que lo hice.Mecanismo dual de orden de mercado + orden límite:
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);
}
// ... 检查订单状态,失败则重试
}
El sistema puede volver a intentarlo un máximo de 3 veces, y en cada intento utilizará una orden de mercado como red de seguridad.
Esta trampa está particularmente bien escondida y requiere precaución adicional. La cantidad para una orden de mercado de futuros es…Número de monedasSin embargo, la cantidad solicitada al comprar una orden en el mercado al contado es…Monto en USDT!
Aquí se ha añadido específicamente una lógica de conversión:
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;
}
La cantidad de criptomoneda utilizada en una orden limitada se convierte automáticamente a USDT para una orden de mercado.
Otro problema frustrante es que se detecta una señal de arbitraje y usted está listo para abrir una posición, pero la oportunidad ya ha desaparecido en el momento en que realiza la orden.
Los precios fluctúan en tiempo real, y las condiciones del mercado pueden haber cambiado entre la detección de la señal y la ejecución de una posición. Los diferenciales de precios pueden haberse reducido y las oportunidades de arbitraje pueden haber desaparecido. Si sigue abriendo una posición insensatamente en este punto, simplemente está desperdiciando comisiones.
Así que se añadió.Mecanismo de confirmación secundaria:
// 开仓前重新获取实时价格并验证套利机会
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;
}
Antes de realizar un pedido, vuelva a obtener el precio en tiempo real, vuelva a calcular el puntaje Z y solo ejecute el pedido si está seguro de que la oportunidad aún existe.
En ocasiones, al reiniciar una estrategia o cerrar una posición anterior, pueden quedar posiciones residuales en la cuenta de futuros. Si no se solucionan, las nuevas posiciones se superpondrán con las antiguas, lo que resultará en un tamaño de posición descontrolado.
Así que se añadió.Liquidación forzosa antes de abrir una posiciónLa lógica:
// 检查期货现有仓位并平仓
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;
}
}
Antes de abrir una posición, revísela. Si quedan posiciones, ciérrelas para asegurar que su cuenta esté limpia.
Este es todo el proceso de desarrollo de la estrategia.El más oculto y el más mortalUno de los problemas.
Después de que la estrategia comenzó a ejecutarse, se observó un fenómeno extraño:
¿Intentaste usar órdenes de mercado? El resultado fue aún peor:
¿Qué ocurrió exactamente? Tras comparar repetidamente los datos de operaciones en vivo de la bolsa, finalmente se descubrió el problema:
Los datos del ticker reflejan el precio de transacción real más reciente.Esto suena bien, pero en los mercados con baja liquidez, como los contratos de entrega, surgen problemas:
时间轴:
10:00:00 - 有人以50000成交了1张合约 → Ticker价格更新为50000
10:00:05 - 盘口挂单:买49800 / 卖50200(但没有成交)
10:00:10 - 盘口挂单:买49850 / 卖50150(但没有成交)
...
10:05:00 - Ticker价格仍然是50000(因为5分钟内没有新的成交)
¿Has notado el problema?El precio del Ticker de 50.000 ya era histórico hace 5 minutos.Sin embargo, el precio real actual del mercado (precio del libro de órdenes) puede haber llegado a ser 49850⁄50150.
Si utiliza el precio de cotización de 50 000 para calcular oportunidades de arbitraje y colocar órdenes limitadas, entonces:
La liquidez de los contratos de entrega es mucho peor que la de los contratos al contado:
En el caso de contratos con poca liquidez, la desviación entre el precio del ticker y el precio real del libro de órdenes puede alcanzar:
Para las estrategias de arbitraje, esta desviación es fatal. La ventaja esperada por la diferencia de precio podría ser solo del 0,5 %, pero el precio real de la transacción resulta ser completamente diferente.
Dado que Ticker no es confiable, usemos…Datos de profundidad (profundidad del libro de pedidos):
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;
}
Ventajas de los datos de profundidad:
Finalmente adoptadoSolución híbrida de Ticker + Profundidad:
1. Utilice Ticker para mantener secuencias de datos históricos.
// 用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
});
¿Por qué se sigue usando Ticker para datos históricos? Porque es necesario.Continuidad de datosSi los datos históricos también se representan utilizando profundidad, las fluctuaciones en los precios del libro de órdenes causarán discontinuidades en la secuencia histórica, lo que afectará la precisión del análisis estadístico.
2. Utilice Profundidad para el juicio en tiempo real y la verificación de la apertura de la posición.
// 开仓前用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. Calcule el precio de la orden límite utilizando la profundidad.
// 基于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;
}
El precio de la orden límite calculado de esta manera se basa en el libro de órdenes real, lo que aumenta enormemente la probabilidad de ejecución.
4. Calcule ganancias y pérdidas en tiempo real utilizando Profundidad.
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;
}
// 计算盈亏...
}
Problemas con el uso de Ticker:
检测到套利信号(基于Ticker)
→ 计算限价单价格
→ 下单等待
→ 长时间不成交(价格已经不对了)
→ 改用市价单
→ 成交价格和预期差很多
→ 套利失败或微利
Mejoras después de usar Profundidad:
检测到套利信号(基于Ticker历史)
→ 用Depth重新验证(机会仍在)
→ 基于Depth计算限价单价格
→ 下单,价格贴近盘口
→ 较快成交
→ 成交价格符合预期
→ 套利成功
Si vamos a usar órdenes limitadas, ¿cómo fijamos el precio? Si lo fijamos de forma demasiado agresiva, la transacción no se concretará; si lo fijamos de forma demasiado conservadora, no obtendremos un buen precio.
Basándonos en el precio de profundidad, el enfoque aquí es:Ajustar dinámicamente en función de la desviación entre el diferencial de precios actual y el diferencial de precios promedio.。
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));
Si se trata de un conjunto completo (con una gran diferencia de precio):
Esto permite que las transacciones se completen con una diferencia de precio favorable, evitando al mismo tiempo estar demasiado lejos del libro de órdenes y, por lo tanto, no perder una transacción.
Cualquier intento fallido de abrir una posición indica un problema en el mercado, que podría deberse a liquidez insuficiente o volatilidad excesiva. En tales casos, no debería intentarlo de nuevo de inmediato; en lugar de eso, mantenga la calma.
Por lo tanto, se añadió una penalización a cada par comercial fallido.Enfriamiento de 10 minutos:
function addCooldown(deliverySymbol, coin, reason) {
pairCooldowns[deliverySymbol] = Date.now() + CONFIG.cooldownDuration;
Log(`⏸️ ${deliverySymbol} 进入10分钟冷却期`);
Log(` 原因: ${reason}`);
_G('pairCooldowns', pairCooldowns);
}
Durante el período de enfriamiento, no se abrirán posiciones para este par comercial para evitar fallas repetidas y desperdicio de tarifas de transacción.
Esta estrategia todavía es sólo un trabajo en progreso y hay muchas áreas que se pueden optimizar:
1. Problema de retraso Actualmente, los precios se obtienen mediante un método de sondeo, lo que genera una latencia significativa. Cambiar a WebSocket para las actualizaciones de precios en tiempo real mejoraría significativamente la velocidad de respuesta.
2. Optimización del control de riesgos El método actual de stop loss es relativamente simple y directo, y puede considerar:
3. Gestión de deslizamientos La estrategia de precios para órdenes limitadas se puede hacer más inteligente, por ejemplo, ajustándola dinámicamente en función de factores como la profundidad del libro de órdenes y el volumen de transacciones recientes.
4. Otras aplicaciones de los datos de profundidad Puede analizar el desequilibrio de la cartera de órdenes, predecir las tendencias de precios y mejorar la tasa de éxito del arbitraje.
Las estrategias de arbitraje suenan atractivas, pero en la práctica está claro que existen innumerables obstáculos entre el ideal y la realidad.
En particular, la cuestión de los datos de ticker rezagados es un problema en todo el proceso de desarrollo de la estrategia.Los más fáciles de pasar por alto, pero con el mayor impacto.Los inconvenientes. Para los mercados de contratos de entrega con baja liquidez:
Principio fundamental: utilizar Ticker para mantener la continuidad histórica y utilizar Profundidad para aprovechar oportunidades en tiempo real.
Este artículo registra los problemas encontrados y las soluciones durante el proceso de exploración y, con suerte, puede proporcionar alguna referencia para todos.Para reiterar, este artículo tiene fines exclusivamente educativos y de debate. El código aún está en desarrollo y no debe utilizarse directamente en operaciones en vivo.。
Si usas una estrategia similar, no dudes en conversar conmigo. El mercado es complejo, y es precisamente esta complejidad la que hace que el trading cuantitativo sea tan desafiante.
Código fuente de la estrategia: https://www.fmz.com/strategy/519280