Calculate CryptoCurrency cross-exchange arbitrage in Java

CryptoCurrency cross-exchange arbitrage means buying or selling CryptoCurrency at separate exchanges to attempt to profit off the price differences for that currency at the exchanges.

Below I’ve pasted the Java code for a small project I undertook, which I later quickly abandoned. But I thought it might be useful to someone else, to give an idea how it could be done. The code is a hodge-podge and not very well put together. Take it for what it is, a quick experiment and proof of concept.

In summary, it looks at two CryptoCurrencies: Bitcoin and Ethereum. Across 3 exchanges. (Can easily be extended to do more currencies and exchanges). It gets the live books using the exchanges’ RESTful API (most provide these), and bases calculations off real asks and bids currently on the order books of each exchange. It then takes all combinations of buying and selling a crypto at one exchange vs another, and outputs what profits (or losses) could be had at the current moment. And that’s it.

It’s a nice tool for exploring what arbitrage opportunities may exist out there, and that’s all. It does not do any trading. Proceed with caution!

package cryparb;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.TimeZone;

import org.json.JSONArray;
import org.json.JSONObject;

@SuppressWarnings("serial")
public class Calculate 
{
	public static final float INITIAL_TRADE_AMOUNT_ETH = 50f;
	public static final float INITIAL_TRADE_AMOUNT_BTC = 10f;
	
	public static final int SLEEP_TO_SIMULATE_CRYPTOCOIN_TRANSFER_DELAYS_IN_MINS_BTC = 30;
	public static final int SLEEP_TO_SIMULATE_CRYPTOCOIN_TRANSFER_DELAYS_IN_MINS_ETH = 15;
	public static final int WEBSERVICE_GET_RETRY_LIMIT = 3;
	public static final int WEBSERVICE_GET_RETRY_AFTER_SECS = 15;
	public static final int RETAIN_BOOKS_CACHE_SECS = 30;
	
	public static final String QUADRIGACX = "quadrigacx";
	public static final String GEMINI = "gemini";
	public static final String GATECOIN = "gatecoin";
	
	public static final LinkedHashMap<String,Exchange> exchanges = new LinkedHashMap<String,Exchange>()
	{{
		put(QUADRIGACX, 
				new Exchange(
						QUADRIGACX, 
						QUADRIGACX_BOOKS_URL, 
						QUADRIGACX_TRADE_FEE_PERCENT, 
						QUADRIGACX_FIXED_WITHDRAWAL_FEE_BTC,
						QUADRIGACX_FIXED_WITHDRAWAL_FEE_ETH,
						QUADRIGACX_BIDS_KEY,
						QUADRIGACX_ASKS_KEY,
						QUADRIGACX_AMOUNT_KEY,
						QUADRIGACX_RATE_KEY
					)
			);
		put(GEMINI, 
				new Exchange(
						GEMINI, 
						GEMINI_BOOKS_URL, 
						GEMINI_TRADE_FEE_PERCENT, 
						GEMINI_FIXED_WITHDRAWAL_FEE_BTC,
						GEMINI_FIXED_WITHDRAWAL_FEE_ETH,
						GEMINI_BIDS_KEY,
						GEMINI_ASKS_KEY,
						GEMINI_AMOUNT_KEY,
						GEMINI_RATE_KEY
					)
			);
		put(GATECOIN, 
				new Exchange(
						GATECOIN, 
						GATECOIN_BOOKS_URL, 
						GATECOIN_TRADE_FEE_PERCENT, 
						GATECOIN_FIXED_WITHDRAWAL_FEE_BTC,
						GATECOIN_FIXED_WITHDRAWAL_FEE_ETH,
						GATECOIN_BIDS_KEY,
						GATECOIN_ASKS_KEY,
						GATECOIN_AMOUNT_KEY,
						GATECOIN_RATE_KEY
					)
			);
	}};
	
	public static final HashMap<String,JSONObject> booksCache = new HashMap<String,JSONObject>();	

	public static final String QUADRIGACX_BOOKS_URL = "https://api.quadrigacx.com/public/orders?book=eth_btc";
	public static final float QUADRIGACX_TRADE_FEE_PERCENT = 0.2f;
	public static final float QUADRIGACX_FIXED_WITHDRAWAL_FEE_BTC = 0f;
	public static final float QUADRIGACX_FIXED_WITHDRAWAL_FEE_ETH = 0f;
	public static final String QUADRIGACX_BIDS_KEY = "buy";
	public static final String QUADRIGACX_ASKS_KEY = "sell";
	public static final String QUADRIGACX_AMOUNT_KEY = "amount";
	public static final String QUADRIGACX_RATE_KEY = "rate";

