From 048904919e33c1268dc4fef00aad97ac2e8abf1a Mon Sep 17 00:00:00 2001 From: bludnic Date: Sun, 6 Oct 2024 19:57:49 +0100 Subject: [PATCH] feat: implement DCA trades (#18) --- packages/bot-processor/src/effect-runner.ts | 40 ++- packages/bot-processor/src/effects/index.ts | 1 + .../src/effects/types/effect-types.ts | 2 + packages/bot-processor/src/effects/useDca.ts | 34 ++ .../bot-processor/src/types/store/types.ts | 15 +- .../bot-templates/src/templates/test/dca.ts | 31 ++ .../bot-templates/src/templates/test/index.ts | 1 + packages/bot/src/streams/orders.stream.ts | 5 +- packages/db/src/entities/order.entity.ts | 9 +- .../db/src/entities/smart-trade.entity.ts | 101 +++--- .../db/src/extension/models/order.model.ts | 24 +- .../src/extension/models/smart-trade.model.ts | 6 + .../migration.sql | 2 + packages/prisma/src/schema.prisma | 1 + .../processing/src/bot/utils/toPrismaOrder.ts | 2 + .../src/bot/utils/toPrismaSmartTrade.ts | 14 +- .../bot/utils/toSmartTradeIteratorResult.ts | 7 +- .../src/executors/arb/arb.executor.ts | 1 + .../src/executors/dca/dca.executor.ts | 291 ++++++++++++++++++ .../src/executors/order/order.executor.ts | 32 +- .../src/executors/smart-trade.executor.ts | 7 +- .../src/executors/trade/trade.executor.ts | 65 +--- 22 files changed, 539 insertions(+), 152 deletions(-) create mode 100644 packages/bot-processor/src/effects/useDca.ts create mode 100644 packages/bot-templates/src/templates/test/dca.ts create mode 100644 packages/prisma/src/migrations/20241006164140_add_order_relative_price/migration.sql create mode 100644 packages/processing/src/executors/dca/dca.executor.ts diff --git a/packages/bot-processor/src/effect-runner.ts b/packages/bot-processor/src/effect-runner.ts index 590e0fdb..99540196 100644 --- a/packages/bot-processor/src/effect-runner.ts +++ b/packages/bot-processor/src/effect-runner.ts @@ -16,11 +16,11 @@ * Repository URL: https://github.com/bludnic/opentrader */ import { rsi } from "@opentrader/indicators"; -import { OrderStatusEnum, OrderType } from "@opentrader/types"; +import { OrderStatusEnum, OrderType, XEntityType, XOrderSide } from "@opentrader/types"; import { TradeService, SmartTradeService } from "./types/index.js"; import type { TBotContext } from "./types/index.js"; -import type { BaseEffect, EffectType } from "./effects/types/index.js"; -import type { +import { BaseEffect, EffectType, USE_DCA } from "./effects/types/index.js"; +import { buy, useTrade, useArbTrade, @@ -34,6 +34,7 @@ import type { useMarket, useCandle, useRSI, + useDca, } from "./effects/index.js"; import { BUY, @@ -63,6 +64,7 @@ export const effectRunnerMap: Record< [REPLACE_SMART_TRADE]: runReplaceSmartTradeEffect, [USE_TRADE]: runUseTradeEffect, [USE_ARB_TRADE]: runUseArbTradeEffect, + [USE_DCA]: runUseDcaEffect, [BUY]: runBuyEffect, [SELL]: runSellEffect, [USE_EXCHANGE]: runUseExchangeEffect, @@ -207,6 +209,38 @@ async function runUseArbTradeEffect(effect: ReturnType, ctx: return new SmartTradeService(effect.ref, smartTrade); } +async function runUseDcaEffect(effect: ReturnType, ctx: TBotContext) { + const { payload, ref } = effect; + + const smartTrade = await ctx.control.getOrCreateSmartTrade(ref, { + type: "DCA", + quantity: payload.quantity, + buy: { + symbol: payload.symbol, + type: payload.price ? OrderType.Limit : OrderType.Market, + price: payload.price, + status: OrderStatusEnum.Idle, + }, + sell: { + symbol: payload.symbol, + type: OrderType.Limit, + relativePrice: payload.tpPercent, + status: OrderStatusEnum.Idle, + }, + // Safety orders + additionalOrders: payload.safetyOrders.map((order) => ({ + relativePrice: order.relativePrice, + quantity: order.quantity, + symbol: payload.symbol, + type: OrderType.Limit, + side: XOrderSide.Buy, + entityType: XEntityType.SafetyOrder, + })), + }); + + return new SmartTradeService(effect.ref, smartTrade); +} + async function runBuyEffect(effect: ReturnType, ctx: TBotContext) { const { payload, ref } = effect; diff --git a/packages/bot-processor/src/effects/index.ts b/packages/bot-processor/src/effects/index.ts index 5dfb35f2..c0acd440 100644 --- a/packages/bot-processor/src/effects/index.ts +++ b/packages/bot-processor/src/effects/index.ts @@ -7,5 +7,6 @@ export * from "./useExchange.js"; export * from "./useIndicators.js"; export * from "./useTrade.js"; export * from "./useArbTrade.js"; +export * from "./useDca.js"; export * from "./market.js"; export * from "./indicators.js"; diff --git a/packages/bot-processor/src/effects/types/effect-types.ts b/packages/bot-processor/src/effects/types/effect-types.ts index 3367e080..5e8185cb 100644 --- a/packages/bot-processor/src/effects/types/effect-types.ts +++ b/packages/bot-processor/src/effects/types/effect-types.ts @@ -1,6 +1,7 @@ export const USE_SMART_TRADE = "USE_SMART_TRADE"; export const USE_TRADE = "USE_TRADE"; export const USE_ARB_TRADE = "USE_ARB_TRADE"; +export const USE_DCA = "USE_DCA"; export const BUY = "BUY"; export const SELL = "SELL"; export const REPLACE_SMART_TRADE = "REPLACE_SMART_TRADE"; @@ -17,6 +18,7 @@ export type EffectType = | typeof USE_SMART_TRADE | typeof USE_TRADE | typeof USE_ARB_TRADE + | typeof USE_DCA | typeof BUY | typeof SELL | typeof REPLACE_SMART_TRADE diff --git a/packages/bot-processor/src/effects/useDca.ts b/packages/bot-processor/src/effects/useDca.ts new file mode 100644 index 00000000..3660d67f --- /dev/null +++ b/packages/bot-processor/src/effects/useDca.ts @@ -0,0 +1,34 @@ +import { USE_DCA } from "./types/index.js"; +import { makeEffect } from "./utils/index.js"; + +type SafetyOrder = { + quantity: number; + relativePrice: number; +}; + +type UseDcaPayload = { + /** + * Entry order quantity + */ + quantity: number; + /** + * The Limit entry price of the order. If not provided, a market order will be placed. + */ + price?: number; + /** + * The Limit exit price of the order (e.g. 0.01 is 10%) + */ + tpPercent: number; + /** + * Safety orders to be placed when price goes down + */ + safetyOrders: SafetyOrder[]; + /** + * The symbol to trade, e.g. BTC/USDT. + */ + symbol?: string; +}; + +export function useDca(params: UseDcaPayload, ref = "0") { + return makeEffect(USE_DCA, params, ref); +} diff --git a/packages/bot-processor/src/types/store/types.ts b/packages/bot-processor/src/types/store/types.ts index 993a319c..5ef89a2a 100644 --- a/packages/bot-processor/src/types/store/types.ts +++ b/packages/bot-processor/src/types/store/types.ts @@ -1,4 +1,4 @@ -import { OrderStatusEnum, OrderType, XSmartTradeType } from "@opentrader/types"; +import { OrderStatusEnum, OrderType, XEntityType, XOrderSide, XSmartTradeType } from "@opentrader/types"; export type OrderPayload = { /** @@ -12,11 +12,24 @@ export type OrderPayload = { type: OrderType; status?: OrderStatusEnum; // default to Idle price?: number; // if undefined, then it's a market order + /** + * Price deviation relative to entry price. + * If 0.1, the order will be placed as entryPrice + 10% + * If -0.1, the order will be placed as entryPrice - 10% + */ + relativePrice?: number; }; +export interface AdditionalOrderPayload extends OrderPayload { + quantity: number; + entityType: XEntityType; + side: XOrderSide; +} + export type CreateSmartTradePayload = { type: XSmartTradeType; buy: OrderPayload; sell?: OrderPayload; + additionalOrders?: AdditionalOrderPayload[]; quantity: number; }; diff --git a/packages/bot-templates/src/templates/test/dca.ts b/packages/bot-templates/src/templates/test/dca.ts new file mode 100644 index 00000000..e03fd68a --- /dev/null +++ b/packages/bot-templates/src/templates/test/dca.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { cancelSmartTrade, TBotContext, useDca } from "@opentrader/bot-processor"; +import { logger } from "@opentrader/logger"; + +export function* testDca(ctx: TBotContext) { + if (ctx.onStart) { + logger.info(`[Test DCA] Bot started`); + } + if (ctx.onStop) { + logger.info(`[Test DCA] Bot stopped`); + + yield cancelSmartTrade(); + + return; + } + + logger.info("[Test DCA] Executing strategy template"); + yield useDca({ + quantity: 0.001, + tpPercent: 0.03, // +3% + safetyOrders: [ + { relativePrice: -0.01, quantity: 0.002 }, + { relativePrice: -0.02, quantity: 0.004 }, + { relativePrice: -0.03, quantity: 0.006 }, + ], + }); +} + +testDca.displayName = "Test DCA"; +testDca.hidden = true; +testDca.schema = z.object({}); diff --git a/packages/bot-templates/src/templates/test/index.ts b/packages/bot-templates/src/templates/test/index.ts index cb1470ad..d02ab4ba 100644 --- a/packages/bot-templates/src/templates/test/index.ts +++ b/packages/bot-templates/src/templates/test/index.ts @@ -6,3 +6,4 @@ export * from "./rsi.js"; export * from "./state.js"; export * from "./trades.js"; export * from "./testMarketOrder.js"; +export * from "./dca.js"; diff --git a/packages/bot/src/streams/orders.stream.ts b/packages/bot/src/streams/orders.stream.ts index 1aea3211..892fe220 100644 --- a/packages/bot/src/streams/orders.stream.ts +++ b/packages/bot/src/streams/orders.stream.ts @@ -1,6 +1,6 @@ import { findStrategy } from "@opentrader/bot-templates/server"; import type { ExchangeCode, IWatchOrder, MarketId } from "@opentrader/types"; -import { BotProcessing, getWatchers, shouldRunStrategy } from "@opentrader/processing"; +import { BotProcessing, getWatchers, shouldRunStrategy, SmartTradeExecutor } from "@opentrader/processing"; import type { OrderWithSmartTrade, ExchangeAccountWithCredentials } from "@opentrader/db"; import { xprisma } from "@opentrader/db"; import { logger } from "@opentrader/logger"; @@ -104,6 +104,9 @@ export class OrdersStream { subscribedMarkets, }); } + + const smartTradeExecutor = await SmartTradeExecutor.fromId(order.smartTrade.id); + await smartTradeExecutor.next(); } private async onOrderCanceled(exchangeOrder: IWatchOrder, order: OrderWithSmartTrade) { diff --git a/packages/db/src/entities/order.entity.ts b/packages/db/src/entities/order.entity.ts index 9741ce35..5fd2923c 100644 --- a/packages/db/src/entities/order.entity.ts +++ b/packages/db/src/entities/order.entity.ts @@ -1,7 +1,7 @@ import { XOrderStatus, XOrderType } from "@opentrader/types"; import type { Order as OrderModel } from "@prisma/client"; -type GenericOrderProps = "type" | "status" | "price" | "filledPrice" | "filledAt" | "placedAt"; +type GenericOrderProps = "type" | "status" | "price" | "relativePrice" | "filledPrice" | "filledAt" | "placedAt"; type OrderEntityBuilder< OrderType extends XOrderType, @@ -11,6 +11,7 @@ type OrderEntityBuilder< type: OrderType; status: OrderStatus; price: OrderType extends "Limit" ? NonNullable : null; + relativePrice: O["relativePrice"]; filledPrice: OrderStatus extends "Filled" ? NonNullable : null; filledAt: OrderStatus extends "Filled" ? NonNullable : null; placedAt: OrderStatus extends "Placed" | "Filled" | "Canceled" ? NonNullable : null; @@ -60,7 +61,8 @@ export function toOrderEntity(order: T): OrderEntity { type, status, - price: assertNonNullable(price, "price"), + // Skip price checking for relative price orders + price: baseProps.relativePrice ? price || -1 : assertNonNullable(price, "price"), filledPrice: null, filledAt: null, placedAt: null, @@ -105,7 +107,8 @@ export function toOrderEntity(order: T): OrderEntity { type, status, - price: assertNonNullable(price, "price"), + // Skip price checking for relative price orders + price: baseProps.relativePrice ? price || -1 : assertNonNullable(price, "price"), filledPrice: null, filledAt: null, placedAt: null, diff --git a/packages/db/src/entities/smart-trade.entity.ts b/packages/db/src/entities/smart-trade.entity.ts index 64e8053b..7f0cf4d2 100644 --- a/packages/db/src/entities/smart-trade.entity.ts +++ b/packages/db/src/entities/smart-trade.entity.ts @@ -1,4 +1,4 @@ -import { XEntryType, XTakeProfitType } from "@opentrader/types"; +import { XEntityType, XEntryType, XTakeProfitType } from "@opentrader/types"; import type { SmartTradeWithOrders } from "../types/smart-trade/index.js"; import type { OrderEntity } from "./order.entity.js"; import { toOrderEntity } from "./order.entity.js"; @@ -11,51 +11,39 @@ export type SmartTradeEntityBuilder< entryType: EntryType; takeProfitType: TakeProfitType; } & EntryOrderBuilder & - TakeProfitOrderBuilder; + TakeProfitOrderBuilder & + SafetyOrdersBuilder; + +type EntryOrderBuilder = EntryType extends "Order" + ? { + entryOrder: OrderEntity; + } + : { + entryOrders: OrderEntity[]; + }; -type EntryOrderBuilder = - EntryType extends "Order" +type TakeProfitOrderBuilder = TakeProfitType extends "None" + ? { + takeProfitOrder: null; + } + : TakeProfitType extends "Order" ? { - entryOrder: OrderEntity; + takeProfitOrder: OrderEntity; } : { - entryOrders: OrderEntity[]; + takeProfitOrders: OrderEntity[]; }; -type TakeProfitOrderBuilder = - TakeProfitType extends "None" - ? { - takeProfitOrder: null; - } - : TakeProfitType extends "Order" - ? { - takeProfitOrder: OrderEntity; - } - : { - takeProfitOrders: OrderEntity[]; - }; - -export type SmartTradeEntity_Order_None = SmartTradeEntityBuilder< - "Order", - "None" ->; - -export type SmartTradeEntity_Order_Order = SmartTradeEntityBuilder< - "Order", - "Order" ->; -export type SmartTradeEntity_Order_Ladder = SmartTradeEntityBuilder< - "Order", - "Ladder" ->; -export type SmartTradeEntity_Ladder_Order = SmartTradeEntityBuilder< - "Ladder", - "Order" ->; -export type SmartTradeEntity_Ladder_Ladder = SmartTradeEntityBuilder< - "Ladder", - "Ladder" ->; +type SafetyOrdersBuilder = { + safetyOrders: OrderEntity[]; +}; + +export type SmartTradeEntity_Order_None = SmartTradeEntityBuilder<"Order", "None">; + +export type SmartTradeEntity_Order_Order = SmartTradeEntityBuilder<"Order", "Order">; +export type SmartTradeEntity_Order_Ladder = SmartTradeEntityBuilder<"Order", "Ladder">; +export type SmartTradeEntity_Ladder_Order = SmartTradeEntityBuilder<"Ladder", "Order">; +export type SmartTradeEntity_Ladder_Ladder = SmartTradeEntityBuilder<"Ladder", "Ladder">; export type SmartTradeEntity = | SmartTradeEntity_Order_None @@ -64,42 +52,32 @@ export type SmartTradeEntity = | SmartTradeEntity_Ladder_Order | SmartTradeEntity_Ladder_Ladder; -export function toSmartTradeEntity( - entity: SmartTradeWithOrders, -): SmartTradeEntity { +export function toSmartTradeEntity(entity: SmartTradeWithOrders): SmartTradeEntity { const { orders, entryType, takeProfitType, type, ...other } = entity; - if (type === "DCA") { - throw new Error("Unsupported type DCA"); - } - const findSingleEntryOrder = (): OrderEntity => { - const entryOrder = orders.find( - (order) => order.entityType === "EntryOrder", - ); + const entryOrder = orders.find((order) => order.entityType === XEntityType.EntryOrder); if (!entryOrder) throw new Error("Entry order not found"); return toOrderEntity(entryOrder); }; const findSingleTakeProfitOrder = (): OrderEntity => { - const takeProfitOrder = orders.find( - (order) => order.entityType === "TakeProfitOrder", - ); + const takeProfitOrder = orders.find((order) => order.entityType === XEntityType.TakeProfitOrder); if (!takeProfitOrder) throw new Error("TakeProfit order not found"); return toOrderEntity(takeProfitOrder); }; const findMultipleEntryOrders = (): OrderEntity[] => { - return orders - .filter((order) => order.entityType === "EntryOrder") - .map(toOrderEntity); + return orders.filter((order) => order.entityType === XEntityType.EntryOrder).map(toOrderEntity); }; const findMultipleTakeProfitOrders = (): OrderEntity[] => { - return orders - .filter((order) => order.entityType === "TakeProfitOrder") - .map(toOrderEntity); + return orders.filter((order) => order.entityType === XEntityType.TakeProfitOrder).map(toOrderEntity); + }; + + const findSafetyOrders = (): OrderEntity[] => { + return orders.filter((order) => order.entityType === XEntityType.SafetyOrder).map(toOrderEntity); }; if (entryType === "Order" && takeProfitType === "None") { @@ -113,6 +91,7 @@ export function toSmartTradeEntity( entryOrder: findSingleEntryOrder(), takeProfitOrder: null, + safetyOrders: findSafetyOrders(), }; } else if (entryType === "Order" && takeProfitType === "Order") { return { @@ -125,6 +104,7 @@ export function toSmartTradeEntity( entryOrder: findSingleEntryOrder(), takeProfitOrder: findSingleTakeProfitOrder(), + safetyOrders: findSafetyOrders(), }; } else if (entryType === "Order" && takeProfitType === "Ladder") { return { @@ -137,6 +117,7 @@ export function toSmartTradeEntity( entryOrder: findSingleEntryOrder(), takeProfitOrders: findMultipleTakeProfitOrders(), + safetyOrders: findSafetyOrders(), }; } else if (entryType === "Ladder" && takeProfitType === "Order") { return { @@ -149,6 +130,7 @@ export function toSmartTradeEntity( entryOrders: findMultipleEntryOrders(), takeProfitOrder: findSingleTakeProfitOrder(), + safetyOrders: findSafetyOrders(), }; } else if (entryType === "Ladder" && takeProfitType === "Ladder") { return { @@ -161,6 +143,7 @@ export function toSmartTradeEntity( entryOrders: findMultipleEntryOrders(), takeProfitOrders: findMultipleTakeProfitOrders(), + safetyOrders: findSafetyOrders(), }; } diff --git a/packages/db/src/extension/models/order.model.ts b/packages/db/src/extension/models/order.model.ts index 9bc20991..a1a77670 100644 --- a/packages/db/src/extension/models/order.model.ts +++ b/packages/db/src/extension/models/order.model.ts @@ -31,23 +31,13 @@ export const orderModel = (prisma: PrismaClient) => ({ * This method is meant to just update the `status` in the DB when * synchronizing with the Exchange. */ - async updateStatus( - status: Extract, - orderId: number, - ) { - const resetSmartTradeRef = { - update: { - ref: null, - }, - }; - + async updateStatus(status: Extract, orderId: number) { return prisma.order.update({ where: { id: orderId, }, data: { status, - smartTrade: resetSmartTradeRef, }, }); }, @@ -60,21 +50,15 @@ export const orderModel = (prisma: PrismaClient) => ({ const { orderId, filledPrice, filledAt, fee } = data; if (filledPrice === null) { - throw new Error( - 'Cannot update order status to "filled" without specifying "filledPrice"', - ); + throw new Error('Cannot update order status to "filled" without specifying "filledPrice"'); } if (fee === null) { - throw new Error( - 'Cannot update order status to "filled" without specifying "fee"', - ); + throw new Error('Cannot update order status to "filled" without specifying "fee"'); } if (filledAt === null) { - throw new Error( - 'Cannot update order status to "filled" without specifying "filledAt"', - ); + throw new Error('Cannot update order status to "filled" without specifying "filledAt"'); } return prisma.order.update({ diff --git a/packages/db/src/extension/models/smart-trade.model.ts b/packages/db/src/extension/models/smart-trade.model.ts index 18adb2b5..1fe5edb2 100644 --- a/packages/db/src/extension/models/smart-trade.model.ts +++ b/packages/db/src/extension/models/smart-trade.model.ts @@ -1,6 +1,12 @@ import type { PrismaClient } from "@prisma/client"; export const smartTradeModel = (prisma: PrismaClient) => ({ + async clearRef(id: number) { + return prisma.smartTrade.update({ + where: { id }, + data: { ref: null }, + }); + }, async setRef(id: number, ref: string | null) { return prisma.smartTrade.update({ where: { diff --git a/packages/prisma/src/migrations/20241006164140_add_order_relative_price/migration.sql b/packages/prisma/src/migrations/20241006164140_add_order_relative_price/migration.sql new file mode 100644 index 00000000..115dec16 --- /dev/null +++ b/packages/prisma/src/migrations/20241006164140_add_order_relative_price/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "relativePrice" REAL; diff --git a/packages/prisma/src/schema.prisma b/packages/prisma/src/schema.prisma index 7ef4ce34..aa191a58 100644 --- a/packages/prisma/src/schema.prisma +++ b/packages/prisma/src/schema.prisma @@ -160,6 +160,7 @@ model Order { entityType String // EntityType side String // OrderSide price Float? // Market orders doesn't require price to be specified + relativePrice Float? // Percentage deviation calculated from the entry price for orders where the exact price is unknown (e.g., DCA orders) filledPrice Float? fee Float? symbol String diff --git a/packages/processing/src/bot/utils/toPrismaOrder.ts b/packages/processing/src/bot/utils/toPrismaOrder.ts index caea2a8f..133ae46b 100644 --- a/packages/processing/src/bot/utils/toPrismaOrder.ts +++ b/packages/processing/src/bot/utils/toPrismaOrder.ts @@ -7,6 +7,7 @@ export function toPrismaOrder( type?: OrderType; status?: OrderStatusEnum; price?: number; + relativePrice?: number; }, quantity: number, side: XOrderSide, @@ -19,6 +20,7 @@ export function toPrismaOrder( type: order.type || OrderType.Limit, entityType, price: order.price, + relativePrice: order.relativePrice, // Must be a number when Order["status"] is Filled to satisfy // Order entity type. // diff --git a/packages/processing/src/bot/utils/toPrismaSmartTrade.ts b/packages/processing/src/bot/utils/toPrismaSmartTrade.ts index 40a12120..9a17beac 100644 --- a/packages/processing/src/bot/utils/toPrismaSmartTrade.ts +++ b/packages/processing/src/bot/utils/toPrismaSmartTrade.ts @@ -27,6 +27,18 @@ export function toPrismaSmartTrade( ? toPrismaOrder(sell, quantity, XOrderSide.Sell, XEntityType.TakeProfitOrder, sellExchangeAccountId, sellSymbol) : undefined; + const additionalOrders = + smartTrade.additionalOrders?.map((order) => + toPrismaOrder( + order, + order.quantity, + order.side, + order.entityType, + order.exchange || bot.exchangeAccountId, + order.symbol || bot.symbol, + ), + ) || []; + return { entryType: "Order", takeProfitType: sell ? "Order" : "None", @@ -37,7 +49,7 @@ export function toPrismaSmartTrade( orders: { createMany: { - data: sellOrderData ? [buyOrderData, sellOrderData] : [buyOrderData], + data: sellOrderData ? [buyOrderData, sellOrderData, ...additionalOrders] : [buyOrderData, ...additionalOrders], }, }, diff --git a/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts b/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts index ba313776..cda5df0a 100644 --- a/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts +++ b/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts @@ -15,9 +15,10 @@ export function toSmartTradeIteratorResult(smartTrade: SmartTradeEntity): Proces throw new Error("SmartTrade is missing ref"); } - if (smartTrade.type === "DCA") { - throw new Error("toSmartTradeIteratorResult: DCA trade is not supported yet"); - } + // @todo handle relativePrice + // if (smartTrade.type === "DCA") { + // throw new Error("toSmartTradeIteratorResult: DCA trade is not supported yet"); + // } if (smartTrade.entryType === "Ladder") { throw new Error('toSmartTradeIteratorResult: SmartTrade with "entryType = Ladder" is not supported yet'); diff --git a/packages/processing/src/executors/arb/arb.executor.ts b/packages/processing/src/executors/arb/arb.executor.ts index a81fbbfb..606e78a1 100644 --- a/packages/processing/src/executors/arb/arb.executor.ts +++ b/packages/processing/src/executors/arb/arb.executor.ts @@ -119,6 +119,7 @@ export class ArbExecutor implements ISmartTradeExecutor { allOrders.push(cancelled); } + await xprisma.smartTrade.clearRef(this.smartTrade.id); await this.pull(); const cancelledOrders = allOrders.filter((cancelled) => cancelled); diff --git a/packages/processing/src/executors/dca/dca.executor.ts b/packages/processing/src/executors/dca/dca.executor.ts new file mode 100644 index 00000000..c7b92a52 --- /dev/null +++ b/packages/processing/src/executors/dca/dca.executor.ts @@ -0,0 +1,291 @@ +import { xprisma } from "@opentrader/db"; +import type { SmartTradeWithOrders } from "@opentrader/db"; +import { exchangeProvider } from "@opentrader/exchanges"; +import { logger } from "@opentrader/logger"; +import { XEntityType, XOrderStatus } from "@opentrader/types"; + +import type { ISmartTradeExecutor } from "../smart-trade-executor.interface.js"; +import { OrderExecutor } from "../order/order.executor.js"; + +export class DcaExecutor implements ISmartTradeExecutor { + smartTrade: SmartTradeWithOrders; + + constructor(smartTrade: SmartTradeWithOrders) { + this.smartTrade = smartTrade; + } + + static create(smartTrade: SmartTradeWithOrders) { + return new DcaExecutor(smartTrade); + } + + static async fromId(id: number) { + const smartTrade = await xprisma.smartTrade.findUniqueOrThrow({ + where: { + id, + }, + include: { + orders: true, + exchangeAccount: true, + }, + }); + + return new DcaExecutor(smartTrade); + } + + static async fromOrderId(orderId: number) { + const order = await xprisma.order.findUniqueOrThrow({ + where: { + id: orderId, + }, + include: { + smartTrade: { + include: { + orders: true, + exchangeAccount: true, + }, + }, + }, + }); + + return new DcaExecutor(order.smartTrade); + } + + static async fromExchangeOrderId(exchangeOrderId: string) { + const order = await xprisma.order.findFirstOrThrow({ + where: { + exchangeOrderId, + }, + include: { + smartTrade: { + include: { + orders: true, + exchangeAccount: true, + }, + }, + }, + }); + + return new DcaExecutor(order.smartTrade); + } + + /** + * Place both entry and take profit orders at the same time. + */ + async next(): Promise { + const entryOrder = this.smartTrade.orders.find((order) => order.entityType === XEntityType.EntryOrder)!; + const safetyOrders = this.smartTrade.orders.filter((order) => order.entityType === XEntityType.SafetyOrder); + let takeProfitOrder = this.smartTrade.orders.find((order) => order.entityType === XEntityType.TakeProfitOrder)!; + + if (entryOrder.status === XOrderStatus.Idle) { + const exchangeAccount = await xprisma.exchangeAccount.findUniqueOrThrow({ + where: { id: entryOrder.exchangeAccountId }, + }); + + const orderExecutor = new OrderExecutor( + entryOrder, + exchangeProvider.fromAccount(exchangeAccount), + entryOrder.symbol, + ); + + await orderExecutor.place(); + await this.pull(); + + logger.info( + `[DcaExecutor] ${entryOrder.type} entry order placed at price: ${entryOrder.price}. SmartTrade ID: ${this.smartTrade.id}`, + ); + + return true; + } + + let safetyOrdersToBePlaced = safetyOrders.filter((order) => order.status === XOrderStatus.Idle); + if ( + entryOrder.status === XOrderStatus.Filled && + (takeProfitOrder.status === XOrderStatus.Idle || safetyOrdersToBePlaced.length > 0) + ) { + const exchangeAccount = await xprisma.exchangeAccount.findUniqueOrThrow({ + where: { id: takeProfitOrder.exchangeAccountId }, + }); + + // Update the TP order price based on entry price + if (takeProfitOrder.status === XOrderStatus.Idle) { + const newTakeProfitOrderPrice = + entryOrder.filledPrice! + takeProfitOrder.relativePrice! * entryOrder.filledPrice!; + + takeProfitOrder = await xprisma.order.update({ + where: { id: takeProfitOrder.id }, + data: { + ...takeProfitOrder, + price: newTakeProfitOrderPrice, + }, + }); + + const orderExecutor = new OrderExecutor( + takeProfitOrder, + exchangeProvider.fromAccount(exchangeAccount), + takeProfitOrder.symbol, + ); + + await orderExecutor.place(); + await this.pull(); + + logger.info( + `[DcaExecutor] TP order placed at price: ${takeProfitOrder.price}. SmartTrade ID: ${this.smartTrade.id}`, + ); + } + + // Update safety orders prices and place the orders + if (safetyOrdersToBePlaced.length > 0) { + const exchangeAccount = await xprisma.exchangeAccount.findUniqueOrThrow({ + where: { id: entryOrder.exchangeAccountId }, + }); + logger.info(`[DcaExecutor] Placing ${safetyOrdersToBePlaced.length} Safety Orders...`); + + safetyOrdersToBePlaced = await Promise.all( + safetyOrdersToBePlaced.map(async (order) => { + const newPrice = entryOrder.filledPrice! + order.relativePrice! * entryOrder.filledPrice!; + + const updatedOrder = await xprisma.order.update({ + where: { id: order.id }, + data: { + ...order, + price: newPrice, + }, + }); + + return updatedOrder; + }), + ); + + for (const order of safetyOrdersToBePlaced) { + const orderExecutor = new OrderExecutor(order, exchangeProvider.fromAccount(exchangeAccount), order.symbol); + + await orderExecutor.place(); + await this.pull(); + + logger.info( + `[DcaExecutor] Safety order placed at price: ${order.price}. SmartTrade { id: ${this.smartTrade.id} }`, + ); + } + + return true; + } + + return true; + } + + if (entryOrder.status === XOrderStatus.Filled && takeProfitOrder.status === XOrderStatus.Filled) { + logger.info( + `Nothing to do: Position is already closed { id: ${this.smartTrade.id}, entryOrderStatus: ${entryOrder.status}, takeProfitOrderStatus: ${takeProfitOrder?.status} }`, + ); + + // Cancel safety orders if any + await this.cancelOrders(); + + return false; + } + + const filledSafetyOrders = safetyOrders.filter((order) => order.status === XOrderStatus.Filled); + if (filledSafetyOrders.length > 0) { + // calculating average entry price + const totalCost = + filledSafetyOrders.reduce((cost, order) => cost + order.quantity * order.filledPrice!, 0) + + entryOrder.quantity * entryOrder.filledPrice!; + const totalQty = filledSafetyOrders.reduce((qty, order) => qty + order.quantity, 0) + entryOrder.quantity; + const entryPrice = totalCost / totalQty; + + const newTakeProfitPrice = entryPrice + entryPrice * takeProfitOrder.relativePrice!; + logger.info( + `[DcaExecutor] New take profit price is ${newTakeProfitPrice} (+${takeProfitOrder.relativePrice! * 100}%)`, + ); + + // Difference in price may also be used a marker that TP order must be modified + if (totalQty > takeProfitOrder.quantity) { + logger.info(`[DcaExecutor] One of Safety Orders was filled. Updating TP order...`); + logger.info(` Price: ${takeProfitOrder.price} -> ${newTakeProfitPrice}`); + logger.info(` Qty: ${takeProfitOrder.quantity} -> ${totalQty}`); + + const exchangeAccount = await xprisma.exchangeAccount.findUniqueOrThrow({ + where: { id: takeProfitOrder.exchangeAccountId }, + }); + const exchange = exchangeProvider.fromAccount(exchangeAccount); + + // Place a new TP order with a new price + // The previous one will be canceled + const orderExecutor = new OrderExecutor(takeProfitOrder, exchange, takeProfitOrder.symbol); + await orderExecutor.modify({ + ...takeProfitOrder, + price: newTakeProfitPrice, + quantity: totalQty, + }); + + logger.info(`[DcaExecutor] Take Profit order updated successfully ID:${takeProfitOrder.id}`); + } + } + + return false; + } + + /** + * Cancel all orders linked to the smart trade. + * Return number of cancelled orders. + */ + async cancelOrders(): Promise { + const allOrders = []; + + for (const order of this.smartTrade.orders) { + const exchangeAccount = await xprisma.exchangeAccount.findUniqueOrThrow({ + where: { id: order.exchangeAccountId }, + }); + const exchange = exchangeProvider.fromAccount(exchangeAccount); + + const orderExecutor = new OrderExecutor(order, exchange, order.symbol); + + const cancelled = await orderExecutor.cancel(); + allOrders.push(cancelled); + } + + await xprisma.smartTrade.clearRef(this.smartTrade.id); + await this.pull(); + + const cancelledOrders = allOrders.filter((cancelled) => cancelled); + logger.info( + `[DcaExecutor] Orders were canceled: Position { id: ${this.smartTrade.id} }. Cancelled ${cancelledOrders.length} of ${allOrders.length} orders.`, + ); + + return cancelledOrders.length; + } + + get status(): "Entering" | "Exiting" | "Finished" { + const entryOrder = this.smartTrade.orders.find((order) => order.entityType === "EntryOrder")!; + const takeProfitOrder = this.smartTrade.orders.find((order) => order.entityType === "TakeProfitOrder"); + + if (entryOrder.status === "Idle" || entryOrder.status === "Placed") { + return "Entering"; + } + + if ( + entryOrder.status === "Filled" && + (takeProfitOrder?.status === "Idle" || takeProfitOrder?.status === "Placed") + ) { + return "Exiting"; + } + + return "Finished"; + } + + /** + * Pulls the order from the database to update the status. + * Call directly only for testing. + */ + async pull() { + this.smartTrade = await xprisma.smartTrade.findUniqueOrThrow({ + where: { + id: this.smartTrade.id, + }, + include: { + orders: true, + exchangeAccount: true, + }, + }); + } +} diff --git a/packages/processing/src/executors/order/order.executor.ts b/packages/processing/src/executors/order/order.executor.ts index 63010b5b..ca4095c7 100644 --- a/packages/processing/src/executors/order/order.executor.ts +++ b/packages/processing/src/executors/order/order.executor.ts @@ -1,12 +1,9 @@ import type { IExchange } from "@opentrader/exchanges"; +import { XOrderStatus } from "@opentrader/types"; import type { Order } from "@prisma/client"; import { logger } from "@opentrader/logger"; import type { OrderEntity } from "@opentrader/db"; -import { - assertHasExchangeOrderId, - toOrderEntity, - xprisma, -} from "@opentrader/db"; +import { assertHasExchangeOrderId, toOrderEntity, xprisma } from "@opentrader/db"; import { OrderNotFound } from "ccxt"; export class OrderExecutor { @@ -89,6 +86,24 @@ export class OrderExecutor { } } + /** + * Cancels the current order and replace a new order with new price. + */ + async modify(newOrder: Order): Promise { + await this.cancel(); + + const order = await xprisma.order.update({ + where: { id: newOrder.id }, + data: { + ...newOrder, + status: XOrderStatus.Idle, + }, + }); + this.order = toOrderEntity(order); + + return this.place(); + } + /** * Returns true if the order was canceled successfully. */ @@ -105,9 +120,7 @@ export class OrderExecutor { await xprisma.order.updateStatus("Revoked", this.order.id); await this.pullOrder(); - logger.info( - `Order was canceled (Idle → Revoked): Order { id: ${this.order.id}, status: ${this.order.status} }`, - ); + logger.info(`Order was canceled (Idle → Revoked): Order { id: ${this.order.id}, status: ${this.order.status} }`); return true; } @@ -151,11 +164,10 @@ export class OrderExecutor { } if (this.order.status === "Filled") { - await xprisma.order.removeRef(this.order.id); await this.pullOrder(); logger.info( - `Cannot cancel order because it is already Filled: Order { id: ${this.order.id}, status: ${this.order.status}. Removed ref.`, + `Cannot cancel order because it is already Filled: Order { id: ${this.order.id}, status: ${this.order.status}.`, ); return false; diff --git a/packages/processing/src/executors/smart-trade.executor.ts b/packages/processing/src/executors/smart-trade.executor.ts index f3062a41..cb7e145f 100644 --- a/packages/processing/src/executors/smart-trade.executor.ts +++ b/packages/processing/src/executors/smart-trade.executor.ts @@ -1,10 +1,11 @@ import type { ExchangeAccountWithCredentials, SmartTradeWithOrders } from "@opentrader/db"; import { xprisma } from "@opentrader/db"; import { exchangeProvider } from "@opentrader/exchanges"; +import { XSmartTradeType } from "@opentrader/types"; import type { ISmartTradeExecutor } from "./smart-trade-executor.interface.js"; import { TradeExecutor } from "./trade/trade.executor.js"; import { ArbExecutor } from "./arb/arb.executor.js"; -import { XSmartTradeType } from "@opentrader/types"; +import { DcaExecutor } from "./dca/dca.executor.js"; /** * Combine all type of SmartTrades into one executor. @@ -21,6 +22,8 @@ export class SmartTradeExecutor { return new TradeExecutor(smartTrade, exchange); case "ARB": return new ArbExecutor(smartTrade); + case "DCA": + return new DcaExecutor(smartTrade); default: throw new Error(`Unknown SmartTrade type: ${smartTrade.type}`); } @@ -43,6 +46,8 @@ export class SmartTradeExecutor { return new TradeExecutor(smartTrade, exchange); case "ARB": return new ArbExecutor(smartTrade); + case "DCA": + return new DcaExecutor(smartTrade); default: throw new Error(`Unknown SmartTrade type: ${smartTrade.type}`); } diff --git a/packages/processing/src/executors/trade/trade.executor.ts b/packages/processing/src/executors/trade/trade.executor.ts index fb11adfa..3a61c724 100644 --- a/packages/processing/src/executors/trade/trade.executor.ts +++ b/packages/processing/src/executors/trade/trade.executor.ts @@ -1,8 +1,5 @@ import { xprisma } from "@opentrader/db"; -import type { - SmartTradeWithOrders, - ExchangeAccountWithCredentials, -} from "@opentrader/db"; +import type { SmartTradeWithOrders, ExchangeAccountWithCredentials } from "@opentrader/db"; import type { IExchange } from "@opentrader/exchanges"; import { exchangeProvider } from "@opentrader/exchanges"; import { logger } from "@opentrader/logger"; @@ -18,10 +15,7 @@ export class TradeExecutor implements ISmartTradeExecutor { this.exchange = exchange; } - static create( - smartTrade: SmartTradeWithOrders, - exchangeAccount: ExchangeAccountWithCredentials, - ) { + static create(smartTrade: SmartTradeWithOrders, exchangeAccount: ExchangeAccountWithCredentials) { const exchange = exchangeProvider.fromAccount(exchangeAccount); return new TradeExecutor(smartTrade, exchange); @@ -58,9 +52,7 @@ export class TradeExecutor implements ISmartTradeExecutor { }, }); - const exchange = exchangeProvider.fromAccount( - order.smartTrade.exchangeAccount, - ); + const exchange = exchangeProvider.fromAccount(order.smartTrade.exchangeAccount); return new TradeExecutor(order.smartTrade, exchange); } @@ -80,9 +72,7 @@ export class TradeExecutor implements ISmartTradeExecutor { }, }); - const exchange = exchangeProvider.fromAccount( - order.smartTrade.exchangeAccount, - ); + const exchange = exchangeProvider.fromAccount(order.smartTrade.exchangeAccount); return new TradeExecutor(order.smartTrade, exchange); } @@ -92,36 +82,19 @@ export class TradeExecutor implements ISmartTradeExecutor { * Returns `true` if the order was placed successfully. */ async next(): Promise { - const entryOrder = this.smartTrade.orders.find( - (order) => order.entityType === "EntryOrder", - )!; - const takeProfitOrder = this.smartTrade.orders.find( - (order) => order.entityType === "TakeProfitOrder", - ); + const entryOrder = this.smartTrade.orders.find((order) => order.entityType === "EntryOrder")!; + const takeProfitOrder = this.smartTrade.orders.find((order) => order.entityType === "TakeProfitOrder"); if (entryOrder.status === "Idle") { - const orderExecutor = new OrderExecutor( - entryOrder, - this.exchange, - this.smartTrade.symbol, - ); + const orderExecutor = new OrderExecutor(entryOrder, this.exchange, this.smartTrade.symbol); await orderExecutor.place(); await this.pull(); - logger.info( - `Entry order was placed: Position { id: ${this.smartTrade.id} }`, - ); + logger.info(`Entry order was placed: Position { id: ${this.smartTrade.id} }`); return true; - } else if ( - entryOrder.status === "Filled" && - takeProfitOrder?.status === "Idle" - ) { - const orderExecutor = new OrderExecutor( - takeProfitOrder, - this.exchange, - this.smartTrade.symbol, - ); + } else if (entryOrder.status === "Filled" && takeProfitOrder?.status === "Idle") { + const orderExecutor = new OrderExecutor(takeProfitOrder, this.exchange, this.smartTrade.symbol); await orderExecutor.place(); await this.pull(); @@ -146,16 +119,13 @@ export class TradeExecutor implements ISmartTradeExecutor { const allOrders = []; for (const order of this.smartTrade.orders) { - const orderExecutor = new OrderExecutor( - order, - this.exchange, - this.smartTrade.symbol, - ); + const orderExecutor = new OrderExecutor(order, this.exchange, this.smartTrade.symbol); const cancelled = await orderExecutor.cancel(); allOrders.push(cancelled); } + await xprisma.smartTrade.clearRef(this.smartTrade.id); await this.pull(); const cancelledOrders = allOrders.filter((cancelled) => cancelled); @@ -167,12 +137,8 @@ export class TradeExecutor implements ISmartTradeExecutor { } get status(): "Entering" | "Exiting" | "Finished" { - const entryOrder = this.smartTrade.orders.find( - (order) => order.entityType === "EntryOrder", - )!; - const takeProfitOrder = this.smartTrade.orders.find( - (order) => order.entityType === "TakeProfitOrder", - ); + const entryOrder = this.smartTrade.orders.find((order) => order.entityType === "EntryOrder")!; + const takeProfitOrder = this.smartTrade.orders.find((order) => order.entityType === "TakeProfitOrder"); if (entryOrder.status === "Idle" || entryOrder.status === "Placed") { return "Entering"; @@ -180,8 +146,7 @@ export class TradeExecutor implements ISmartTradeExecutor { if ( entryOrder.status === "Filled" && - (takeProfitOrder?.status === "Idle" || - takeProfitOrder?.status === "Placed") + (takeProfitOrder?.status === "Idle" || takeProfitOrder?.status === "Placed") ) { return "Exiting"; }