diff --git a/.vscode/settings.json b/.vscode/settings.json index a6fb976..2e6dad4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "eslint.enable": true + "eslint.enable": true, + "sonarlint.connectedMode.project": { + "projectKey": "icyfry_serverless-bot" + } } \ No newline at end of file diff --git a/README.md b/README.md index a4c494a..468a8b7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ ![GitHub top language](https://img.shields.io/github/languages/top/icyfry/serverless-bot) [![Build](https://github.com/icyfry/serverless-bot/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/icyfry/serverless-bot/actions/workflows/build.yml) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=icyfry_serverless-bot&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=icyfry_serverless-bot) +> ⚠️ this is a experimentation project, DO NOT use it in a real use case, performance will most probably be negative. + A serverless bot to trigger automatic orders on dYdX @@ -19,7 +21,7 @@ A serverless bot to trigger automatic orders on dYdX ### Setup and troubleshooting -`zlib-sync` lib may cause segmentation fault on local development and unit tests +`zlib-sync` may cause segmentation fault on local development and unit tests when not using mock for the discord module commands * `task test` Run unit tests diff --git a/src/bot.ts b/src/bot.ts index 22f1884..77d04e7 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,45 +1,50 @@ -import { OrderExecution, OrderSide, OrderTimeInForce, OrderType } from '@dydxprotocol/v4-client-js'; import { GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; -import { Context } from 'aws-lambda'; +import { APIGatewayProxyResult, Context } from 'aws-lambda'; import { Strat } from './strategy/strat'; import { Discord } from './communication/discord'; import { CallbackResponseParams } from './main'; +import { BroadcastTxAsyncResponse, BroadcastTxSyncResponse } from '@cosmjs/tendermint-rpc/build/tendermint37'; +import { IndexedTx} from '@cosmjs/stargate'; +import { OrderExecution, OrderSide, OrderTimeInForce, OrderType } from '@dydxprotocol/v4-client-js'; -/** - * Input of the bot - */ -export class Input { - public market = "BTC-USD"; - public price = 0; - public source: InputSource = InputSource.Mock; - public details: InputDetails = {}; - public emitKey = "nokey"; - public dryrun = false; - public roundingFactor = 100000000; // 8 decimals - public interval = 60; // 1 minute - constructor(event: string) { - Object.assign(this, JSON.parse(event)); +// Transaction response +export type TxResponse = BroadcastTxAsyncResponse | BroadcastTxSyncResponse | IndexedTx; + +// Error that should not interrupt the bot +export class Warning extends Error { + constructor(message?: string) { + super(message); + this.name = 'Warning'; } } -/** - * Output of the bot - */ -export class Output { - public order: BotOrder; - constructor(order: BotOrder) { - this.order = order; - } - toString() : string { return JSON.stringify( - { - "order" : this.order, - }, - null, 2); } +// Configuration of the broker connected to the bot +export interface BrokerConfig { + TESTNET_MNEMONIC: string; + MAINNET_MNEMONIC: string; + DISCORD_TOKEN: string; } -/** - * Trade order - */ +// A position on the broker +export interface Position { + market: string, + status: string, + side: string, + size: number, + maxSize: number, + entryPrice: number, + exitPrice: number, + realizedPnl: number, // in usd + unrealizedPnl: number, // in usd + createdAt: Date, + createdAtHeight: number, + closedAt: Date, + sumOpen: number, // in crypto + sumClose: number, // in crypto + netFunding: number +} + +// An order to place on the broker export class BotOrder { public market = "BTC-USD"; // perpertual market id public type:OrderType = OrderType.LIMIT; // order type @@ -55,30 +60,68 @@ export class BotOrder { public goodTillTime = 86400; // goodTillTime in seconds } -export interface BrokerConfig { - TESTNET_MNEMONIC: string; - MAINNET_MNEMONIC: string; - DISCORD_TOKEN: string; -} - -export interface InputDetails { +// Output of the bot +export class Output { + public order: BotOrder; + constructor(order: BotOrder) { + this.order = order; + } + toString() : string { return JSON.stringify( + { + "order" : this.order, + }, + null, 2); } } -export interface SuperTrendDetails extends InputDetails{ - action: string; // BUY or SELL - limit: number; +// Input of the bot +export class Input { + public market = "BTC-USD"; + public price = 0; + public source: InputSource = InputSource.Mock; + public details: InputDetails = {}; + public emitKey = "nokey"; + public dryrun = false; + public roundingFactor = 100000000; // 8 decimals + public interval = 60; // 1 minute + constructor(event: string) { + Object.assign(this, JSON.parse(event)); + // Rounding the prices + this.price = Math.round(this.price*100)/100; + } } -export interface SMCDetails extends InputDetails{ - type: string; +// Commons details of the input +export interface InputDetails { + plots?: string[]; } +// Available sources of input export enum InputSource { SuperTrend = "SUPER_TREND", SMC = "SMART_MONEY_CONCEPTS", Mock = "MOCK" } +// Details of the SuperTrend input +export class SuperTrendDetails implements InputDetails { + public action = "BUY"; // BUY or SELL + public limit = 0; + public plots?: string[] = []; + constructor(details: string) { + Object.assign(this, JSON.parse(details)); + // Rounding the prices + this.limit = Math.round(this.limit*100)/100; + } +} + +// Details of the SMC input +export interface SMCDetails extends InputDetails{ + type: string; +} + +/** + * Bot abstract class + */ export abstract class Bot { static readonly NETWORK_MAINNET: string = "mainnet"; @@ -95,38 +138,38 @@ export abstract class Bot { } /** - * Place an order on the exchange + * Place an order on the broker * @param order Order to place * @returns transaction response */ - public abstract placeOrder(order:BotOrder): Promise; + public abstract placeOrder(order:BotOrder): Promise; /** - * Close a position on the exchange + * Close a position on the broker * @param market the market to close * @param hasToBeSide the side the position has to be before closing (LONG or SHORT) - * @param refPrice reference price to close the position + * @param refPrice reference price used to close the position * @returns transaction response and position closed */ - public abstract closePosition(market: string, hasToBeSide?: OrderSide, refPrice?: number): Promise<{tx: any, position: any}>; + public abstract closePosition(market: string, hasToBeSide?: OrderSide, refPrice?: number): Promise<{tx: TxResponse, position: Position}>; /** - * Connect to the exchange + * Connect to the broker * @returns address of the connected account */ public abstract connect() : Promise; /** - * Disconnect from the exchange + * Disconnect from the broker */ public abstract disconnect(): Promise; /** - * Cancel order + * Cancel an order * @param market Market to cancel the order - * @param clientId id of the order + * @param clientId id of the order to cancel */ - public abstract cancelOrdersForMarket(market: string, clientId: number): Promise; + public abstract cancelOrdersForMarket(market: string, clientId: number): Promise; /** * Read config from the AWS Secrets Manager @@ -149,23 +192,25 @@ export abstract class Bot { }; /** - * Process the lambda event + * Process the lambda input event * @param input input of the lambda * @param strategy the strategy to apply - * @param context the context + * @param _context the context * @returns the response */ public async process(input: Input, strategy: Strat, context?: Context): Promise { + console.log(context?.awsRequestId); + // Return of the process const response: CallbackResponseParams = { - response_error: null, + response_error: undefined, response_success: { statusCode: 200, headers: { 'Content-Type': 'application/json; charset=utf-8', }, - body: {"message": "no message"} + body: '{"message": "no message"}' } }; @@ -176,30 +221,32 @@ export abstract class Bot { // Order const order: BotOrder = strategy.getStatelessOrderBasedOnInput(input); - + // Close previous position on this market try{ - const closingTransaction: any = await this.closePosition(order.market, order.side === OrderSide.BUY ? OrderSide.SELL : OrderSide.BUY, order.price); - await this.discord.sendMessageClosePosition(order.market, closingTransaction.position, closingTransaction.tx); - } catch(e: any) { - // Position not closed - console.warn(e); - await this.discord.sendError(e); + const closingTransaction: {tx: TxResponse, position: Position} = await this.closePosition(order.market, order.side === OrderSide.BUY ? OrderSide.SELL : OrderSide.BUY, order.price); + await this.discord.sendMessageClosePosition(order.market, closingTransaction.position, closingTransaction.tx); + } catch(error) { + if(error instanceof Warning) { + console.warn(error); + await this.discord.sendError(error); // Position not closed + } else throw error; } - const orderTransaction: any = await this.placeOrder(order); + // Open new position + const orderTransaction: TxResponse = await this.placeOrder(order); await this.discord.sendMessageOrder(order, input, strategy, orderTransaction); // Output const output: Output = new Output(order); console.log("Output "+output); - response.response_success.body = JSON.stringify({"message": "process done"}); + (response.response_success as APIGatewayProxyResult).body = JSON.stringify({"message": "process done"}); } - catch(e: any) { - await this.discord.sendError(e); - response.response_success=null; - response.response_error=new Error(e); + catch(error) { + await this.discord.sendError(error as Error); + response.response_success = undefined; + response.response_error= error as Error; } return response; diff --git a/src/communication/discord.ts b/src/communication/discord.ts index 264601e..0575f06 100644 --- a/src/communication/discord.ts +++ b/src/communication/discord.ts @@ -1,9 +1,16 @@ import { Client, ColorResolvable, EmbedBuilder, GatewayIntentBits, Message, TextChannel } from "discord.js"; -import { BotOrder, Input, Output } from "../bot"; +import { BotOrder, Position, Input, Output, Warning } from "../bot"; import { OrderSide } from "@dydxprotocol/v4-client-js"; -import { Position } from "../dydx/dydx-bot"; import { Strat } from "../strategy/strat"; +import { BroadcastTxAsyncResponse, BroadcastTxSyncResponse } from "@cosmjs/tendermint-rpc"; +import { IndexedTx } from "@cosmjs/stargate"; +// Transaction response +type TxResponse = BroadcastTxAsyncResponse | BroadcastTxSyncResponse | IndexedTx; + +/** + * Discord interactions + */ export class Discord { public client?: Client; @@ -19,7 +26,10 @@ export class Discord { } - private getTxEmbedField(tx: any): any { + /** + * Get the tx embed field + */ + private getTxEmbedField(tx: TxResponse): {name: string, value: string, inline: boolean} { let hash: string; if (tx?.hash instanceof Uint8Array) { hash = Buffer.from(tx?.hash).toString('hex'); @@ -31,7 +41,7 @@ export class Discord { return { name: `tx`, value: `${hash}`, inline: true }; } - private getChaoslabsUrl(address: string,subAccount: number): any { + private getChaoslabsUrl(address: string,subAccount: number): string { return `https://community.chaoslabs.xyz/dydx-v4/risk/accounts/${address}/subAccount/${subAccount}/overview`; } @@ -53,6 +63,9 @@ export class Discord { } + /** + * Disconnect from discord + */ public async logout(): Promise { if(this.client === undefined) throw new Error("Client not initialized"); this.client.removeAllListeners(); @@ -76,8 +89,8 @@ export class Discord { if(!this.client?.isReady) throw new Error("Client not ready"); return this.channel.send({ embeds: [embed] }); } - - public sendMessageClosePosition(market: string, position: Position, tx?: any): Promise { + + public sendMessageClosePosition(market: string, position: Position, tx?: TxResponse): Promise { const embed = new EmbedBuilder() .setTitle(`${market}`) @@ -96,11 +109,11 @@ export class Discord { // Performance at close const perf = Math.round((pnl/(position.sumOpen * position.entryPrice))*100)/100; let perfIcon: string; - if(perf > -0.02 && perf < 0.01) { + if(perf > -0.02 && perf < 0.02) { perfIcon = "😑"; - }else if(perf >= 0.05) { + }else if(perf >= 0.1) { perfIcon = "🚀"; - }else if(perf <= -0.02) { + }else if(perf <= -0.05) { perfIcon = "😡"; }else { perfIcon = "😐"; @@ -114,7 +127,7 @@ export class Discord { } - public sendMessageOrder(order: BotOrder, input?: Input, strategy?: Strat, tx?: any): Promise { + public sendMessageOrder(order: BotOrder, input?: Input, strategy?: Strat, tx?: TxResponse): Promise { // Color of the embed let color: ColorResolvable; @@ -127,7 +140,7 @@ export class Discord { } // size in USD - let usdSize = Math.round((order.price*order.size)*100)/100; + const usdSize = Math.round((order.price*order.size)*100)/100; const embed = new EmbedBuilder() .setTitle(`${order.market}`) @@ -155,8 +168,9 @@ export class Discord { return this.sendMessage(message); } - public sendError(message: string): Promise { - return this.sendMessage("⚠️ "+message); + public sendError(message: Error): Promise { + if(message instanceof Warning) return this.sendMessage("⚠️ "+message); + else return this.sendMessage("❌ "+message); } } diff --git a/src/dydx/dydx-bot.ts b/src/dydx/dydx-bot.ts index 91dab20..1ae66b8 100644 --- a/src/dydx/dydx-bot.ts +++ b/src/dydx/dydx-bot.ts @@ -1,28 +1,9 @@ import { BECH32_PREFIX,CompositeClient, LocalWallet, Network, OrderFlags, OrderSide, OrderTimeInForce, OrderType, SubaccountClient } from "@dydxprotocol/v4-client-js"; -import { BotOrder, Bot, BrokerConfig } from "../bot"; -import { BroadcastTxAsyncResponse, BroadcastTxSyncResponse } from '@cosmjs/tendermint-rpc/build/tendermint37'; -import { IndexedTx} from '@cosmjs/stargate'; - -export interface Position { - market: string, - status: string, - side: string, - size: number, - maxSize: number, - entryPrice: number, - exitPrice: number, - realizedPnl: number, // in usd - unrealizedPnl: number, // in usd - createdAt: Date, - createdAtHeight: number, - closedAt: Date, - sumOpen: number, // in crypto - sumClose: number, // in crypto - netFunding: number -} - -export type TxResponse = BroadcastTxAsyncResponse | BroadcastTxSyncResponse | IndexedTx; +import { BotOrder, Bot, BrokerConfig, Warning, TxResponse, Position } from "../bot"; +/** + * Bot implementation for dYdX + */ export class DYDXBot extends Bot { public client?: CompositeClient; @@ -87,7 +68,7 @@ export class DYDXBot extends Bot { const position: Position | undefined = await this.client.indexerClient.account.getSubaccountPerpetualPositions(this.subaccount.address, this.SUBACCOUNT_NUMBER).then((result) => { return result.positions.find((position: Position) => position.market === market && position.status === "OPEN"); }); - if(position === undefined) throw new Error(`Trying to close a positon that does not exist on ${market}`); + if(position === undefined) throw new Warning(`Trying to close a positon that does not exist on ${market}`); // Check if position side is correct if(hasToBeSide !== undefined && position.side !== (hasToBeSide === OrderSide.BUY ? Bot.SIDE_LONG : Bot.SIDE_SHORT)) throw new Error(`Trying to close a positon on ${market} but the position is already on the target side ${position.side}`); @@ -96,8 +77,7 @@ export class DYDXBot extends Bot { const closingOrder: BotOrder = new BotOrder(); closingOrder.market = market; closingOrder.clientId = Date.now(); - // closingOrder.reduceOnly = true; - + if(position.side === Bot.SIDE_LONG){ closingOrder.size = position.size; closingOrder.side = OrderSide.SELL; @@ -118,6 +98,7 @@ export class DYDXBot extends Bot { else { closingOrder.type = OrderType.MARKET; closingOrder.timeInForce = OrderTimeInForce.FOK; + closingOrder.reduceOnly = true; } // Send closing order diff --git a/src/main.ts b/src/main.ts index db5776c..dbd0da6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,21 @@ import { Network } from "@dydxprotocol/v4-client-js"; import { DYDXBot } from "./dydx/dydx-bot"; import { BasicStrat } from "./strategy/strat-basic"; -import { APIGatewayProxyEvent, Context } from "aws-lambda"; +import { APIGatewayProxyCallback, APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda"; import { Bot, Input } from "./bot"; +/** + * Response parameters for the APIGateway callback + */ export interface CallbackResponseParams { - response_error: Error|null; - response_success: any; + response_error?: Error; + response_success?: APIGatewayProxyResult; } -export type CallbackResponse = (response_error: Error|null, response_success: any) => any; /** * Main handler for the lambda function */ -exports.handler = function (event: APIGatewayProxyEvent, context: Context, callback: CallbackResponse) { +exports.handler = function (event: APIGatewayProxyEvent, context: Context, callback: APIGatewayProxyCallback) { console.log(`Event: ${JSON.stringify(event, null, 2)}`); console.log(`Context: ${JSON.stringify(context, null, 2)}`); @@ -34,7 +36,7 @@ exports.handler = function (event: APIGatewayProxyEvent, context: Context, callb } } - catch(e: any) { + catch(e) { console.error(e); exit(callback, {response_error: new Error("Error"), response_success:undefined} ); } @@ -46,12 +48,12 @@ exports.handler = function (event: APIGatewayProxyEvent, context: Context, callb * @param callback callback function * @param response content returned by the bot */ -function exit(callback: CallbackResponse, response: CallbackResponseParams) { +function exit(callback: APIGatewayProxyCallback, response: CallbackResponseParams) { console.log(response); callback(response.response_error, response.response_success); } -function DydxHandler(input: Input, context: Context, callback: CallbackResponse) { +function DydxHandler(input: Input, context: Context, callback: APIGatewayProxyCallback) { let network: Network; @@ -71,13 +73,15 @@ function DydxHandler(input: Input, context: Context, callback: CallbackResponse) bot.process(input, new BasicStrat(), context).then((response: CallbackResponseParams) => { exit(callback,response); - }).catch((e: any): void => { - throw new Error(e); + }).catch((e: Error): void => { + throw e; }).finally ((): void => { bot.disconnect(); }); - }).catch((e: any): void => { + }).catch((e: Error): void => { + console.error(e); + // Return generic error in the body exit(callback, {response_error: new Error("Error"), response_success:undefined} ); }); diff --git a/test/__mocks__/bot-mock.ts b/test/__mocks__/bot-mock.ts index 4397804..69ed44b 100644 --- a/test/__mocks__/bot-mock.ts +++ b/test/__mocks__/bot-mock.ts @@ -87,7 +87,7 @@ export class MockBot extends Bot { // Position already in place , ignore input if((order.side == OrderSide.BUY && this.openPositonsLongs.has(order.market)) || (order.side == OrderSide.SELL && this.openPositonsShorts.has(order.market))) { - return Promise.resolve({response_error:null, response_success:null}); + return Promise.resolve({response_error:undefined, response_success:undefined}); } // Close previous position @@ -96,7 +96,7 @@ export class MockBot extends Bot { // Open new position this.placeOrder(order); - return Promise.resolve({response_error:null, response_success:null}); + return Promise.resolve({response_error:undefined, response_success:undefined}); } diff --git a/test/discord.test.ts b/test/discord.test.ts index b8ce7eb..d16e598 100644 --- a/test/discord.test.ts +++ b/test/discord.test.ts @@ -35,7 +35,7 @@ describe("discord", () => { order.price = 1000; order.size = 0.1; order.side = OrderSide.SELL; - d.sendMessageOrder(order, new Input("{}"), new BasicStrat(), {hash: "0x1234"}); + d.sendMessageOrder(order, new Input("{}"), new BasicStrat(), undefined); }, TIMEOUT); it("output message to discord", async () => { @@ -44,7 +44,7 @@ describe("discord", () => { }, TIMEOUT); it("error message to discord", async () => { - d.sendError("error message"); + d.sendError(new Error("error message")); }, TIMEOUT); it("close position message to discord", async () => { @@ -64,7 +64,7 @@ describe("discord", () => { sumOpen: 0.1, sumClose: 0.1, netFunding: 0 - },{hash: "0x1234"}) + },undefined) }, TIMEOUT); }); \ No newline at end of file diff --git a/test/dydx.test.ts b/test/dydx.test.ts index ea9ca8b..e177fbb 100644 --- a/test/dydx.test.ts +++ b/test/dydx.test.ts @@ -87,7 +87,7 @@ describe("dYdX", () => { }, TIMEOUT); it("dryrun", async () => { - const input : Input = {roundingFactor:1000, interval:60, dryrun:true, emitKey:"", market:"BTC-USD", price:10000, source: InputSource.Mock ,details:{action:"SELL",limit:50000}}; + const input : Input = {roundingFactor:1000, interval:60, dryrun:true, emitKey:"", market:"BTC-USD", price:10000, source: InputSource.Mock ,details:{}}; try { await bot.process(input, new BasicStrat(), undefined); } diff --git a/test/strat-basic.test.ts b/test/strat-basic.test.ts index 2eadb5e..f1b46e1 100644 --- a/test/strat-basic.test.ts +++ b/test/strat-basic.test.ts @@ -2,7 +2,7 @@ import { MockBot } from "./__mocks__/bot-mock"; import { OrderSide} from "@dydxprotocol/v4-client-js"; -import { BotOrder, Input, InputSource } from "../src/bot"; +import { BotOrder, Input, InputSource, SuperTrendDetails } from "../src/bot"; import { BasicStrat } from "../src/strategy/strat-basic"; import dotenv from 'dotenv'; import fs from 'fs'; @@ -26,14 +26,14 @@ describe("basic strat", () => { }); it("supertrend BUY", () => { - const order: BotOrder = strat.getStatelessOrderBasedOnInput({interval:60, roundingFactor:1000, dryrun:false, emitKey:"", market:"BTC-USD",price:10000,source: InputSource.SuperTrend ,details:{action:"BUY",limit:50000}}); + const order: BotOrder = strat.getStatelessOrderBasedOnInput({interval:60, roundingFactor:1000, dryrun:false, emitKey:"", market:"BTC-USD",price:10000,source: InputSource.SuperTrend ,details:({action:"BUY",limit:50000} as SuperTrendDetails)}); expect(order.size).toBe(0.1); expect(order.side).toBe(OrderSide.BUY); expect(order.price).toBe(50000); }); it("supertrend SELL", () => { - const order: BotOrder = strat.getStatelessOrderBasedOnInput({interval:60, roundingFactor:1000, dryrun:false, emitKey:"", market:"BTC-USD",price:10000,source: InputSource.SuperTrend ,details:{action:"SELL",limit:50000}}); + const order: BotOrder = strat.getStatelessOrderBasedOnInput({interval:60, roundingFactor:1000, dryrun:false, emitKey:"", market:"BTC-USD",price:10000,source: InputSource.SuperTrend ,details:({action:"SELL",limit:50000} as SuperTrendDetails)}); expect(order.size).toBe(0.1); expect(order.side).toBe(OrderSide.SELL); expect(order.price).toBe(50000); @@ -41,12 +41,12 @@ describe("basic strat", () => { it("supertrend error", () => { expect(() => { - const order: BotOrder = strat.getStatelessOrderBasedOnInput({interval:60, roundingFactor:1000, dryrun:false, emitKey:"", market:"BTC-USD",price:10000,source: InputSource.SuperTrend ,details:{action:"ERR"}}); + const order: BotOrder = strat.getStatelessOrderBasedOnInput({interval:60, roundingFactor:1000, dryrun:false, emitKey:"", market:"BTC-USD",price:10000,source: InputSource.SuperTrend ,details:({action:"ERR"} as SuperTrendDetails)}); }).toThrow(); }); it("rounding", async () => { - const input : Input = {interval:60, roundingFactor:0, dryrun:true, emitKey:"", market:"BTC-USD",price:0,source: InputSource.Mock ,details:{action:"BUY",limit:0}}; + const input : Input = {interval:60, roundingFactor:0, dryrun:true, emitKey:"", market:"BTC-USD",price:0,source: InputSource.Mock ,details:({action:"BUY",limit:0} as SuperTrendDetails)}; let order: BotOrder; // no rounding size = 0,0999594977723775 @@ -93,7 +93,7 @@ describe("basic strat", () => { // Successful strategy expect(bot.getFullBalance()).toBeGreaterThan(INITIAL_BALANCE) - console.log("performance : " + ((bot.getFullBalance()/INITIAL_BALANCE)-1)*100 + "%"); + console.log("performance : " + ((bot.getFullBalance()/INITIAL_BALANCE)-1)*100 + " %"); });