Project Seldon

Project Seldon

5 December 2021, (10 months ago)

cryptofinance

How to set up your own passive market-cap-weighted crypto index fund.

History shows that passive investing according to market cap outperforms the vast majority of active professional investors (This may have changed with passive funds becoming so large that they distort markets - but that’s a matter for another time).

Since it’s better to buy the whole market rather than attempt to pick winners, it seems like a great idea to apply this idea to crypto, a rapidly growing yet volatile sector where there will continue to be massive winners and massive losers. By passive investing, we’re guaranteed to catch the overall wave, have upside from the big winners, and avoid over-exposure to many of the inevitable disasters.

However, I couldn’t find a single institutional way to do this. As of today, there are no passive crypto indexes that track the top assets by market cap.

I figured this problem presents a fun challenge, to build a simple monthly self-rebalancing portfolio that allocates itself across the top 25 crypto assets weighted according to market cap. I named the codebase “Project Seldon” because just like Issaac Asimov’s foundation, crypto presents a way to exit an old crumbling economic system that’s increasingly rotten to the core.

Overview

  • Step 1: Set up a Node.js app
  • Step 2: Set up a monthly recurring function to run the algorithm
  • Step 3: Fetch list of available crypto assets from CoinGecko
  • Step 4: Fetch market data for each asset from CoinGecko
  • Step 5: Calculate portfolio percentage allocations
  • Step 6: Set up ability to fetch your Binance spot account details
  • Step 7: Calculate what the new portfolio asset balances should be
  • Step 8: Create buy/sell orders for each asset
  • Step 9: Process the orders & Profit!

What you need

  • Knowledge of how to program in Javascript (I used Typescript but we’ll keep it as simple as possible here)
  • Knowledge of how to set up a cloud-hosted Node.js server.
  • A Binance account with some funds in your spot trading account.

Step 1.

Set up a basic node express app and deploy it to the cloud somewhere (AWS, Azure, GCP, Heroku, etc). There are plenty of free online resources on how to do this that are 1000x better than anything I can do here.

Step 2.

We’ll set up a function that runs exactly once a month.

Add the following to your root server.js (or index.js) file:

import cron from "node-cron"; // Easy task scheduling library

const app = express();
const PORT = process.env.PORT || 420;

try {
  app.listen(PORT, () => console.log('PORT-', PORT));
        
    // runs At 01:01 on day-of-month 10.  
    cron.schedule("1 1 1 * *", async () => {
        // this is where we'll write the monthly script
    }

} catch (e) {
  console.log(e);
}

Step 3.

Fetch a list of available crypto assets from the free CoinGecko API, remove all stablecoins and remove all assets not supported by Binance spot trading.

In a new file called data-fetcher.js

import axios from "axios"; // easy data fetching library

export const getAvailableAssets = async () => {
  try {
    let coinGeckoResponse = null;

        // fetch the asset list from CoinGecko (scroll down to see this function)
    rawAssetList = await getCoinGeckoResponse(); 
    if (!rawAssetList) {
      // try again
      await getCoinGeckoResponse();
    }

    const listToReturn = [];
        // Loop through the raw list of assets
    for (let assetIndex = 0; assetIndex < rawAssetList.data.length; assetIndex++) {
      const newListAsset = rawAssetList.data[assetIndex];
      console.log(assetIndex, newListAsset.name);

            // add a delay because the free-tier API is rate limited
      await new Promise((res) => setTimeout(res, 1200));

      try {
                // fetch detailed info for this asset.
        const coinInfoResponse = await axios({
          method: "get",
          url: `https://api.coingecko.com/api/v3/coins/${newListAsset.id}`,
        });

        // exclude stablecoins!
        if (coinInfoResponse.data.categories.includes("Stablecoins")) {
          console.log(`%cSkip, stablecoin`, "color: red");
          continue;
        }
      } catch (e) {
                // If error, rewind the loop iterator and try to fetch again
        console.log(`Error fetching ${newListAsset.name}`Ï);
        await new Promise((res) => setTimeout(res, 5000)); // wait
        assetIndex -= 1;
        continue;
      }

            // Ensure asset compatibility with Binance and get minimum trade amounts.
      let LOT_SIZE = 0;

      try {
                const symbol = newListAsset.symbol.toUpperCase();
        const binanceResponse = await axios({
          method: "get",
          url: `https://api.binance.com/api/v3/exchangeInfo?symbol=${symbol}BTC`,
        });

        if (!binanceResponse.data.symbols?.[0]?.isSpotTradingAllowed) {
          console.log(`Skip, not available for spot trading`);
          continue;
        }
        LOT_SIZE = Number(
          binanceResponse.data.symbols?.[0]?.filters.find(
            (a: any) => a.filterType === "LOT_SIZE"
          )?.stepSize || 0
        );
        if (!LOT_SIZE) {
          console.log(`Skip, no lot size`);
          continue;
        }
      } catch (e) {
        if (newListAsset.symbol.toUpperCase() !== "BTC") {
          console.log(`Skip, not supported by Binance`);
          continue;
        }
      }

      listToReturn.push([newListAsset.id, newListAsset.symbol, LOT_SIZE]);

            // limit to 50 assets.
      if (listToReturn.length === 50) {
        break;
      }
    }

    return listToReturn;
  } catch (e: any) {
    console.log(e);
    return [];
  }
};

const getCoinGeckoResponse = async () => {
  console.log("fetch assets from CoinGecko");
  try {
    const timeout = setTimeout(() => {
      // Timeout makes the request fail if it takes over 25 seconds
      throw new Error("Timeout");
    }, 25000);

    const response = await axios({
      method: "get",
      url: `https://api.coingecko.com/api/v3/coins?per_page=80`,
    });
    clearTimeout(timeout);
    return response;
  } catch (e) {
    console.log(e);
        // wait 5 seconds
    await new Promise((resolve) => setTimeout(resolve, 5000));
    return null;
  }
};

In your server.js file call the new getAvailableAssets function.

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
};