	public static final String GEMINI_BOOKS_URL = "https://api.gemini.com/v1/book/ethbtc";
	public static final float GEMINI_TRADE_FEE_PERCENT = 0.25f;
	public static final float GEMINI_FIXED_WITHDRAWAL_FEE_BTC = 0f;
	public static final float GEMINI_FIXED_WITHDRAWAL_FEE_ETH = 0f;
	public static final String GEMINI_BIDS_KEY = "bids";
	public static final String GEMINI_ASKS_KEY = "asks";
	public static final String GEMINI_AMOUNT_KEY = "amount";
	public static final String GEMINI_RATE_KEY = "price";

	public static final String GATECOIN_BOOKS_URL = "https://api.gatecoin.com/Public/MarketDepth/ETHBTC";
	public static final float GATECOIN_TRADE_FEE_PERCENT = 0.35f;
	public static final float GATECOIN_FIXED_WITHDRAWAL_FEE_BTC = 0f;
	public static final float GATECOIN_FIXED_WITHDRAWAL_FEE_ETH = 0f;
	public static final String GATECOIN_BIDS_KEY = "bids";
	public static final String GATECOIN_ASKS_KEY = "asks";
	public static final String GATECOIN_AMOUNT_KEY = "volume";
	public static final String GATECOIN_RATE_KEY = "price";

	public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36";
	
    public static class Exchange
    {
    	public String name;
    	public String booksUrl;
    	public float tradeFeePercent;
    	public float fixedWithdrawalFeeBtc;
    	public float fixedWithdrawalFeeEth;
    	public String bidsKey;
    	public String asksKey;
    	public String amountKey;
    	public String rateKey;
    	
    	public Exchange(
	    	    	String name,
	    	    	String booksUrl,
	    	    	float tradeFeePercent,
	    	    	float fixedWithdrawalFeeBtc,
	    	    	float fixedWithdrawalFeeEth,
	    	    	String bidsKey,
	    	    	String asksKey,
	    	    	String amountKey,
	    	    	String rateKey
    			)
    	{
        	this.name = name;
        	this.booksUrl = booksUrl;
        	this.tradeFeePercent = tradeFeePercent;
        	this.fixedWithdrawalFeeBtc = fixedWithdrawalFeeBtc;
        	this.fixedWithdrawalFeeEth = fixedWithdrawalFeeEth;
        	this.bidsKey = bidsKey;
        	this.asksKey = asksKey;
        	this.amountKey = amountKey;
        	this.rateKey = rateKey;
    	}
    }
    
    public static class FullLoop 
    {
    	public String originExchange;
    	public String terminationExchange;
    	public String originCryptoCurrency;
    	public float originAmount;
    	public float profit;
    	public Date timestamp;
    	
    	public FullLoop(String originExchange, String terminationExchange, String originCryptoCurrency, float originAmount, float profit, Date timestamp)
    	{
    		this.originExchange = originExchange;
        	this.terminationExchange = terminationExchange;
        	this.originCryptoCurrency = originCryptoCurrency;
        	this.originAmount = originAmount;
    		this.profit = profit;
    		this.timestamp = timestamp;
    	}
    }

    public static String getUrlAsString(String urlString) throws Exception 
    {
        StringBuilder response = new StringBuilder();
        int retry = 0;
        
        while(true)
        {
            try
        	{
                URL url = new URL(urlString);
                HttpURLConnection httpUrlConnection = (HttpURLConnection) url.openConnection();
        		httpUrlConnection.setRequestMethod("GET");
        		httpUrlConnection.setDoInput(true);
        		httpUrlConnection.setDoOutput(false);
        		httpUrlConnection.setInstanceFollowRedirects(false);
        		httpUrlConnection.setConnectTimeout(60000);
        		httpUrlConnection.setReadTimeout(60000);
        		httpUrlConnection.setRequestProperty("User-Agent", USER_AGENT);
                BufferedReader in = new BufferedReader(
                                        new InputStreamReader(
                                        		httpUrlConnection.getInputStream()));
                String inputLine;
                while ((inputLine = in.readLine()) != null) 
                    response.append(inputLine);
                in.close();
                break;
        	}
        	catch(Exception e)
        	{
        		retry++;
        		if(retry==WEBSERVICE_GET_RETRY_LIMIT)
        			break;
        		System.out.println("Unable to fetch "+urlString+". Will sleep and try again.");
        		try { Thread.sleep(WEBSERVICE_GET_RETRY_AFTER_SECS*1000); } catch(Exception e1) { }
        	}
        }

        return response.toString();
    }

