Building an Automated Cryptocurrency Trading Bot with TypeScript and AWS

Building an Automated Cryptocurrency Trading Bot with TypeScript and AWS

December 27, 2024

15 min read

Building an Automated Cryptocurrency Trading Bot with TypeScript and AWS

In this article, we'll walk through building a simple cryptocurrency trader using TypeScript. To streamline the development process, we'll leverage RadzionKit—a TypeScript monorepo boilerplate—as our foundation.

Implementing an Automated Moving Average Crossover Strategy

We will implement an automated Moving Average Crossover Strategy, where the bot runs every 10 minutes and uses a short-term period of 20 and a long-term period of 50 to identify buy or sell signals based on trend changes in the market.

export const traderConfig = {
  shortTermPeriod: 20,
  longTermPeriod: 50,
}

Managing Trader State

The state of our trader will include an id for referencing the state in the database, prices to store the historical prices of the asset (up to 50, based on our configuration), asset to track the current asset being traded, and lastTrade to record the type of the most recent trade.

import { EntityWithId } from "@lib/utils/entities/EntityWithId"
import { TradeAsset } from "./TradeAsset"

export type TradeType = "buy" | "sell"

export type Trader = EntityWithId & {
  prices: number[]
  asset: TradeAsset
  lastTrade: TradeType
}

Minimizing Fees with Polygon

To minimize fees, we'll use Polygon—a Layer 2 scaling solution for Ethereum.

import { Address } from "viem"
import { polygon } from "viem/chains"

export const tradeAssets = ["weth", "usdc"] as const

export type TradeAsset = (typeof tradeAssets)[number]

export const tradeAssetAddress: Record<TradeAsset, Address> = {
  weth: "0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619",
  usdc: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
}

export const tradeAssetPriceProividerId: Record<TradeAsset, string> = {
  weth: "polygon:weth",
  usdc: "polygon:usdc",
}

export const tradeChain = polygon

export const cashAsset: TradeAsset = "usdc"

Storing Bot State in DynamoDB

We'll store state of our bot in a DynamoDB table. Using utilities from RadzionKit, we can quickly define the essential functions needed for basic CRUD operations.

import { getPickParams } from "@lib/dynamodb/getPickParams"
import { totalScan } from "@lib/dynamodb/totalScan"
import { Trader } from "../entities/Trader"
import { getEnvVar } from "../getEnvVar"
import { DeleteCommand, PutCommand } from "@aws-sdk/lib-dynamodb"
import { dbDocClient } from "@lib/dynamodb/client"
import { makeGetItem } from "@lib/dynamodb/makeGetItem"
import { updateItem } from "@lib/dynamodb/updateItem"

const tableName = getEnvVar("TRADERS_TABLE_NAME")

export const getTraderItemParams = (id: string) => ({
  TableName: tableName,
  Key: {
    id,
  },
})

export const getAllTraders = async <T extends (keyof Trader)[]>(
  attributes?: T,
) => {
  return totalScan<Pick<Trader, T[number]>>({
    TableName: tableName,
    ...getPickParams(attributes),
  })
}

export const getTrader = makeGetItem<string, Trader>({
  tableName,
  getKey: (id: string) => ({ id }),
})

export const deleteTrader = (id: string) => {
  const command = new DeleteCommand(getTraderItemParams(id))

  return dbDocClient.send(command)
}

export const deleteAllTraders = async () => {
  const alerts = await getAllTraders(["id"])

  return Promise.all(alerts.map(({ id }) => deleteTrader(id)))
}

export const putTrader = (item: Trader) => {
  const command = new PutCommand({
    TableName: tableName,
    Item: item,
  })

  return dbDocClient.send(command)
}

export const updateTrader = async (id: string, fields: Partial<Trader>) => {
  return updateItem({
    tableName,
    key: { id },
    fields,
  })
}

Ensuring Type-Safe Environment Variables

To ensure type-safe access to environment variables, we'll use the getEnvVar utility. This will act as the single source of truth for managing our application's environment variables.

type VariableName =
  | "TRADERS_TABLE_NAME"
  | "SENTRY_KEY"
  | "SECRETS"
  | "TELEGRAM_BOT_CHAT_ID"

export const getEnvVar = <T extends string>(name: VariableName): T => {
  const value = process.env[name]
  if (!value) {
    throw new Error(`Missing ${name} environment variable`)
  }

  return value as T
}

