本文章主要介绍,如何用js编写一个简单的线下套利机器人,在去中心化交易所进行无风险盈利。本自动化程序无生产环境试验,仅用于研究,读者风险自担。
去中心化交易所
项目中使用了两个去中心化交易所:UniSwap、OneSwap;借助uniSwap提供的flashSwap(闪电贷)的功能,在不同的去中心化交易所中进行无损套利。
- flashSwap(闪电贷):简单说,用户在一次合约调用中,从uniswap的交易对资金池中借出某种token,调用提前部署好的套利合约,与别的去中心化交易所的资金池进行无风险套利,完后,归还相应价值的借贷资产。在这种无风险套利中,用户只需要支付以太坊上的交易手续费,并且不需要事先持有任何的交易对中的token。
uniswap 与 oneswap套利合约示例:https://github.com/oneswap/uniswap_oneswap_arbitrage/blob/master/contracts/FlashSwap.sol
由于不同的去中心化交易所的交易引擎算法不同,所以需要针对于不同的交易所,编写特定的套利合约。
程序编写
本文描述的机器人是一个简单的MVP(Minimum Viable Product)版本,主要包含下列几种功能:
- 套利资金池的配置
- 查询不同交易所上资金池的数据
- 计算资金池在不同交易所中的价格
- 计算填平资金池之间的价格差距,需要从uniswap中借出的token数量
- 计算需要还给uniswap的token数量,计算可以从另一个交易所中得到的token数量
- 计算是否有套利空间
- 发送套利交易
配置套利资金池
在类uniswap的去中心化交易所中,每个资金池都由一个交易对组成(即:包含两种token),所以第一步,配置准备套利的资金池。因为不同的交易所中组成交易对合约的两种token排序算法不同,所以在这步,需要手动设置两个交易token的顺序(以方便下步的价格计算)
示例代码如下:
function initPairs(){
pairs = new Map();
// note: 此处value为的tokens地址,需要与uniswap, oneswap上组成pair的地址顺序相同
// eth/usdt; uniSwapPairAddr; oneSwapPairAddr
pairs.set("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852;0xD5c97DaA0bfF751e4282BbC5AC8D008738881224;ETH/USDT",
["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0xdAC17F958D2ee523a2206206994597C13D831ec7"]);
....
}
pair:为Map类型,key 为string类型,value 为数组类型
- key: 为两个交易所中资金池的地址,交易对名称,由
;分隔;格式uniSwapPairAddr;oneSwapPairAddr;PairSymbol - value: 为两个交易所中组成资金池的token,按顺序放置;格式
[uniswapToken0,uniswapToken1,oneswapStock,oneswapMoney]
uniswap中组成交易对的token排序规则:地址小的排在前面。 oneswap中组成交易对的token排序规则:stock定位的token排在前面,money定位的token排在后面;
当需要进行别的交易对套利时,只需要继续增加配置即可。
查询资金池的质押资金
在类uniswap的去中心交易所中,每个资金池都有两种对应的token质押金额,用来为交易者提供流动性;更详细的信息参见:https://uniswap.org/docs/v2/core-concepts/pools/;
此处查询两个交易对合约中的质押资金,示例代码如下:
async function queryUniSwapReserve(pairAddr){
console.log("uniswap pair : " + pairAddr);
let pairContract = await uniswapPairContract.at(pairAddr);
let reserves = await pairContract.getReserves();
return [reserves[0], reserves[1]]
}
async function queryOneSwapReserve(pairAddr){
console.log("oneswap pair : " + pairAddr);
let pairContract = await oneswapPairContract.at(pairAddr);
let reserves = await pairContract.getReserves();
return [reserves[0], reserves[1]]
}
注意:此处查询的质押金额的与第一步资金池中配置的token顺序相同。
即uniswap的质押金额:索引0为 token0的质押资金,索引1为 token1的质押资金;
oneswap的质押金额:索引0为 stock的质押资金,索引1为 money的质押资金;
计算两个资金池的当前价格
价格计算公式:price = money / stock;
统一质押金额顺序
计算价格时的第一步,是对两个资金池的质押金额顺序进行统一;此处是依据oneswap的顺序为准;
代码示例如下
function resortUniReserves(tokens, uniReserves){
if (tokens[0] === tokens[2]){ // uniToken0 == stock
return uniReserves
}else if (tokens[0] === tokens[3]){ uniToken0 == money
return [uniReserves[1], uniReserves[0]]
}
}
将uniswap的质押金额的顺序,与oneswap的顺序调整一致;此时返回的金额数组,索引0 为stock token的金额,索引1 为money token的金额。
计算价格
依据公式:price = moneyTokenAmount / stockTokenAmount
示例代码如下
function calPrice(reserves){
return reserves[1] / reserves[0]
}
分别计算出 oneswap、uniswap资金池中交易对的价格
计算填平价格差距时所需的资金数量
依据计算出的价格,计算填平两个资金池价格差距时所需要资金数量。
此处主要有三种情况:
- 价格相同,或者相差不大(可以自己设置价格滑点),表示无套利机会,直接退出。
- uniSwapPrice > oneSwapPrice,则从uniSwap借出money token,在oneSwap市场中,购买stock token,将价格拉升至与uniSwap相同,计算需要借出的数量
- uniSwapPrice < oneSwapPrice,则从uniSwap借出stock token,在oneSwap市场中,卖出stock token,将价格拉低至与uniSwap相同,计算需要借出的数量
注意:虽然oneSwap市场中,添加了订单簿的功能,但是在计算套利空间时,加上订单这部分计算,会导致数据查询、计算过程比较复杂;所以此处只考虑
AMM的质押金额;但是这种考虑方式,也可以兼容订单簿的数据;因为每次置换时,如果碰巧oneSwap有订单簿可以成交,会导致最后将资金池的价格无法拉升/降低至与uniSwap价格相同,导致下次查询时,还存在套利空间,可以继续进行套利。弊端在于,可能将一笔交易完成的套利,切分成多笔;优点在于,线下计算、数据查询复杂度降低。
示例代码如下
function tillUniSwapPriceNeededAmount(uniReserves, oneReserves, tokens){
let uniPrice = calPrice(uniReserves)
let onePrice = calPrice(oneReserves)
let amount;
let borrowToken;
// 借入money,去套利stock,把oneswap市场的价格拉升
if (uniPrice > onePrice){
if (uniPrice < onePrice * 1.03){ return {amount: -2} }
uniPrice = onePrice * 1.03
amount = Big(oneReserves[0]).times(Big(oneReserves[1])).times(Big(uniPrice)).sqrt().minus(Big(oneReserves[1]))
borrowToken = tokens[3] // 借出money
}
......
console.log("calculate amount end: ", amount.toString())
return {
amount:amount,
borrowToken: borrowToken,
}
}
上述函数的返回值为{amount(Big), borrowToken(string)};
- amount:从uniSwap借出token的数量
- borrowToken: 借出token的地址
计算中出现两个硬编码的值:1.03,为随意设置,未经过实际数据测试;
- 1.03: 表示只套利当前OneSwap交易对价格的3%
uniPrice = onePrice * 1.03
设置该数值的原因在于,两个去中心化交易所的资金池质押数量相差悬殊,相同的价格区间,在不同资金池的价格曲线上,滑点会有很大差异;质押数量越小的资金池,滑点越大,会导致当填平所有价格差距时,付出大于回报,拉低收益率。
在计算时,填平价格区间时涉及两个公式的推导过程:
-
借出money token,买stock token,拉升oneSwap资金池价格
;
所以:
则:
-
借出stock token,卖stock token,拉低oneSwap资金池价格
与上述推到类似,此处直接写结果:
计算还给uniSwap的token数量和从oneSwap得到的token数量
接下来计算需要还给uniSwap的token数量。
注意:假设从uniSwap借的是A token,那么这里还回去的就是等值的B token;
第二步计算,向oneSwap输入A token数量时,获得的B token数量.
示例代码如下:
unction calReceivedAndRequiredAmount(amountAndBorrowToken, uniReserves, oneReserves, tokens){
let uniRequired;
let oneSwapReceived;
// 第一步:计算需要还给uniSwap的token数量
if (amountAndBorrowToken.borrowToken === tokens[2]){ uniRequired = getAmountInUniSwap(amountAndInputToken.amount, uniReserves[1], uniReserves[0]) }
else{ uniRequired = getAmountInUniSwap(amountAndInputToken.amount, uniReserves[0], uniReserves[1]) }
// 第二步:计算从oneSwap获得的token数量
if (amountAndInputToken.borrowToken === tokens[2]){ oneSwapReceived = getAmountOutOneSwap(amountAndInputToken.amount, oneReserves[1], oneReserves[0]) }
else{ oneSwapReceived = getAmountOutOneSwap(amountAndInputToken.amount, oneReserves[0], oneReserves[1]) }
console.log("calculate profit, oneSwapReceived: ", oneSwapReceived.toString(),"; uniRequired: ", uniRequired.toString())
return {uniRequired: uniRequired, oneSwapReceived: oneSwapReceived}
}
该函数的返回值为 两个资金池得到/需求的数量;
- 索引0: uniSwap资金池需要的数量
- 索引1: oneSwap资金池获得的数量
该函数涉及两个公式,推导过程如下:
-
需要还给uniSwap的token数量
所以:
则:
因为有0.3%的手续费,所以最后的公式要添加上手续费的计算:
-
可以从oneSwap获得token数量
所以:
则:
因为有0.5%的手续费,所以最后的公式要添加上手续费的计算: {\Delta s} =
计算是否有套利空间
依据上步计算出的两个市场的数量,计算可以盈利的数量;同时,在这里需要考虑两个方面:
- 盈利的token是否可以覆盖 这笔交易的手续费
- 因为此处的盈利为链下计算,交易上链可能需要一段时间(尤其是针对以太坊的拥堵情况)
所以,需要此处只有盈利极大的情况下(比如:100 usdt),才可以发送套利交易。
这里的100 美金为综合这段时间以太坊的手续费考虑 + 交易上链的时间(因为这段时间有可能链上还有用户在交易,抹平价格差)。
示例代码如下
// 获利太小的情况下,退出
if (oneSwapReceived < uniRequired.times(slippage)){
console.log("oneSwapReceived: ", oneSwapReceived.toString(), "; uniRequired * slippage : ", uniRequired.times(slippage).toString())
return {input: -1}
}
此处,并没有引入获利金额,而是用了一个盈利滑点控制。需要后续进行优化,引入盈利金额进行控制。因为一般情况下,每个币种一天的单价不会变化太大;所以,可以在配置里,写入当天币种的单价,来计算盈利。
发送套利交易
在上述所有计算之后,如果出现套利机会,就可以发送套利交易,调用uniSwap的flash swap(闪电贷)进行获利。
示例代码如下:
async function sendTx(tokenAndAmount, uniSwapPairAddr, tokens){
let bytes = web3.eth.abi.encodeParameters(['bool','bool'],[tokenAndAmount.uniSwapToken0IsStock, false]);
let contract = await uniswapPairContract.at(uniSwapPairAddr);
if (tokenAndAmount.inputToken === tokens[0]) {
await contract.swap(tokenAndAmount.amount, 0, arbitrageAddr, bytes);
}else if ((tokenAndAmount.inputToken === tokens[1]) ){
await contract.swap(0, tokenAndAmount.amount, arbitrageAddr, bytes);
}
}
上述函数有两个逻辑:
- 套利合约需要的参数:
- 套利合约需要两个参数:第一个为,uniSwap的token0 是否为 oneSwap的stock token,依据配置情况传入即可。第二个参数为:oneSwap的市场参数。
- 在oneSwap中,相同的交易对(如:ETH/USDT),可能存在两个市场,一个开启限价单功能,一个未开启限价单功能;false: 表式开启限价单功能。
- 调用uniSwap时,需要借出的金额
- 如果借出的token 为token0,则数量写在第一个参数,反之亦然。
组装机器人
通过将上述的功能组装起来,就可以得到一个可以运行的机器人。
示例代码如下:
async function loop(){
initPairs()
console.log("enter loop")
while (true) {
console.log("enter ...")
await work()
}
}
async function work(){
console.log("work ...: ")
for (let pair of pairs) {
let pairAddrs = spiltPairs(pair[0])
console.log("\n\n\ncheck pair: ", pairAddrs[2])
let uniReserves = await queryUniSwapReserve(pairAddrs[0]); // toke1, token2
let oneReserves = await queryOneSwapReserve(pairAddrs[1]); // stock, money
let tokenAndAmount = hasChanceToArbitrage(pair[1], uniReserves, oneReserves)
console.log("calculate profit amount: ", tokenAndAmount.profit, "; input token amount: ", tokenAndAmount.amount)
if (tokenAndAmount.amount > 0) {
await sendTx(tokenAndAmount, pairAddrs[0], pair[1])
}
}
}
总结
至此,一个套利机器人的MVP初版完成,大部分的核心功能都已具备。但是,在以太坊的去中心化交易所中套利时,除了套利程序这个基本的要素之外,还有一个必备的要素:与套利程序链接的以太坊节点,最好是直接链接矿池的节点,这样可以保证交易及时上链,保证套利交易的成功率。
套利的成功与否,作者认为取决于上述两点要素:套利程序的性能,链接的以太坊节点;二者缺一不可,套利程序可以保证捕捉到去中心化交易所中的套利机会;链接的节点,可以保证将捕捉到的机会真正兑现;下面的链接文章描述了在以太坊上套利时,节点的重要性。