	public static float convertEthToBtcAtExchange(String exchangeName, float amountEthToConvertOriginal) throws Exception
	{
		float btcGotten = 0;
		Exchange exchange = exchanges.get(exchangeName);
		
		float amountEthToConvert = amountEthToConvertOriginal - (exchange.tradeFeePercent/100)*amountEthToConvertOriginal;
		
		System.out.println("---");
		System.out.println("Converting eth->btc on "+exchangeName+": "+amountEthToConvertOriginal+"eth, after fee ("+exchange.tradeFeePercent+"%) amount: "+amountEthToConvert+"eth");

		JSONObject exchangeBooks = booksCache.get(exchangeName);
		if(exchangeBooks==null || (exchangeBooks.has("timestamp") && new Date().getTime()-exchangeBooks.getLong("timestamp")>(RETAIN_BOOKS_CACHE_SECS*1000)))
		{
			System.out.println("Refreshing books");
			exchangeBooks = new JSONObject(getUrlAsString(exchange.booksUrl));
			exchangeBooks.put("timestamp", new Date().getTime());
			booksCache.put(exchangeName, exchangeBooks);
		}
		else
		{
			System.out.println("Using cached books");
		}
		
		JSONArray exchangeBids = exchangeBooks.getJSONArray(exchange.bidsKey);
		float ethRemaining = amountEthToConvert;
		for(int i=0; i<exchangeBids.length(); i++)
		{
			JSONObject thisBid = exchangeBids.getJSONObject(i);
			float amount = Float.parseFloat(thisBid.get(exchange.amountKey).toString());
			float rate = Float.parseFloat(thisBid.get(exchange.rateKey).toString());
			float amountEthSpentOnThisOrder = ethRemaining<amount?ethRemaining:amount;
			float amountBtcGottenFromThisOrder = amountEthSpentOnThisOrder*rate;
			btcGotten += amountBtcGottenFromThisOrder;
			ethRemaining -= amountEthSpentOnThisOrder;
			System.out.println("Amount: "+amount+". Rate: "+rate+". Eth remaining before this order: "+(ethRemaining+amountEthSpentOnThisOrder)+". Selling "+amountEthSpentOnThisOrder+"eth, amounting to "+amountBtcGottenFromThisOrder+"btc. Total btc gotten so far: "+btcGotten+". Eth remaining after order: "+ethRemaining);
			if(ethRemaining==0)
				break;
		}
		System.out.println("Total eth spent: "+amountEthToConvertOriginal+". Total btc gotten: "+btcGotten+".");

		//TODO apply btc withdrawl fee if applicable
		
		System.out.println("---");
		return btcGotten;
	}
	
	public static float convertBtcToEthAtExchange(String exchangeName, float amountBtcToConvertOriginal) throws Exception
	{
		float ethGotten = 0;
		Exchange exchange = exchanges.get(exchangeName);
		
		float amountBtcToConvert = amountBtcToConvertOriginal - (exchange.tradeFeePercent/100)*amountBtcToConvertOriginal;

		System.out.println("---");
		System.out.println("Converting btc->eth on "+exchangeName+": "+amountBtcToConvertOriginal+"btc, after fee ("+exchange.tradeFeePercent+"%) amount: "+amountBtcToConvert+"btc");
		
		JSONObject exchangeBooks = booksCache.get(exchangeName);
		if(exchangeBooks==null || (exchangeBooks.has("timestamp") && new Date().getTime()-exchangeBooks.getLong("timestamp")>15000))
		{
			System.out.println("Refreshing books");
			exchangeBooks = new JSONObject(getUrlAsString(exchange.booksUrl));
			exchangeBooks.put("timestamp", new Date().getTime());
			booksCache.put(exchangeName, exchangeBooks);
		}
		else
		{
			System.out.println("Using cached books");
		}

		JSONArray exchangeAsks = exchangeBooks.getJSONArray(exchange.asksKey);
		float btcRemaining = amountBtcToConvert;
		for(int i=0; i<exchangeAsks.length(); i++)
		{
			JSONObject thisAsk = exchangeAsks.getJSONObject(i);
			float amount = Float.parseFloat(thisAsk.get(exchange.amountKey).toString());
			float rate = Float.parseFloat(thisAsk.get(exchange.rateKey).toString());
			float amountBtcAvailableOnThisOrder = amount*rate;
			float amountBtcSpentToThisOrder = btcRemaining<amountBtcAvailableOnThisOrder?btcRemaining:amountBtcAvailableOnThisOrder;
			float amountEthGottenFromThisOrder = amountBtcSpentToThisOrder/rate;
			ethGotten += amountEthGottenFromThisOrder;
			btcRemaining -= amountBtcSpentToThisOrder;
			System.out.println("Amount: "+amount+". Rate: "+rate+". Btc available on this order: "+amountBtcAvailableOnThisOrder+". Btc remaining before this order: "+(btcRemaining+amountBtcSpentToThisOrder)+". Selling "+amountBtcSpentToThisOrder+"btc, amounting to "+amountEthGottenFromThisOrder+"eth. Total eth gotten so far: "+ethGotten+". Btc remaining after order: "+btcRemaining);
			if(btcRemaining==0)
				break;
		}
		System.out.println("Total btc spent: "+amountBtcToConvertOriginal+". Total eth gotten: "+ethGotten+".");

		//TODO apply eth withdrawl fee if applicable

		System.out.println("---");
		return ethGotten;
	}
	
