Dollar Cost Averaging (DCA) in Coinbase Pro
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
.
{
"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.
{
"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
.
{
"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.
{
"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.
{
"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
.
{
"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:
{
"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.
{
"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.
# 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.
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.
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.
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!
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.
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.
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.
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.
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
.
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.
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.
{
"scripts": {
"build": "rm -rf ./build && tsc"
}
}
Then, we'll create a new purchase
script that will
- run the build
- set the Node environment to production
- buy some crypto 🚀
{
"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.
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:
- Check out your repository.
- Set up Node.js version 14.
- Install dependencies.
- Run the app.
- 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.
{
"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.