Populating the Database with Initial Data

We can populate the database with initial data using the setTraders script. Since this script is only for our use, it's safe to delete all existing traders before adding new ones.

import { deleteAllTraders, putTrader } from "../db/traders"
import { Trader } from "../entities/Trader"

const partialItems: Omit<Trader, "id">[] = [
  {
    prices: [],
    asset: "weth",
    lastTrade: "buy",
  },
]

const items: Trader[] = partialItems.map((value, index) => ({
  ...value,
  id: index.toString(),
}))

const setTraders = async () => {
  await deleteAllTraders()

  await Promise.all(items.map(putTrader))
}

setTraders()

Supporting Multiple Traders

Currently, our system supports only a single trader, as it trades the entire amount of the asset. However, it can be easily extended to support multiple traders handling different assets, such as wBTC.

Setting the Last Trade Field

Even though we haven’t made a trade yet, we still need to set the lastTrade field to align the bot's actions with the deposited asset. This should be buy if you deposited USDC into the account (so the bot can buy WETH on a signal), or sell if you deposited WETH (so the bot can sell it on a signal).

Setting Up Telegram Notifications

To keep track of our bot's activity, we’ll set up Telegram notifications for trades. Whenever the bot makes a trade, it will send a message detailing the action, the asset, and the price. This provides real-time updates, ensuring you’re always aware of the bot’s decisions without manually checking logs.

import { getEnvVar } from "../getEnvVar"
import TelegramBot from "node-telegram-bot-api"
import { getSecret } from "../getSercret"
import { TradeAsset } from "../entities/TradeAsset"
import { TradeType } from "../entities/Trader"
import { match } from "@lib/utils/match"

type Input = {
  asset: TradeAsset
  price: number
  tradeType: TradeType
}

export const sendTradeNotification = async ({
  asset,
  price,
  tradeType,
}: Input) => {
  const token = await getSecret("telegramBotToken")
  const bot = new TelegramBot(token)

  const action = match(tradeType, {
    buy: () => "Bought",
    sell: () => "Sold",
  })

  const message = `${action} ${asset} at price of ${price}`

  return bot.sendMessage(getEnvVar("TELEGRAM_BOT_CHAT_ID"), message)
}

Enhancing Security with AWS Secrets Manager

To improve security, we’ll store sensitive information, such as the Telegram bot token, in AWS Secrets Manager. This approach centralizes all secrets required by our service. To optimize performance, we’ll memoize the getSecrets function to cache the response, reducing the need for repeated requests. The JSON response is then parsed, and we assert the specific field we need.

import { getEnvVar } from "./getEnvVar"
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager"
import { memoizeAsync } from "@lib/utils/memoizeAsync"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { assertField } from "@lib/utils/record/assertField"

type SecretName = "accountPrivateKey" | "zeroXApiKey" | "telegramBotToken"

const getSecrets = memoizeAsync(async () => {
  const client = new SecretsManagerClient({})
  const command = new GetSecretValueCommand({ SecretId: getEnvVar("SECRETS") })
  const { SecretString } = await client.send(command)

  return shouldBePresent(SecretString)
})

export const getSecret = async <T = string>(name: SecretName): Promise<T> => {
  const secrets = await getSecrets()

  return assertField(JSON.parse(secrets), name)
}

Fetching Cryptocurrency Prices with CoinGecko API

To fetch cryptocurrency prices, we use the CoinGecko API. The getAssetPrices function accepts an array of asset IDs and an optional fiat currency.

import { addQueryParams } from "@lib/utils/query/addQueryParams"
import { FiatCurrency } from "../FiatCurrency"
import { queryUrl } from "@lib/utils/query/queryUrl"
import { recordMap } from "@lib/utils/record/recordMap"

type Input = {
  ids: string[]
  fiatCurrency?: FiatCurrency
}

type Response = Record<string, Record<FiatCurrency, number>>

const baseUrl = "https://api.coingecko.com/api/v3/simple/price"

export const getAssetPrices = async ({ ids, fiatCurrency = "usd" }: Input) => {
  const url = addQueryParams(baseUrl, {
    ids: ids.join(","),
    vs_currencies: fiatCurrency,
  })

  const result = await queryUrl<Response>(url)

  return recordMap(result, (value) => value[fiatCurrency])
}

