skies.dev

Dollar Cost Averaging (DCA) in Coinbase Pro

13 min read

We're going to build a tool that uses real money to buy crypto. This is not investment advice. The software presented may have bugs and is offered "as is" without warranty. Use this software at your own risk.

One nice thing about using Coinbase Pro over Coinbase is lower fees.

Unfortunately Coinbase Pro doesn't have a feature to buy crypto on a schedule.

However, they do expose an API so we can build our own solution enabling us to do dollar cost averaging (DCA).

We'll build our DCA solution with Node.js, TypeScript, and GitHub Actions.

We'll use Node.js to write a script to issue market buy requests and GitHub Actions to run the script on a schedule.

Source code for this project is on GitHub.

Project Setup

First things first, let's create a new project with a package.json.

mkdir coinbase-pro-dca
cd coinbase-pro-dca
npm init -y

We're going to use ES modules in this project. Add a type property in your package.json.

package.json
{
  "type": "module"
}

Configuring TypeScript

Install TypeScript.

npm install --save-dev @types/node ts-node typescript

Create a TypeScript config file.

touch tsconfig.json

Asdd the following configuration.

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "lib": ["ESNext", "DOM"],
    "baseUrl": "./src",
    "paths": {
      "*": ["./*"]
    },
    "allowJs": true,
    "outDir": "build",
    "rootDir": "src",
    "strict": true,
    "noImplicitAny": false,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "types": ["node"]
  }
}

Setting Up Formatting & Linting

Now let's set up our formatter and linter to help us write clean code.

npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-config-standard eslint-plugin-import eslint-plugin-node eslint-plugin-prettier prettier

First let's add our Prettier config to package.json.

package.json
{
  "prettier": {
    "semi": true,
    "trailingComma": "all",
    "singleQuote": true,
    "printWidth": 80,
    "bracketSpacing": false
  }
}

Next we'll set up ESLint.

From the project root, run the following in the terminal.

touch .eslintrc

Now let's add our ESLint config to the newly created file.