...

Step 4.

Get the latest market data for the list of assets you have created.

In your data-fetcher.js file, add a new function:

import dayjs from "dayjs"; // library for working with dates.
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);

export const getMarketInfo = async (assetList) => {
  console.log("getting market info");

    // Get yesterday's date.
  let loopDate = dayjs
    .utc()
    .hour(0)
    .minute(0)
    .second(0)
    .millisecond(0)
    .subtract(1, "days");
  console.log(loopDate.toISOString());
  const date = dayjs.utc(loopDate).format("YYYY-MM-DD");

  const newRecords = [];

  for (const asset of assetList) {
    console.log(asset[0], date);

    let historicData = null;
    try {
      // get data from coinGecko
      const coinGeckoResponse = await axios({
        method: "get",
        url: `https://api.coingecko.com/api/v3/coins/${asset[0]}/history?date=${dayjs
          .utc(loopDate)
          .format("DD-MM-YYYY")}&localization=false`,
      });

      historicData = coinGeckoResponse.data;
    } catch (e: any) {
      console.log(e);
      await new Promise((res) => setTimeout(res, 1200)); // wait
      continue;
    }

    if (historicData.market_data && historicData.market_data.current_price) {
      const recordToAdd = [asset[0], "MARKET_PRICE", date, Number(
        historicData.market_data.current_price.usd.toFixed(8)
      )];
      newRecords.push(recordToAdd);
    } else {
      const recordToAdd = [asset[0], "MARKET_PRICE", date, null];
      newRecords.push(recordToAdd);
    }

    if (historicData.market_data && historicData.market_data.market_cap) {
      const recordToAdd = [asset[0], "MARKET_CAP", date, Number(historic.market_data.market_cap.usd.toFixed(8));
      newRecords.push(recordToAdd)];
    } else {
      const recordToAdd = [asset[0], "MARKET_CAP", date, null];
      newRecords.push(recordToAdd);
    }

        // Add delay because the free tier API is rate limited
    await new Promise((res) => setTimeout(res, 1200));
  }

  newRecords.sort((a, b) => {
    const aDate = dayjs(a[2]).hour(0).minute(0).second(0).millisecond(0);
    const bDate = dayjs(b[2]).hour(0).minute(0).second(0).millisecond(0);
    return aDate.diff(bDate);
  });

  return newRecords;
};

In your server.js file, add the new getMarketInfo function.

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
    const records = wati getMarketInfo(assets);
};

...

Step 5.

Use the newly fetched market info to calculate what the updated portfolio balance between assets should be.

Create a new file called portfolio-services.js and enter the following function:

export const calculatePortfolioBalancing = (assets, records) => {
    const portfolioBalancing = [];
    for (const asset of assets) {
      const capRecord = records.find(
        (r) => r[0] === asset[0] && r[1] === ("MARKET_CAP")
      );
      const priceRecord = records.find(
        (r) => r[0] === asset[0] && r[1] === ("MARKET_PRICE")
      );

      if (capRecord && priceRecord) {
        portfolioBalancing.push({
          name: asset[0],
          value: capRecord[3] || 0,
          price: priceRecord[3] || 0,
        });
      }
    }

        // sort by market cap
    portfolioBalancing.sort((a, b) => {
      return b.value - a.value;
    });

        // limit to top 25 assets by market cap size.
    portfolioBalancing.splice(25);

    // normalize the values into fractions of 1
    let totalValue = 0;
    for (const holding of portfolioBalancing) totalValue += holding.value;
    for (const holding of portfolioBalancing) {
      holding.value = holding.value / totalValue;
    }

    return portfolioBalancing;
}

In your server.js file, add the new calculatePortfolioBalancing function.

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
    const records = await getMarketInfo(assets);
    const balancing = calculatePortfolioBalancing(assets, records);
};

...

Step 6.

Set up the ability to read your current spot account balances in Binance.

You will need to set up an API key for your Binance account that allows you to programmatically make spot trades. Make sure you limit the API key to spot trading ONLY and ensure you keep it as secure as possible via the use of an environment variables (.env) file.

In your portfolio-services.js file, add a new getAccount function:

export const getAccount = async () => {
  try {
    const timestamp = new Date().getTime();
    const response = await axios({
      method: "get",
      url: `https://api.binance.com/api/v3/account?timestamp=${timestamp}&signature=${sha256.hmac(
        process.env.SPOT_TRADING_API_SECRET as string,
        `timestamp=${timestamp}`
      )}`,
      headers: {
        "X-MBX-APIKEY": process.env.SPOT_TRADING_API_KEY as string,
      },
    });
    if (!response.data.canTrade) {
      throw new Error("Account cannot trade");
    }
    if (response.data.accountType !== "SPOT") {
      throw new Error("Account is not spot");
    }

    const activeBalances = response.data.balances.filter(
      (v) => Number(v.free) > 0
    );

        // Generate list of current holdings
    const currentHoldings = activeBalances.map((v) => ({
      symbol: v.asset,
      quantity: Number(v.free),
      priceInBTC: 0,
      valueInBTC: 0,
    }));

        // Populate the list of current holdings with their respective values
        // in BTC, as well as each one's current price in BTC
    for (const holding of currentHoldings) {
      if (holding.symbol === "BTC") {
        holding.priceInBTC = 1;
        holding.valueInBTC = holding.quantity;
      } else {
        const holdingResponse = await axios({
          method: "GET",
          url: `https://api.binance.com/api/v3/avgPrice?symbol=${holding.symbol}BTC`,
        });
        holding.priceInBTC = Number(holdingResponse.data.price);
        holding.valueInBTC = holding.quantity * holding.priceInBTC;
      }
    }

    return currentHoldings;
  } catch (e: any) {
    console.log({ e });
    return [];
  }
};

In your server.js file, add the new getAccount function and add a calculation to get the portfolio's current value in BTC.

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
    const records = await getMarketInfo(assets);
    const balancing = calculatePortfolioBalancing(assets, records);
    const currentHoldings = await getAccount();

    let totalInBTC = currentHoldings.reduce(
    (a, b) => a + b.quantity * b.priceInBTC,
    0
  );
    // Reduce by 1% to give margin of error for fees and fluctuations
  totalInBTC = totalInBTC * 0.99;

};

...

Step 7.

Calculate what your portfolio’s new allocation amounts should be.

In your portfolio-services.js file, add a function called calculateNewHoldings:

export const calculateNewHoldings = async (balancing, totalInBTC, assets) => {
  const newHoldings = [];

    // populate newHoldings items with name, symbol, lotSize
  for (const allocation of balancing) {
    const name = allocation.name;
    let symbol = null;
    let lotSize = 0;
    for (const b of assets) {
      if (name === b[0]) {
        symbol = b[1].toUpperCase();
        lotSize = b[2];
      }
    }
    if (!symbol) throw new Error(`Asset ${name} not found`);
    const valueInBTC = totalInBTC * allocation.value;

    newHoldings.push({
      name,
      symbol,
      valueInBTC,
      lotSize,
      quantity: 0,
      priceInBTC: 0,
    });
  }

    // populate newHoldings items with quantity and price in BTC
  for (const holding of newHoldings) {
    if (holding.symbol === "BTC") {
      holding.priceInBTC = 1;
      holding.quantity = holding.valueInBTC;
    } else {
      const holdingResponse = await axios({
        method: "get",
        url: `https://api.binance.com/api/v3/avgPrice?symbol=${holding.symbol}BTC`,
      });
      holding.priceInBTC = Number(holdingResponse.data.price);
      holding.quantity = holding.valueInBTC / holding.priceInBTC;
      holding.quantity =
        Math.floor(holding.quantity / holding.lotSize) * holding.lotSize;
    }
  }

  return newHoldings;
};