Handling Different Trading Pairs

If we were trading WETH against WBTC, this approach wouldn’t work. In that case, we’d need to query a swap quote and derive the price from it. However, since we’re trading with stablecoins, we can rely on the direct price from the CoinGecko API.

Secure Wallet Management

To enhance security, we’ll avoid storing our primary wallet’s private key on the server. Instead, we’ll create a dedicated account solely for executing swaps. This account will hold only the asset we want to swap and a small amount of POL to cover gas fees. A simple script will generate a private key using viem and derive an account address from it.

import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"

const createAccount = () => {
  const privateKey = generatePrivateKey()

  const { address } = privateKeyToAccount(privateKey)

  console.log("EVM Account Created:")
  console.log("Address:", address)
  console.log("Private Key:", privateKey)
}

createAccount()

Implementing the Withdraw Function

Once our trading activities are complete, we’ll use the withdraw function to transfer all remaining assets back to our primary account.

import { privateKeyToAddress } from "viem/accounts"
import { getErc20Balance } from "../../../lib/chain/evm/erc20/getErc20Balance"

import { getSecret } from "../getSercret"
import { transferErc20Token } from "../../../lib/chain/evm/erc20/transferErc20Token"
import {
  tradeAssetAddress,
  tradeAssets,
  tradeChain,
} from "../entities/TradeAsset"

const withdraw = (address: `0x${string}`) =>
  Promise.all(
    tradeAssets.map(async (asset) => {
      const assetAddress = tradeAssetAddress[asset]

      const privateKey = await getSecret<`0x${string}`>(`accountPrivateKey`)

      const amount = await getErc20Balance({
        chain: tradeChain,
        accountAddress: privateKeyToAddress(privateKey),
        address: assetAddress,
      })

      if (amount === BigInt(0)) {
        return
      }

      return transferErc20Token({
        chain: tradeChain,
        privateKey,
        tokenAddress: assetAddress,
        to: address,
        amount,
      })
    }),
  )

const address = process.argv[2] as `0x${string}`

withdraw(address)

The withdraw function iterates over every supported asset, checks the balance, and transfers the entire amount to the specified address. The getErc20Balance and transferErc20Token functions handle this process by interacting with the ERC20 contract methods to read balances and transfer tokens, respectively.

import { Address, Chain, erc20Abi } from "viem"
import { getPublicClient } from "../utils/getPublicClient"

type Input = {
  chain: Chain
  address: Address
  accountAddress: Address
}

export const getErc20Balance = async ({
  chain,
  address,
  accountAddress,
}: Input) => {
  const publicClient = getPublicClient(chain)

  return publicClient.readContract({
    address,
    abi: erc20Abi,
    functionName: "balanceOf",
    args: [accountAddress],
  })
}

Executing Swaps with the 0x Swap API

To execute a swap, we’ll leverage the 0x Swap API, which identifies the optimal route for the trade.

import { createClientV2 } from "@0x/swap-ts-sdk"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import {
  Address,
  Chain,
  concat,
  Hex,
  maxUint256,
  numberToHex,
  size,
} from "viem"
import { TransferDirection } from "@lib/utils/TransferDirection"
import { assertField } from "@lib/utils/record/assertField"
import { privateKeyToAccount } from "viem/accounts"
import { setErc20Allowance } from "./setErc20Allowance"
import { getWalletClient } from "../utils/getWalletClient"
import { getPublicClient } from "../utils/getPublicClient"
import { assertTx } from "../utils/assertTx"

type Input = Record<TransferDirection, Address> & {
  chain: Chain
  zeroXApiKey: string
  amount: bigint
  privateKey: `0x${string}`
}