	public static String printProfits(ArrayList<FullLoop> fullLoopList)
	{
		StringBuilder profitsStr = new StringBuilder();
		profitsStr.append("---\n");
		for(int i=0; i<fullLoopList.size(); i++)
		{
			FullLoop fullLoop = fullLoopList.get(i);
			profitsStr.append(fullLoop.timestamp+" - Profit if started with "+fullLoop.originAmount+fullLoop.originCryptoCurrency+" at origin exchange '"+fullLoop.originExchange+"' and ended at termination exchange '"+fullLoop.terminationExchange+"': "+fullLoop.profit+fullLoop.originCryptoCurrency+"\n");
		}
		profitsStr.append("---");
		return profitsStr.toString();
	}

	public static void main(String args[]) throws Exception
	{
		TimeZone.setDefault(TimeZone.getTimeZone("UTC")); 
		
		File logFile = new File("log.txt");
		ArrayList<String> exchangeNames = new ArrayList<String>(exchanges.keySet());
		LinkedHashMap<Date,ArrayList<FullLoop>> fullLoops = new LinkedHashMap<Date,ArrayList<FullLoop>>();
		
		while(true)
		{
			Date currentTimestamp = new Date();
			
			ArrayList<FullLoop> fullLoopList = new ArrayList<FullLoop>();
			fullLoops.put(currentTimestamp, fullLoopList);
			
			for(int i=0; i<exchangeNames.size(); i++)
			{
				for(int j=0; j<exchangeNames.size(); j++)
				{
					if(i==j)
						continue;
					
					String originExchangeName = exchangeNames.get(i);
					String terminationExchangeName = exchangeNames.get(j);
					
					System.out.println("Origin '"+originExchangeName+"'. Termination '"+terminationExchangeName+"'");
					
					float btcGottenOriginExchange = convertEthToBtcAtExchange(originExchangeName, INITIAL_TRADE_AMOUNT_ETH);
					float ethGottenOriginExchange = convertBtcToEthAtExchange(originExchangeName, INITIAL_TRADE_AMOUNT_BTC);
					
					float ethRevertedTerminationExchange = convertBtcToEthAtExchange(terminationExchangeName, btcGottenOriginExchange);
					float btcRevertedTerminationExchange = convertEthToBtcAtExchange(terminationExchangeName, ethGottenOriginExchange);
					
					float profitEth = ethRevertedTerminationExchange-INITIAL_TRADE_AMOUNT_ETH;
					float profitBtc = btcRevertedTerminationExchange-INITIAL_TRADE_AMOUNT_BTC;
					
					fullLoopList.add(new FullLoop(originExchangeName, terminationExchangeName, "eth", INITIAL_TRADE_AMOUNT_ETH, profitEth, currentTimestamp));
					fullLoopList.add(new FullLoop(originExchangeName, terminationExchangeName, "btc", INITIAL_TRADE_AMOUNT_BTC, profitBtc, currentTimestamp));
				}
			}

			Collections.sort(fullLoopList, new Comparator<FullLoop>() {
			    @Override
			    public int compare(FullLoop o1, FullLoop o2) {
			        return new Float(o2.profit).compareTo(o1.profit);
			    }
			});
			
			String profitsStr = printProfits(fullLoopList);
			System.out.println(profitsStr);
			
			Files.write(logFile.toPath(), (profitsStr+"\n").getBytes(), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
			
			Thread.sleep(30000);
		}
		
	}
}