In your server.js file, add the new calculateNewHoldings function:

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
    const records = await getMarketInfo(assets);
    const balancing = calculatePortfolioBalancing(assets, records);
    const currentHoldings = await getAccount();

    // Get total portfolio value measured in BTC
    let totalInBTC = currentHoldings.reduce(
    (a, b) => a + b.quantity * b.priceInBTC,
    0
  );
    // Reduce by 1% to give margin of error for fees and fluctuations
  totalInBTC = totalInBTC * 0.99;

    const newHoldings = await calculateNewHoldings(balancing, totalInBTC, assets);
    
};

...

Step 8.

Calculate buy and sell orders.

In your portfolio-services.js file, add the following function:

export const createBuyAndSellOrders = async ({
  currentHoldings,
  assetsList,
  newHoldings,
} => {
    // sell positions in the current holdings that are not the new holdings.
  const positionsToSell = currentHoldings.filter(
    (a) => !newHoldings.find((b) => a.symbol === b.symbol)
  );
    // reduce positions that are larger in the current than the new holdings
  const positionsToReduce = currentHoldings.filter((a) =>
    newHoldings.find(
      (b) => a.symbol === b.symbol && a.valueInBTC > b.valueInBTC
    )
  );
    // increase positions that are larger in the new holdings than the current 
  const positionsToIncrease = currentHoldings.filter((a) =>
    newHoldings.find(
      (b) => a.symbol === b.symbol && a.valueInBTC < b.valueInBTC
    )
  );
    // buy positions that are in the new holdings but not currently owned
  const positionsToBuy = newHoldings.filter(
    (a) => !currentHoldings.find((b) => a.symbol === b.symbol)
  );

  const sellPositionOrders = [];
  const reducePositionOrders = [];
  const increasePositionOrders = [];
  const buyPositionOrders = [];

    // Create reduce position orders
  for (const position of positionsToReduce) {
    if (position.symbol === "BTC") continue;
    const newPosition = newHoldings.find((b) => b.symbol === position.symbol);
    if (!newPosition) throw new Error(`Asset ${position.symbol} not found`);
    const asset = assetsList.find(
      (a) => a[1].toUpperCase() === position.symbol
    );
    if (!asset) throw new Error(`Asset ${position.symbol} not found`);

    const quantityToSell =
      Math.floor((position.quantity - newPosition.quantity) / asset[2]) *
      asset[2];
    reducePositionOrders.push({
      symbol: `${position.symbol}BTC`,
      side: "SELL",
      type: "MARKET",
      quantity: quantityToSell.toFixed(8),
    });
  }

    // Create sell position orders
  for (const position of positionsToSell) {
    if (position.symbol === "BTC") continue;
    const asset = assetsList.find(
      (a) => a[1].toUpperCase() === position.symbol
    );
    if (!asset) throw new Error(`Asset ${position.symbol} not found`);
    const quantityToSell = Math.floor(position.quantity / asset[2]) * asset[2];

    sellPositionOrders.push({
      symbol: `${position.symbol}BTC`,
      side: "SELL",
      type: "MARKET",
      quantity: quantityToSell.toFixed(8),
    });
  }

    // Create increase position orders
  for (const position of positionsToIncrease) {
    if (position.symbol === "BTC") continue;
    const newPosition = newHoldings.find((b) => b.symbol === position.symbol);
    if (!newPosition) throw new Error(`Asset ${position.symbol} not found`);
    const asset = assetsList.find(
      (a) => a[1].toUpperCase() === position.symbol
    );
    if (!asset) throw new Error(`Asset ${position.symbol} not found`);

    const quantityToBuy =
      Math.floor((newPosition.quantity - position.quantity) / asset[2]) *
      asset[2];
    increasePositionOrders.push({
      symbol: `${position.symbol}BTC`,
      side: "BUY",
      type: "MARKET",
      quantity: quantityToBuy.toFixed(8),
    });
  }

    // Create buy position orders
  for (const position of positionsToBuy) {
    if (position.symbol === "BTC") continue;
    const newPosition = newHoldings.find((b) => b.symbol === position.symbol);
    if (!newPosition) throw new Error(`Asset ${position.symbol} not found`);
    const asset = assetsList.find(
      (a) => a[1].toUpperCase() === position.symbol
    );
    if (!asset) throw new Error(`Asset ${position.symbol} not found`);
    const quantityToBuy = Math.floor(position.quantity / asset[2]) * asset[2];

    buyPositionOrders.push({
      symbol: `${position.symbol}BTC`,
      side: "BUY",
      type: "MARKET",
      quantity: quantityToBuy.toFixed(8),
    });
  }

  return {
        sellPositionOrders,
        reducePositionOrders,
        increasePositionOrders,
        buyPositionOrders,
    }
};

In your server.js file, add the new createBuyAndSellOrders function.

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
    const records = await getMarketInfo(assets);
    const balancing = calculatePortfolioBalancing(assets, records);
    const currentHoldings = await getAccount();

    // Get total portfolio value measured in BTC
    let totalInBTC = currentHoldings.reduce(
    (a, b) => a + b.quantity * b.priceInBTC,
    0
  );
    // Reduce by 1% to give margin of error for fees and fluctuations
  totalInBTC = totalInBTC * 0.99;

    const newHoldings = await calculateNewHoldings(balancing, totalInBTC, assets);
    
    const {
        sellPositionOrders,
        reducePositionOrders,
        increasePositionOrders,
        buyPositionOrders,
    } = createBuyAndSellOrders(currentHoldings, assets, newHoldings);
    
    
};