export const swapErc20Token = async ({
  zeroXApiKey,
  chain,
  from,
  to,
  amount,
  privateKey,
}: Input) => {
  const client = createClientV2({
    apiKey: zeroXApiKey,
  })

  const publicClient = getPublicClient(chain)

  const account = privateKeyToAccount(privateKey)

  const walletClient = getWalletClient({ chain, privateKey })

  const quote = await client.swap.permit2.getQuote.query({
    sellToken: from,
    buyToken: to,
    chainId: chain.id,
    sellAmount: amount.toString(),
    taker: account.address,
  })

  if ("issues" in quote) {
    const { allowance } = quote.issues
    if (allowance) {
      const { spender } = allowance
      await setErc20Allowance({
        chain,
        privateKey,
        tokenAddress: from,
        spender: spender as Address,
        amount: maxUint256,
      })
    }
  }

  const transaction = assertField(quote, "transaction")

  const { eip712 } = assertField(quote, "permit2")

  const signature = await walletClient.signTypedData(eip712 as any)

  const signatureLengthInHex = numberToHex(size(signature), {
    signed: false,
    size: 32,
  })

  transaction.data = concat([
    transaction.data as Hex,
    signatureLengthInHex,
    signature,
  ])

  const nonce = await publicClient.getTransactionCount({
    address: account.address,
  })

  const hash = await walletClient.sendTransaction({
    gas: BigInt(shouldBePresent(transaction.gas, "gas")),
    to: transaction.to as Address,
    data: transaction.data as `0x${string}`,
    value: BigInt(transaction.value),
    gasPrice: BigInt(transaction.gasPrice),
    nonce,
  })

  return assertTx({ publicClient, hash })
}

Streamlining Token Approvals with Permit2

We’re using Permit2 because it simplifies and streamlines token approvals for different protocols. Instead of needing multiple transactions and approvals, Permit2 consolidates these steps with a single signature, reducing gas costs and making the swap process faster and more straightforward.

Handling Allowance Issues

We check if there’s an allowance issue in the quote because smart contracts need permission to transfer tokens on your behalf. If you haven’t already approved the contract (spender) to move your tokens, we must set the token allowance so the swap can succeed. This extra step ensures the swap contract has the necessary access, preventing transaction failures due to insufficient allowance.

import { Address, Chain, erc20Abi } from "viem"
import { assertTx } from "../utils/assertTx"
import { getPublicClient } from "../utils/getPublicClient"
import { getWalletClient } from "../utils/getWalletClient"

type Input = {
  chain: Chain
  privateKey: `0x${string}`
  tokenAddress: Address
  spender: Address
  amount: bigint
}

export async function setErc20Allowance({
  chain,
  privateKey,
  tokenAddress,
  spender,
  amount,
}: Input) {
  const walletClient = getWalletClient({ chain, privateKey })

  const hash = await walletClient.writeContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: "approve",
    args: [spender, amount],
  })

  return assertTx({ publicClient: getPublicClient(chain), hash })
}

Appending EIP-712 Signature

We’re appending the EIP-712 signature to the transaction data so the contract can verify that you’ve granted permission for Permit2. By signing the typed data first and then adding the signature (along with its length) to the transaction, the on-chain contract can confirm it’s authorized to move your tokens according to the Permit2 specification, all in a single, seamless step.

Preparing and Asserting Transactions

The transaction generated from the quote already includes all the necessary fields. Our task is to convert specific fields, add appropriate types, and include the nonce before sending the transaction.

To ensure the transaction is successful before proceeding, we use the assertTx function to wait for the transaction receipt. If the transaction fails, an error is thrown with the transaction’s status.

Scheduling the Trading Bot with runTraders

Every 10 minutes, we will execute the runTraders function. This function begins by updating the prices of the assets, filters out traders that lack sufficient historical data, and finally runs the trading logic for each eligible trader.

import { getAllTraders } from "../db/traders"
import { traderConfig } from "./config"
import { runTrader } from "./runTrader"
import { updatePrices } from "./updatePrices"

export const runTraders = async () => {
  await updatePrices()

  const traders = await getAllTraders()

  const tradersWithPrices = traders.filter(
    ({ prices }) => prices.length >= traderConfig.longTermPeriod,
  )

  return Promise.all(tradersWithPrices.map(runTrader))
}

Updating Asset Prices

To update the prices of traded assets, we fetch the state of each trader, then retrieve the prices for all assets being traded. Afterward, we iterate over each trader, updating their historical prices with the latest data while limiting the array to the long-term period defined in the configuration. This ensures the bot maintains an accurate and manageable history of asset prices for its trading logic.

import { withoutDuplicates } from "@lib/utils/array/withoutDuplicates"
import { getAllTraders, updateTrader } from "../db/traders"
import { getAssetPrices } from "../../../lib/chain/price/utils/getAssetPrices"
import { traderConfig } from "./config"
import { tradeAssetPriceProividerId } from "../entities/TradeAsset"

