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.
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,
}
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
}
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"
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,
})
}
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
}
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()
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.
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).
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)
}
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)
}
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])
}
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.
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()
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],
})
}
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 })
}
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.
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 })
}
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.
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.
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))
}
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 })
}),
)
}
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 })
}
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)
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.
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!