...

Step 9.

Process the orders

In your portfolio-services.js file, add the following function:

export const processOrders = async (orders) => {
  console.log("Processing orders");
  for (const { symbol, side, type, quantity } of orders) {
    try {
      const requestBody = {
        symbol,
        side,
        type,
        quantity,
        recvWindow: 15000,
        timestamp: new Date().getTime(),
      };

      const orderResponse = await axios({
        method: "POST",
        url: `https://api.binance.com/api/v3/order?${convertObjectToURLParams(
          requestBody
        )}&signature=${sha256.hmac(
          process.env.SPOT_TRADING_API_SECRET,
          convertObjectToURLParams(requestBody)
        )}`,
        timeout: 60000,
        headers: {
          "X-MBX-APIKEY": process.env.SPOT_TRADING_API_KEY,
        },
      });
            
      console.log({ orderResponse });
    } catch (e: any) {
      console.log({ e });
    }
  }
  return;
};

// helper function
const convertObjectToURLParams = (obj: {
  [key: string]: string | number | boolean;
}): string => {
  var str: string = Object.keys(obj)
    .map(function (key) {
      return key + "=" + encodeURIComponent(obj[key]);
    })
    .join("&");
  return str;
};

In your server.js file, process the orders:

...

cron.schedule("1 1 1 * *", async () => {
    const assets = await getAvailableAssets();
    const records = await getMarketInfo(assets);
    const balancing = calculatePortfolioBalancing(assets, records);
    const currentHoldings = await getAccount();

    // Get total portfolio value measured in BTC
    let totalInBTC = currentHoldings.reduce(
    (a, b) => a + b.quantity * b.priceInBTC,
    0
  );
    // Reduce by 1% to give margin of error for fees and fluctuations
  totalInBTC = totalInBTC * 0.99;

    const newHoldings = await calculateNewHoldings(balancing, totalInBTC, assets);
    
    const {
        sellPositionOrders,
        reducePositionOrders,
        increasePositionOrders,
        buyPositionOrders,
    } = createBuyAndSellOrders(currentHoldings, assets, newHoldings);
    
    await processOrders(reducePositionOrders);
    await processOrders(sellPositionOrders);
  await processOrders(increasePositionOrders);
    await processOrders(buyPositionOrders);

    // fetch account again to check that it worked
    const updatedHoldings = await getAccount();
    console.log(updatedHoldings);
};

...

That’s it, now you can run your own crypto index fund, I hope you found this interesting!

An optional extra feature would be to set up an automated email alert that lets you know when your portfolio has been rebalanced (I have this set up for myself and it’s nice to see what it’s doing every month).