export const updatePrices = async () => {
  const traders = await getAllTraders()

  const assets = withoutDuplicates(
    traders.map(({ asset }) => tradeAssetPriceProividerId[asset]),
  )

  const priceRecord = await getAssetPrices({ ids: assets })

  return Promise.all(
    traders.map(async ({ id, prices: oldPrices, asset }) => {
      const price = priceRecord[tradeAssetPriceProividerId[asset]]

      const prices = [...oldPrices, price].slice(-traderConfig.longTermPeriod)

      return updateTrader(id, { prices })
    }),
  )
}

Executing Trading Logic

The runTrader function executes the trading logic for a single trader. It calculates the short-term and long-term moving averages of the asset's historical prices to determine a trading signal—either "buy" or "sell." If the short-term average crosses above or below the long-term average and the signal differs from the last executed trade, a transaction is triggered. The function retrieves the trader's private key, determines the appropriate assets to swap, and executes the trade using the 0x Swap API. After completing the trade, it sends a Telegram notification with the trade details and updates the trader's state in the database, including the new lastTrade value.

import { getAverage } from "@lib/utils/math/getAverage"
import { Trader, TradeType } from "../entities/Trader"
import { traderConfig } from "./config"
import { getSecret } from "../getSercret"
import {
  cashAsset,
  tradeAssetAddress,
  tradeChain,
} from "../entities/TradeAsset"
import { getErc20Balance } from "../../../lib/chain/evm/erc20/getErc20Balance"
import { limitOrderAssetAddress } from "../../limit-orders/entities/LimitOrderAsset"
import { privateKeyToAddress } from "viem/accounts"
import { swapErc20Token } from "../../../lib/chain/evm/erc20/swapErc20Token"
import { recordMap } from "@lib/utils/record/recordMap"
import { updateTrader } from "../db/traders"
import { getLastItem } from "@lib/utils/array/getLastItem"
import { sendTradeNotification } from "./sendTradeNotification"

export const runTrader = async ({ prices, asset, lastTrade, id }: Trader) => {
  const shortTermAverage = getAverage(
    prices.slice(-traderConfig.shortTermPeriod),
  )
  const longTermAverage = getAverage(prices.slice(-traderConfig.longTermPeriod))

  if (shortTermAverage === longTermAverage) {
    return
  }

  const tradeType: TradeType =
    shortTermAverage > longTermAverage ? "buy" : "sell"

  if (tradeType === lastTrade) {
    return
  }

  const zeroXApiKey = await getSecret("zeroXApiKey")

  const privateKey = await getSecret<`0x${string}`>("accountPrivateKey")

  const from = tradeType === "buy" ? cashAsset : asset
  const to = from === cashAsset ? asset : cashAsset

  const amount = await getErc20Balance({
    chain: tradeChain,
    address: limitOrderAssetAddress[from],
    accountAddress: privateKeyToAddress(privateKey),
  })

  await swapErc20Token({
    zeroXApiKey,
    privateKey,
    amount,
    chain: tradeChain,
    ...recordMap({ from, to }, (asset) => tradeAssetAddress[asset]),
  })

  await sendTradeNotification({
    asset,
    price: getLastItem(prices),
    tradeType,
  })

  await updateTrader(id, { lastTrade: tradeType })
}

Deploying as an AWS Lambda Function

We'll deploy our code as an AWS Lambda function, wrapping it with Sentry to receive notifications about any potential issues.

import { AWSLambda } from "@sentry/serverless"
import { getEnvVar } from "./getEnvVar"
import { runLimitOrders } from "./core/runLimitOrders"

AWSLambda.init({
  dsn: getEnvVar("SENTRY_KEY"),
})

exports.handler = AWSLambda.wrapHandler(runLimitOrders)

Provisioning AWS Resources with Terraform

To provision the necessary AWS resources for our services, we will use Terraform. To ensure the function runs every 10 minutes, we’ll configure a CloudWatch Event Rule. The Terraform code for this setup is available in the GitHub repository.

Conclusion

By combining TypeScript, AWS, and powerful APIs like 0x and CoinGecko, we’ve built a secure, efficient cryptocurrency trading bot. With automated execution, real-time notifications, and a robust infrastructure, this system demonstrates how modern tools can simplify complex trading strategies. Explore the repository to dive deeper and start building your own trading bot!