.eslintrc
{
  "env": {
    "browser": false,
    "node": true,
    "es2021": true
  },
  "extends": [
    "standard",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint", "prettier"],
  "rules": {}
}

Finally, let's add some scripts to run formatting and linting.

package.json
{
  "scripts": {
    "lint": "eslint . --ext .ts --ignore-path .gitignore --cache --fix",
    "format": "prettier 'src/**/*.ts' --write --ignore-path .gitignore"
  }
}

Setup Pre-Commit Hook to Run Prettier and ESLint

We're going use husky and lint-staged to ensure the code we check in is formatted and linted.

npx mrm lint-staged

Once installed, add the following to your package.json.

package.json
{
  "lint-staged": {
    "*.ts": ["npm run lint", "npm run format"]
  }
}

Setup Up Hot Reloading in Development

We'll use nodemon so that the script hot reloads during development.

npm install --save-dev nodemon

Create a nodemon.json at the project root.

touch nodemon.json

Add the following:

nodemon.json
{
  "watch": ["src"],
  "ext": "*.ts,*.js",
  "ignore": [],
  "exec": "NODE_ENV=development node --es-module-specifier-resolution=node --loader ts-node/esm src/index.ts"
}

We'll add a script to run our app in development.

package.json
{
  "scripts": {
    "dev": "nodemon"
  }
}

Now let's test the dev script by creating the entry point to the app.

mkdir src
echo "console.log('hello world');" >> src/index.ts

You should see "hello world" printed when you run npm run dev.

Setting Up Git and GitHub Repository

We're going to use GitHub Actions to run the cron job so we need to set up Git.

Go to GitHub and create a new repository. You can use the default settings here.

GitHub will give you some commands to run to initialize the project with Git. From your terminal, run

echo "# coinbase-pro-dca" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main

# update this with the repo you created
git remote add origin git@github.com:<USERNAME>/coinbase-pro-dca.git

git push -u origin main

Since we're dealing with real money, it's important we set up a .gitignore to hide our API keys.

touch .gitignore

Let's update our .gitignore with the following.

node_modules
build
.env.production
.env.development
.env
.eslintcache

Great, we can push everything we have so far to remote.

git add .
git commit -m "initializes project"
git push

Setting up Coinbase Pro API

Let's create a file to store environment variables for testing.

touch .env.development .env.production

Log into Coinbase Pro and go to your sandbox API settings.

Create a new API key with Trade permissions, then add the API credentials to .env.development.

It will look something like this.

.env.development
# coinbase pro
PASSPHRASE=jtorwpvcyj
API_KEY=acbeb681466624314157037ee81c87d3
API_SECRET=kmKTVC7kgWF/BFjtdG3WYp7CjV4Pc8rxVy5lbMZqmoiYBUrilovKKTopYSTzTPSXwkYKC/neyFdbLy/TABfXmg==

Now we're ready to start the fun part of buying some crypto with Node.js!

Implementing a Node.js Script to Buy Crypto on Coinbase Pro

Let's start by creating the files we'll write code in.

touch src/client.ts src/coin.config.ts src/env.ts src/purchase.ts src/util.ts

Defining App States

Let's define the various states of the app.

src/util.ts
export enum AppState {
  SUCCESS,
  INVALID_ENV,
  BUY_FAILURE,
}
  • SUCCESS means all the market buy orders were placed successfully.
  • INVALID_ENV means the environment variables weren't set correctly.
  • BUY_FAILURE means the purchase order failed for whatever reason.

Then we'll encapsulate a message with the app state to display the result of the app.

src/util.ts
export interface AppResult {
  state: AppState;
  message: string;
}

Defining a Kill Switch

If something goes wrong in the script execution, we want to kill the script immediately.

For this we'll define a panic function for cases where the script should not continue further.

src/util.ts
export function panic({state, message}: AppResult): void {
  console.error(`☠️ ${AppState[state]}: ${message}`);
  process.exit(state);
}

Initializing the Environment

We need to ensure the environment variables are properly configured. Otherwise, there's no way the app would work!

src/env.ts
import {AppResult, AppState, panic} from './util';

function validateEnvironment(): void {
  const invalidArgs = [
    'PASSPHRASE',
    'API_KEY',
    'API_SECRET',
    'NODE_ENV',
  ].filter((arg) => process.env[arg] == null);

  if (invalidArgs.length > 0) {
    const result: AppResult = {
      state: AppState.INVALID_ENV,
      message: `The following args were not supplied: ${invalidArgs}`,
    };
    panic(result);
  }
}

To pull environment variables from the .env.* files, we'll use dotenv.

npm install dotenv

We'll write the setupEnvironment function to set up and validate the environment in places where the environment variables are required (i.e. on client initialization).

import dotenv from 'dotenv';

export function setupEnvironment(): void {
  dotenv.config({slug: `.env.${process.env.NODE_ENV}`});
  validateEnvironment();
}

Initialize Coinbase Pro Client

We're going to use coinbase-pro-node to interact with the Coinbase Pro API since it offers TypeScript support.

We're also installing axios because coinbase-pro-node uses axios under the hood. We'll use axios to parse error responses.

npm install coinbase-pro-node axios

We'll initialize the REST client with the environment variables we set earlier.

src/client.ts
import {CoinbasePro} from 'coinbase-pro-node';
import {setupEnvironment} from './env';

setupEnvironment();

export const coinbaseClient = new CoinbasePro({
  useSandbox: process.env.NODE_ENV !== 'production',
  passphrase: process.env.PASSPHRASE as string,
  apiSecret: process.env.API_SECRET as string,
  apiKey: process.env.API_KEY as string,
}).rest;

Here we're passing the API credentials and telling the client to use the sandbox environment when not in production.

Configuring What Coins You're Going to Buy

Now we'll configure which coins you're going to buy.

Include whichever coins and allocation you want in your DCA strategy.

In this example below, the script would buy $10 worth of BTC.

The GitHub Action we set up later will run the script on a schedule you'll define.

src/coin.config.ts
export interface CoinbaseCurrency {
  funds: string;
  productId: string;
}

export const coins: CoinbaseCurrency[] = [
  {
    funds: '10.00',
    productId: 'BTC-USD',
  },
];

The product IDs are what you see in the Coinbase Pro app. The product ID to purchase ETH with USD is ETH-USD, BTC with USD is BTC-USD, and so forth.

You can alternatively query the Products API to get a list of available product IDs.

Executing a Market Buy Order

Now we're all set up to call Coinbase's API and make market buy orders.

The script will iterate over the list of coins you configured in the previous step.

For each successful market buy order, we'll return information about the order.

If the market buy order fails for any reason, we panic.

If the buy orders for each coin you configured are successful, we'll return order information for all the coins purchased.

Making the Script More Resilient with a sleep Function

During my own use of this script, I noticed the Coinbase Pro REST API was flaky when making the orders all at once (i.e. using Promise.all); the API would sometimes return an error response causing the app to panic.

In fact, Coinbase Pro API docs say

For Public Endpoints, our rate limit is 3 requests per second, up to 6 requests per second in bursts. For Private Endpoints, our rate limit is 5 requests per second, up to 10 requests per second in bursts.

To make our script more resilient, we'll introduce a sleep function to pause execution momentarily between each market buy order.

src/util.ts
export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Purchasing Cryptocurrency with the Coinbase Pro API

Now we have the pieces in place to buy the cryptocurrencies we configured.

Our marketBuy function attempts to place a market buy order for a given coin.

src/purchase
import {CoinbaseCurrency} from './coin.config';
import {AppResult, AppState, panic, sleep} from './util';
import {coinbaseClient} from './client';
import axios from 'axios';
import {OrderSide, OrderType} from 'coinbase-pro-node';

async function marketBuy(coin: CoinbaseCurrency) {
  try {
    const order = await coinbaseClient.order.placeOrder({
      type: OrderType.MARKET,
      side: OrderSide.BUY,
      funds: coin.funds,
      product_id: coin.productId,
    });
    await sleep(1000);
    return `✅ Order(${order.id}) - Purchased ${coin.funds} of ${order.product_id}`;
  } catch (err: unknown) {
    const message = axios.isAxiosError(err)
      ? err?.response?.data.message
      : err instanceof Error
        ? err.message
        : 'unknown error occurred';
    const data: AppResult = {
      state: AppState.BUY_FAILURE,
      message,
    };
    panic(data);
    // impossible to reach here
    // this is to satisfy the typescript compiler
    return message;
  }
}

Now we want to execute market buy orders for each coin that we configured in coin.config.ts.

src/purchase.ts
import { coins } from './coin.config';
import { AppResult, AppState } from './util';

export async function purchaseCrypto(): Promise<AppResult> {
  const orders: string[] = [];
  for (const coin of coins) {
    orders.push(await marketBuy(coin));
  }
  return {
    state: AppState.SUCCESS,
    message: orders.join('\n'),
  };
}

Finalizing Our App to Buy Crypto from Coinbase Pro

Now it's time to put it all together.

We'll revisit src/index.ts where we wrote our original "hello world" to create the entry point to our app.

src/index.ts
import {purchaseCrypto} from './purchase';

const {message} = await purchaseCrypto();
console.info(message);

Great work. Make sure to test the app with npm run dev.

You might see the app fails due to insufficient funds. You would need to deposit test money into Coinbase Pro's sandbox environment.

From Coinbase Pro's API documentation:

To add funds, use the web interface deposit and withdraw buttons as you would on the production web interface.

Once you feel comfortable the app is working as intended, let's set up the app for production.

Preparing for Production

Now we have all the code in place to issue our market buy orders, but up until this point we've only used the sandbox environment.

For this, log into the production Coinbase Pro app and set up API keys like we did earlier. Only this time, you will store the credentials in .env.production.

Since we're using TypeScript, we need to compile the TypeScript into JavaScript. We're compiling the TypeScript into a build folder.

package.json
{
  "scripts": {
    "build": "rm -rf ./build && tsc"
  }
}

Then, we'll create a new purchase script that will

  1. run the build
  2. set the Node environment to production
  3. buy some crypto 🚀
package.json
{
  "scripts": {
    "purchase": "npm run build && NODE_ENV=production node --es-module-specifier-resolution=node build/index.js"
  }
}

When you run npm run purchase you will use the production credentials you set in .env.production.

Assuming you had everything set up correctly, you'll use your real money to buy real crypto.

Using GitHub Actions to Schedule Recurring Market Buys on Coinbase Pro

We have the script that will execute the market buy orders of the coins we configured. But the script only executes the buy orders once.

To do dollar cost averaging, we want to execute this script on a schedule: Monthly, weekly, daily, etc.

We'll use GitHub Actions to create a cron job.

Upload Coinbase Pro API Secrets to GitHub

Since we aren't checking the environment variables into Git/GitHub, we need to upload the Coinbase Pro API keys to our GitHub repository.

Follow GitHub's guidance on creating encrypted secrets for a repository.

Once the secrets are in, we can access them from our GitHub Action.

Let's create our GitHub Action now.

touch cron.yml

We need to use a POSIX cron expression to set the cron schedule.

Check out crontab guru if you need help creating a cron expression.

The GitHub Action below is configured to buy every day at 12:00 UTC time.

Edit this to get a schedule you want.

cron.yml
name: 'dca crypto job'

on:
  schedule:
    - cron: '0 12 */1 * *' # At 12:00 UTC every day

jobs:
  buy-crypto:
    runs-on: ubuntu-latest
    env:
      API_KEY: ${{ secrets.API_KEY }}
      API_SECRET: ${{ secrets.API_SECRET }}
      PASSPHRASE: ${{ secrets.PASSPHRASE }}
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '14'
      - run: npm install
      - run: npm run purchase

If you're unfamiliar with GitHub Actions, hopefully it's clear what the steps are:

  1. Check out your repository.
  2. Set up Node.js version 14.
  3. Install dependencies.
  4. Run the app.
  5. Profit.

Once you have the cron job enabled, GitHub will run these steps every time the job is run.

How to Enable the GitHub Action

We wrote cron.yml in the project root, but to enable a GitHub Action, we need to move cron.yml to .github/workflows.

Here's some simple utility scripts to help enable (or disable) the cronjob. These scripts simply move the workflow between the workflows folder and the project root.

package.json
{
  "scripts": {
    "cron:enable": "mkdir -p .github/workflows; cp cron.yml .github/workflows/cron.yml",
    "cron:disable": "rm -rf .github"
  }
}

If you want the cron job enabled, run

npm run cron:enable

If you want the cron job disabled, run

npm run cron:disable

Wrapping Up

Once you're ready, you can push the changes to the remote repo.

git add .
git commit -m "setting up dca"
git push

Now you'll start buying into Bitcoin and Ethereum using the DCA strategy! 🚀

If the GitHub Action running the app fails due to insufficient funds or a bug, GitHub should send an email so that you can investigate.

I hope you found this information useful!

All the code we looked at is found on GitHub.

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like