diff --git a/packages/backtesting/src/backtesting-report.ts b/packages/backtesting/src/backtesting-report.ts index 067359d9..2e47aee6 100644 --- a/packages/backtesting/src/backtesting-report.ts +++ b/packages/backtesting/src/backtesting-report.ts @@ -24,7 +24,10 @@ export class BacktestingReport { finishedSmartTrades.forEach((smartTrade) => { transactions.push(buyTransaction(smartTrade)); - transactions.push(sellTransaction(smartTrade)); + + if (smartTrade.sell) { + transactions.push(sellTransaction(smartTrade)); + } }); return transactions; @@ -37,7 +40,10 @@ export class BacktestingReport { smartTrades.forEach((smartTrade) => { activeOrders.push(buyOrder(smartTrade)); - activeOrders.push(sellOrder(smartTrade)); + + if (smartTrade.sell) { + activeOrders.push(sellOrder(smartTrade)); + } }); return activeOrders; @@ -57,7 +63,7 @@ export class BacktestingReport { return this.smartTrades.filter( (smartTrade) => smartTrade.buy.status === OrderStatusEnum.Placed || - smartTrade.sell.status === OrderStatusEnum.Placed, + smartTrade.sell?.status === OrderStatusEnum.Placed, ); } @@ -65,7 +71,7 @@ export class BacktestingReport { return this.smartTrades.filter((smartTrade) => { return ( smartTrade.buy.status === OrderStatusEnum.Filled && - smartTrade.sell.status === OrderStatusEnum.Filled + smartTrade.sell?.status === OrderStatusEnum.Filled ); }); } diff --git a/packages/backtesting/src/backtesting.ts b/packages/backtesting/src/backtesting.ts index a7552b1e..7f478efd 100644 --- a/packages/backtesting/src/backtesting.ts +++ b/packages/backtesting/src/backtesting.ts @@ -1,9 +1,9 @@ import type { IBotConfiguration, - BotManager, + StrategyRunner, BotTemplate, } from "@opentrader/bot-processor"; -import { BotProcessor } from "@opentrader/bot-processor"; +import { createStrategyRunner } from "@opentrader/bot-processor"; import type { ICandlestick } from "@opentrader/types"; import { logger, format } from "@opentrader/logger"; import { fulfilledTable, gridTable } from "./debugging"; @@ -17,7 +17,7 @@ export class Backtesting> { private marketSimulator: MarketSimulator; private store: MemoryStore; private exchange: MemoryExchange; - private processor: BotManager; + private processor: StrategyRunner; constructor(options: { botConfig: T; botTemplate: BotTemplate }) { const { botConfig, botTemplate } = options; @@ -26,7 +26,7 @@ export class Backtesting> { this.store = new MemoryStore(this.marketSimulator); this.exchange = new MemoryExchange(this.marketSimulator); - this.processor = BotProcessor.create({ + this.processor = createStrategyRunner({ store: this.store, exchange: this.exchange, botConfig, diff --git a/packages/backtesting/src/debugging/fulfilledTable.ts b/packages/backtesting/src/debugging/fulfilledTable.ts index 9b9415d5..f1bc1b3a 100644 --- a/packages/backtesting/src/debugging/fulfilledTable.ts +++ b/packages/backtesting/src/debugging/fulfilledTable.ts @@ -7,17 +7,17 @@ export function fulfilledTable(smartTrades: SmartTrade[]) { const isBuy = buy.status === OrderStatusEnum.Placed && - sell.status === OrderStatusEnum.Idle; + (!sell || sell.status === OrderStatusEnum.Idle); const isSell = buy.status === OrderStatusEnum.Filled && - sell.status === OrderStatusEnum.Placed; + sell?.status === OrderStatusEnum.Placed; const isBuyFilled = buy.status === OrderStatusEnum.Filled && - sell.status === OrderStatusEnum.Idle; + (!sell || sell.status === OrderStatusEnum.Idle); const isSellFilled = buy.status === OrderStatusEnum.Filled && - sell.status === OrderStatusEnum.Filled; + sell?.status === OrderStatusEnum.Filled; const prevSmartTrade = smartTrades[i - 1]; const isCurrent = @@ -33,7 +33,7 @@ export function fulfilledTable(smartTrades: SmartTrade[]) { const price = side === "sell" - ? smartTrade.sell.price + ? smartTrade.sell?.price : side === "buy" ? smartTrade.buy.price : "unknown"; @@ -45,7 +45,7 @@ export function fulfilledTable(smartTrades: SmartTrade[]) { side, price, buy: smartTrade.buy.price, - sell: smartTrade.sell.price, + sell: smartTrade.sell?.price, filled: isBuyFilled ? "buy filled" : isSellFilled ? "sell filled" : "", }; diff --git a/packages/backtesting/src/debugging/gridTable.ts b/packages/backtesting/src/debugging/gridTable.ts index 338e36ca..f81b7564 100644 --- a/packages/backtesting/src/debugging/gridTable.ts +++ b/packages/backtesting/src/debugging/gridTable.ts @@ -7,10 +7,10 @@ export function gridTable(smartTrades: SmartTrade[]) { const isBuy = buy.status === OrderStatusEnum.Placed && - sell.status === OrderStatusEnum.Idle; + (!sell || sell.status === OrderStatusEnum.Idle); const isSell = buy.status === OrderStatusEnum.Filled && - sell.status === OrderStatusEnum.Placed; + sell?.status === OrderStatusEnum.Placed; const prevSmartTrade = smartTrades[i - 1]; const isCurrent = @@ -20,7 +20,7 @@ export function gridTable(smartTrades: SmartTrade[]) { const price = side === "sell" - ? smartTrade.sell.price + ? smartTrade.sell?.price : side === "buy" ? smartTrade.buy.price : "unknown"; @@ -32,7 +32,7 @@ export function gridTable(smartTrades: SmartTrade[]) { side, price, buy: smartTrade.buy.price, - sell: smartTrade.sell.price, + sell: smartTrade.sell?.price, }; if (isCurrent) { diff --git a/packages/backtesting/src/report/sellOrder.ts b/packages/backtesting/src/report/sellOrder.ts index 9baf0fe0..baedd61a 100644 --- a/packages/backtesting/src/report/sellOrder.ts +++ b/packages/backtesting/src/report/sellOrder.ts @@ -1,8 +1,8 @@ -import type { SmartTrade } from "@opentrader/bot-processor"; +import { SmartTradeWithSell } from "@opentrader/bot-processor"; import { OrderSideEnum } from "@opentrader/types"; import type { ActiveOrder } from "../types"; -export function sellOrder(smartTrade: SmartTrade): ActiveOrder { +export function sellOrder(smartTrade: SmartTradeWithSell): ActiveOrder { return { side: OrderSideEnum.Sell, quantity: smartTrade.quantity, diff --git a/packages/backtesting/src/report/sellTransaction.ts b/packages/backtesting/src/report/sellTransaction.ts index 731c0105..b3c93773 100644 --- a/packages/backtesting/src/report/sellTransaction.ts +++ b/packages/backtesting/src/report/sellTransaction.ts @@ -1,8 +1,10 @@ -import type { SmartTrade } from "@opentrader/bot-processor"; +import type { SmartTradeWithSell } from "@opentrader/bot-processor"; import { OrderSideEnum } from "@opentrader/types"; import type { SellTransaction } from "../types"; -export function sellTransaction(smartTrade: SmartTrade): SellTransaction { +export function sellTransaction( + smartTrade: SmartTradeWithSell, +): SellTransaction { const { buy, sell, quantity, id } = smartTrade; return { diff --git a/packages/backtesting/src/store/memory-store.ts b/packages/backtesting/src/store/memory-store.ts index b9f98e8c..cf1439a0 100644 --- a/packages/backtesting/src/store/memory-store.ts +++ b/packages/backtesting/src/store/memory-store.ts @@ -3,6 +3,7 @@ import type { SmartTrade, UseSmartTradePayload, } from "@opentrader/bot-processor"; +import type { IExchange } from "@opentrader/exchanges"; import { OrderStatusEnum } from "@opentrader/types"; import uniqueId from "lodash/uniqueId"; import type { MarketSimulator } from "../market-simulator"; @@ -61,12 +62,14 @@ export class MemoryStore implements IStore { createdAt, updatedAt: createdAt, }, - sell: { - price: sell.price, - status: sell.status || OrderStatusEnum.Idle, - createdAt, - updatedAt: createdAt, - }, + sell: sell + ? { + price: sell.price, + status: sell.status || OrderStatusEnum.Idle, + createdAt, + updatedAt: createdAt, + } + : undefined, quantity, }; @@ -75,8 +78,53 @@ export class MemoryStore implements IStore { return smartTrade; } + async updateSmartTrade( + ref: string, + payload: Pick, + botId: number, + ) { + if (!payload.sell) { + console.log( + "MemoryStore: Unable to update smart trade. Reason: `payload.sell` not provided.", + ); + return null; + } + + const smartTrade = await this.getSmartTrade(ref, botId); + + if (!smartTrade) { + return null; + } + + const candlestick = this.marketSimulator.currentCandle; + const updatedAt = candlestick.timestamp; + + if (smartTrade.sell) { + console.log( + "MemoryStore: SmartTrade already has a sell order. Skipping.", + ); + return smartTrade; + } + + const updatedSmartTrade: SmartTrade = { + ...smartTrade, + sell: { + price: payload.sell.price, + status: payload.sell.status || OrderStatusEnum.Idle, + createdAt: updatedAt, + updatedAt, + }, + }; + + return updatedSmartTrade; + } + async cancelSmartTrade(_ref: string, _botId: number): Promise { return false; // @todo // throw new Error("Not implemented yet."); } + + async getExchange(_label: string): Promise { + throw new Error("Not implemented yet."); + } } diff --git a/packages/bot-processor/src/bot-control.ts b/packages/bot-processor/src/bot-control.ts index e2f74f20..f7f72ef0 100644 --- a/packages/bot-processor/src/bot-control.ts +++ b/packages/bot-processor/src/bot-control.ts @@ -1,9 +1,11 @@ import { OrderStatusEnum } from "@opentrader/types"; -import type { UseSmartTradePayload } from "./effects/common/types/use-smart-trade-effect"; -import type { IBotConfiguration } from "./types/bot/bot-configuration.interface"; -import type { IBotControl } from "./types/bot/bot-control.interface"; -import type { SmartTrade } from "./types/smart-trade/smart-trade.type"; -import type { IStore } from "./types/store/store.interface"; +import type { UseSmartTradePayload } from "./effects"; +import type { + IBotConfiguration, + IBotControl, + SmartTrade, + IStore, +} from "./types"; export class BotControl implements IBotControl { constructor( @@ -23,6 +25,13 @@ export class BotControl implements IBotControl { return this.store.createSmartTrade(ref, payload, this.bot.id); } + async updateSmartTrade( + ref: string, + payload: Pick, + ) { + return this.store.updateSmartTrade(ref, payload, this.bot.id); + } + async getOrCreateSmartTrade( ref: string, payload: UseSmartTradePayload, @@ -45,10 +54,12 @@ export class BotControl implements IBotControl { price: smartTrade.buy.price, status: OrderStatusEnum.Idle, }, - sell: { - price: smartTrade.sell.price, - status: OrderStatusEnum.Idle, - }, + sell: smartTrade.sell + ? { + price: smartTrade.sell.price, + status: OrderStatusEnum.Idle, + } + : undefined, quantity: smartTrade.quantity, }; @@ -58,4 +69,8 @@ export class BotControl implements IBotControl { async cancelSmartTrade(ref: string) { return this.store.cancelSmartTrade(ref, this.bot.id); } + + async getExchange(label: string) { + return this.store.getExchange(label); + } } diff --git a/packages/bot-processor/src/bot-manager.ts b/packages/bot-processor/src/bot-manager.ts deleted file mode 100644 index 4ea95519..00000000 --- a/packages/bot-processor/src/bot-manager.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { IExchange } from "@opentrader/exchanges"; -import { computeIndicators } from "@opentrader/indicators"; -import type { IndicatorBarSize } from "@opentrader/types"; -import { lastClosedCandleDate } from "@opentrader/tools"; -import type { TBotContext } from "./types/bot/bot-context.type"; -import { createContext } from "./utils/createContext"; -import { SmartTradeService } from "./smart-trade.service"; -import type { IBotConfiguration, BotTemplate, IBotControl } from "./types"; -import { isReplaceSmartTradeEffect } from "./effects/utils/isReplaceSmartTradeEffect"; -import { isUseExchangeEffect } from "./effects/utils/isUseExchangeEffect"; -import { isUseSmartTradeEffect } from "./effects/utils/isUseSmartTradeEffect"; -import { isCancelSmartTradeEffect } from "./effects/utils/isCancelSmartTradeEffect"; -import { isUseIndicatorsEffect } from "./effects/utils/isUseIndicatorsEffect"; -import { isGetSmartTradeEffect } from "./effects/utils/isGetSmartTradeEffect"; -import { isCreateSmartTradeEffect } from "./effects/utils/isCreateSmartTradeEffect"; - -export class BotManager { - constructor( - private control: IBotControl, - private botConfig: T, - private exchange: IExchange, - private botTemplate: BotTemplate, - ) {} - - async start() { - const context = createContext(this.control, this.botConfig, "start"); - - await this._process(context); - } - - async stop() { - const context = createContext(this.control, this.botConfig, "stop"); - - await this._process(context); - } - - async process() { - const context = createContext(this.control, this.botConfig, "process"); - - await this._process(context); - } - - private async _process(context: TBotContext): Promise { - const processingDate = Date.now(); // @todo better to pass it through context - const generator = this.botTemplate(context); - - let item = generator.next(); - - for (; !item.done; ) { - if (item.value instanceof Promise) { - const result = await item.value; - - item = generator.next(result); - } else if (isUseSmartTradeEffect(item.value)) { - const effect = item.value; - - const smartTrade = await this.control.getOrCreateSmartTrade( - effect.ref, - effect.payload, - ); - const smartTradeService = new SmartTradeService(effect.ref, smartTrade); - - item = generator.next(smartTradeService); - } else if (isReplaceSmartTradeEffect(item.value)) { - const effect = item.value; - - const smartTrade = await this.control.replaceSmartTrade( - effect.ref, - effect.payload, - ); - const smartTradeService = new SmartTradeService(effect.ref, smartTrade); - - item = generator.next(smartTradeService); - } else if (isCancelSmartTradeEffect(item.value)) { - const effect = item.value; - - await this.control.cancelSmartTrade(effect.ref); - - item = generator.next(); - } else if (isGetSmartTradeEffect(item.value)) { - const effect = item.value; - - const smartTrade = await this.control.getSmartTrade(effect.ref); - const smartTradeService = smartTrade - ? new SmartTradeService(effect.ref, smartTrade) - : null; - - item = generator.next(smartTradeService); - } else if (isCreateSmartTradeEffect(item.value)) { - const effect = item.value; - - const smartTrade = await this.control.createSmartTrade( - effect.ref, - effect.payload, - ); - const smartTradeService = new SmartTradeService(effect.ref, smartTrade); - - item = generator.next(smartTradeService); - } else if (isUseExchangeEffect(item.value)) { - item = generator.next(this.exchange); - } else if (isUseIndicatorsEffect(item.value)) { - const effect = item.value; - const barSize = effect.payload.barSize as IndicatorBarSize; // @todo fix eslint error - const { exchangeCode, baseCurrency, quoteCurrency } = this.botConfig; - - console.log(`Compute indicators`, effect.payload); - const indicators = await computeIndicators({ - exchangeCode, - symbol: `${baseCurrency}/${quoteCurrency}`, - barSize, - untilDate: lastClosedCandleDate(processingDate, barSize), - indicators: effect.payload.indicators, - }); - console.log("Indicators computed", indicators); - - item = generator.next(indicators); - } else { - console.log(item.value); - throw new Error("Unsupported effect"); - } - } - } -} diff --git a/packages/bot-processor/src/bot-processor.ts b/packages/bot-processor/src/bot-processor.ts deleted file mode 100644 index 7455f03a..00000000 --- a/packages/bot-processor/src/bot-processor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { IExchange } from "@opentrader/exchanges"; -import { BotControl } from "./bot-control"; -import { BotManager } from "./bot-manager"; -import type { IBotConfiguration } from "./types/bot/bot-configuration.interface"; -import type { BotTemplate } from "./types/bot/bot-template.type"; -import type { IStore } from "./types/store/store.interface"; - -export class BotProcessor { - static create(options: { - store: IStore; - exchange: IExchange; - botConfig: T; - botTemplate: BotTemplate; - }) { - const { botConfig, store, exchange, botTemplate } = options; - - const botControl = new BotControl(store, botConfig); - const manager = new BotManager( - botControl, - botConfig, - exchange, - botTemplate, - ); - - return manager; - } -} diff --git a/packages/bot-processor/src/effect-runner.ts b/packages/bot-processor/src/effect-runner.ts new file mode 100644 index 00000000..f97d4fc4 --- /dev/null +++ b/packages/bot-processor/src/effect-runner.ts @@ -0,0 +1,195 @@ +import { OrderSideEnum, OrderStatusEnum } from "@opentrader/types"; +import { TradeService, SmartTradeService } from "./types"; +import type { TBotContext } from "./types"; +import type { BaseEffect, EffectType } from "./effects/types"; +import type { + buy, + useTrade, + sell, + useExchange, + useSmartTrade, + getSmartTrade, + cancelSmartTrade, + createSmartTrade, + replaceSmartTrade, +} from "./effects"; +import { + BUY, + CANCEL_SMART_TRADE, + CREATE_SMART_TRADE, + GET_SMART_TRADE, + REPLACE_SMART_TRADE, + SELL, + USE_EXCHANGE, + USE_INDICATORS, + USE_SMART_TRADE, + USE_TRADE, +} from "./effects"; + +export const effectRunnerMap: Record< + EffectType, + (effect: BaseEffect, ctx: TBotContext) => unknown +> = { + [USE_SMART_TRADE]: runUseSmartTradeEffect, + [GET_SMART_TRADE]: runGetSmartTradeEffect, + [CANCEL_SMART_TRADE]: runCancelSmartTradeEffect, + [CREATE_SMART_TRADE]: runCreateSmartTradeEffect, + [REPLACE_SMART_TRADE]: runReplaceSmartTradeEffect, + [USE_TRADE]: runUseTradeEffect, + [BUY]: runBuyEffect, + [SELL]: runSellEffect, + [USE_EXCHANGE]: runUseExchangeEffect, + [USE_INDICATORS]: () => { + throw new Error("useIndicators() hook is deprecated"); + }, +}; + +async function runUseSmartTradeEffect( + effect: ReturnType, + ctx: TBotContext, +): Promise { + const smartTrade = await ctx.control.getOrCreateSmartTrade( + effect.ref, + effect.payload, + ); + + return new SmartTradeService(effect.ref, smartTrade); +} + +async function runGetSmartTradeEffect( + effect: ReturnType, + ctx: TBotContext, +) { + const smartTrade = await ctx.control.getSmartTrade(effect.ref); + + return smartTrade ? new SmartTradeService(effect.ref, smartTrade) : null; +} + +async function runCancelSmartTradeEffect( + effect: ReturnType, + ctx: TBotContext, +) { + return ctx.control.cancelSmartTrade(effect.ref); +} + +async function runCreateSmartTradeEffect( + effect: ReturnType, + ctx: TBotContext, +) { + const smartTrade = await ctx.control.createSmartTrade( + effect.ref, + effect.payload, + ); + + return new SmartTradeService(effect.ref, smartTrade); +} + +async function runReplaceSmartTradeEffect( + effect: ReturnType, + ctx: TBotContext, +) { + const smartTrade = await ctx.control.replaceSmartTrade( + effect.ref, + effect.payload, + ); + + return new SmartTradeService(effect.ref, smartTrade); +} + +async function runUseTradeEffect( + effect: ReturnType, + ctx: TBotContext, +) { + const { payload, ref } = effect; + + const smartTrade = await ctx.control.getOrCreateSmartTrade(ref, { + quantity: payload.quantity, + buy: { + status: + payload.side === OrderSideEnum.Buy + ? OrderStatusEnum.Idle + : OrderStatusEnum.Filled, + price: payload.price!, // @todo type + }, + sell: { + status: OrderStatusEnum.Idle, + price: effect.payload.tp!, // @todo type + }, + }); + + return new SmartTradeService(effect.ref, smartTrade); +} + +async function runBuyEffect( + effect: ReturnType, + ctx: TBotContext, +) { + const { payload, ref } = effect; + + const smartTrade = await ctx.control.getOrCreateSmartTrade(ref, { + quantity: payload.quantity, + buy: { + status: OrderStatusEnum.Idle, + price: payload.price!, // @todo type + }, + }); + + return new TradeService(ref, smartTrade); +} + +async function runSellEffect( + effect: ReturnType, + ctx: TBotContext, +) { + let smartTrade = await ctx.control.getSmartTrade(effect.ref); + if (!smartTrade) { + console.info("Skip selling effect. Reason: Not bought before"); + + return null; + } + + if ( + smartTrade.buy.status === OrderStatusEnum.Idle || + smartTrade.buy.status === OrderStatusEnum.Placed + ) { + console.info("Skip selling effect. Reason: Buy order not filled yet"); + + return null; + } + + if (smartTrade.sell) { + console.info("Skip selling advice. Reason: Already selling"); + + return null; + } + + smartTrade = await ctx.control.updateSmartTrade(effect.ref, { + sell: { + status: OrderStatusEnum.Idle, + price: effect.payload.price!, // @todo type + }, + }); + + if (!smartTrade) { + console.warn("Skip selling advice. Smart trade not found."); + + return null; + } + + return new TradeService(effect.ref, smartTrade); +} + +async function runUseExchangeEffect( + effect: ReturnType, + ctx: TBotContext, +) { + const label = effect.payload; + + if (label) { + console.log(`[UseExchange] Using external exchange with label: ${label}`); + + return ctx.control.getExchange(label); + } + + return ctx.exchange; +} diff --git a/packages/bot-processor/src/effects/buy.ts b/packages/bot-processor/src/effects/buy.ts new file mode 100644 index 00000000..fdbc4ef6 --- /dev/null +++ b/packages/bot-processor/src/effects/buy.ts @@ -0,0 +1,15 @@ +import type { ExchangeCode } from "@opentrader/types"; +import { BUY } from "./types"; +import { makeEffect } from "./utils"; + +export type BuyPayload = { + exchange: ExchangeCode; + pair: string; + quantity: number; + price?: number; // if not provided, a market order will be placed + orderType?: "Limit" | "Market"; +}; + +export function buy(payload: BuyPayload, ref = "0") { + return makeEffect(BUY, payload, ref); +}; diff --git a/packages/bot-processor/src/effects/cancelSmartTrade.ts b/packages/bot-processor/src/effects/cancelSmartTrade.ts deleted file mode 100644 index d650e1cb..00000000 --- a/packages/bot-processor/src/effects/cancelSmartTrade.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { CancelSmartTradeEffect } from "./common/types/cancel-smart-trade-effect"; -import { CANCEL_SMART_TRADE } from "./common/types/effect-types"; -import { makeEffect } from "./utils/make-effect"; - -export function cancelSmartTrade(ref: string): CancelSmartTradeEffect { - return makeEffect(CANCEL_SMART_TRADE, undefined, ref); -} diff --git a/packages/bot-processor/src/effects/common/index.ts b/packages/bot-processor/src/effects/common/index.ts deleted file mode 100644 index eea524d6..00000000 --- a/packages/bot-processor/src/effects/common/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./types"; diff --git a/packages/bot-processor/src/effects/common/types/base-effect.ts b/packages/bot-processor/src/effects/common/types/base-effect.ts deleted file mode 100644 index 383c8603..00000000 --- a/packages/bot-processor/src/effects/common/types/base-effect.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type BaseEffect = { - type: T; - ref: R; - payload: P; -}; diff --git a/packages/bot-processor/src/effects/common/types/cancel-smart-trade-effect.ts b/packages/bot-processor/src/effects/common/types/cancel-smart-trade-effect.ts deleted file mode 100644 index feadcbff..00000000 --- a/packages/bot-processor/src/effects/common/types/cancel-smart-trade-effect.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { BaseEffect } from "./base-effect"; -import type { CANCEL_SMART_TRADE } from "./effect-types"; - -export type CancelSmartTradeEffect = BaseEffect< - typeof CANCEL_SMART_TRADE, - undefined, - string ->; diff --git a/packages/bot-processor/src/effects/common/types/create-smart-trade-effect.ts b/packages/bot-processor/src/effects/common/types/create-smart-trade-effect.ts deleted file mode 100644 index 8b24ca9b..00000000 --- a/packages/bot-processor/src/effects/common/types/create-smart-trade-effect.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { BaseEffect } from "./base-effect"; -import type { CREATE_SMART_TRADE } from "./effect-types"; - -export type CreateSmartTradePayload = { - buy: { - price: number; - }; - sell: { - price: number; - }; - quantity: number; -}; - -export type CreateSmartTradeEffect = BaseEffect< - typeof CREATE_SMART_TRADE, - CreateSmartTradePayload, - string ->; diff --git a/packages/bot-processor/src/effects/common/types/effects.ts b/packages/bot-processor/src/effects/common/types/effects.ts deleted file mode 100644 index a73eef05..00000000 --- a/packages/bot-processor/src/effects/common/types/effects.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { CancelSmartTradeEffect } from "./cancel-smart-trade-effect"; -import type { ReplaceSmartTradeEffect } from "./replace-smart-trade-effect"; -import type { UseExchangeEffect } from "./use-exchange-effect"; -import type { UseSmartTradeEffect } from "./use-smart-trade-effect"; - -// @todo not used -export type Effect = - | UseSmartTradeEffect - | ReplaceSmartTradeEffect - | CancelSmartTradeEffect - | UseExchangeEffect; diff --git a/packages/bot-processor/src/effects/common/types/get-smart-trade-effect.ts b/packages/bot-processor/src/effects/common/types/get-smart-trade-effect.ts deleted file mode 100644 index cef72451..00000000 --- a/packages/bot-processor/src/effects/common/types/get-smart-trade-effect.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { BaseEffect } from "./base-effect"; -import type { GET_SMART_TRADE } from "./effect-types"; - -export type GetSmartTradeEffect = BaseEffect< - typeof GET_SMART_TRADE, - undefined, - string ->; diff --git a/packages/bot-processor/src/effects/common/types/index.ts b/packages/bot-processor/src/effects/common/types/index.ts deleted file mode 100644 index 0780f765..00000000 --- a/packages/bot-processor/src/effects/common/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./use-exchange-effect"; -export * from "./replace-smart-trade-effect"; -export * from "./use-smart-trade-effect"; diff --git a/packages/bot-processor/src/effects/common/types/replace-smart-trade-effect.ts b/packages/bot-processor/src/effects/common/types/replace-smart-trade-effect.ts deleted file mode 100644 index c04fb935..00000000 --- a/packages/bot-processor/src/effects/common/types/replace-smart-trade-effect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SmartTrade } from "../../../types"; -import type { BaseEffect } from "./base-effect"; -import type { REPLACE_SMART_TRADE } from "./effect-types"; - -export type ReplaceSmartTradeEffect = BaseEffect< - typeof REPLACE_SMART_TRADE, - SmartTrade, - string ->; diff --git a/packages/bot-processor/src/effects/common/types/use-exchange-effect.ts b/packages/bot-processor/src/effects/common/types/use-exchange-effect.ts deleted file mode 100644 index 5d005d2d..00000000 --- a/packages/bot-processor/src/effects/common/types/use-exchange-effect.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { IExchange } from "@opentrader/exchanges"; -import type { BaseEffect } from "./base-effect"; -import type { USE_EXCHANGE } from "./effect-types"; - -export type UseExchangeEffect = BaseEffect; diff --git a/packages/bot-processor/src/effects/common/types/use-indicators-effect.ts b/packages/bot-processor/src/effects/common/types/use-indicators-effect.ts deleted file mode 100644 index 3fe3492b..00000000 --- a/packages/bot-processor/src/effects/common/types/use-indicators-effect.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IndicatorBarSize, IndicatorName } from "@opentrader/types"; -import type { BaseEffect } from "./base-effect"; -import type { USE_INDICATORS } from "./effect-types"; - -export type UseIndicatorsEffect = BaseEffect< - typeof USE_INDICATORS, - { - indicators: IndicatorName[]; - barSize: IndicatorBarSize; - } ->; diff --git a/packages/bot-processor/src/effects/common/types/use-smart-trade-effect.ts b/packages/bot-processor/src/effects/common/types/use-smart-trade-effect.ts deleted file mode 100644 index 8186d7b5..00000000 --- a/packages/bot-processor/src/effects/common/types/use-smart-trade-effect.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { OrderStatusEnum } from "@opentrader/types"; -import type { BaseEffect } from "./base-effect"; -import type { USE_SMART_TRADE } from "./effect-types"; - -type SmartTradePayload = { - buy: { - status?: OrderStatusEnum; // default to Idle - price: number; - }; - sell: { - status?: OrderStatusEnum; // default to Idle - price: number; - }; - quantity: number; -}; - -export type UseSmartTradePayload = SmartTradePayload; - -export type UseSmartTradeEffect = BaseEffect< - typeof USE_SMART_TRADE, - UseSmartTradePayload, - string ->; diff --git a/packages/bot-processor/src/effects/createSmartTrade.ts b/packages/bot-processor/src/effects/createSmartTrade.ts deleted file mode 100644 index 966cec53..00000000 --- a/packages/bot-processor/src/effects/createSmartTrade.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { - CreateSmartTradeEffect, - CreateSmartTradePayload, -} from "./common/types/create-smart-trade-effect"; -import { CREATE_SMART_TRADE } from "./common/types/effect-types"; -import { makeEffect } from "./utils/make-effect"; - -export function createSmartTrade( - ref: string, - params: CreateSmartTradePayload, -): CreateSmartTradeEffect { - return makeEffect(CREATE_SMART_TRADE, params, ref); -} diff --git a/packages/bot-processor/src/effects/getSmartTrade.ts b/packages/bot-processor/src/effects/getSmartTrade.ts deleted file mode 100644 index f9894551..00000000 --- a/packages/bot-processor/src/effects/getSmartTrade.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { GetSmartTradeEffect } from "./common/types/get-smart-trade-effect"; -import { GET_SMART_TRADE } from "./common/types/effect-types"; -import { makeEffect } from "./utils/make-effect"; - -export function getSmartTrade(ref: string): GetSmartTradeEffect { - return makeEffect(GET_SMART_TRADE, undefined, ref); -} diff --git a/packages/bot-processor/src/effects/index.ts b/packages/bot-processor/src/effects/index.ts index 19d7fc79..d8575b3d 100644 --- a/packages/bot-processor/src/effects/index.ts +++ b/packages/bot-processor/src/effects/index.ts @@ -1,8 +1,8 @@ -export * from "./common"; -export * from "./useSmartTrade"; -export * from "./getSmartTrade"; -export * from "./createSmartTrade"; -export * from "./replaceSmartTrade"; -export * from "./cancelSmartTrade"; +export * from "./types/effect-types"; +export * from "./utils"; +export * from "./smart-trade"; +export * from "./buy"; +export * from "./sell"; export * from "./useExchange"; export * from "./useIndicators"; +export * from "./useTrade"; diff --git a/packages/bot-processor/src/effects/replaceSmartTrade.ts b/packages/bot-processor/src/effects/replaceSmartTrade.ts deleted file mode 100644 index d70312a1..00000000 --- a/packages/bot-processor/src/effects/replaceSmartTrade.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { SmartTrade } from "../types"; -import { REPLACE_SMART_TRADE } from "./common/types/effect-types"; -import type { ReplaceSmartTradeEffect } from "./common/types/replace-smart-trade-effect"; -import { makeEffect } from "./utils/make-effect"; - -export function replaceSmartTrade( - ref: string, - params: SmartTrade, -): ReplaceSmartTradeEffect { - return makeEffect(REPLACE_SMART_TRADE, params, ref); -} diff --git a/packages/bot-processor/src/effects/sell.ts b/packages/bot-processor/src/effects/sell.ts new file mode 100644 index 00000000..9f6f9c76 --- /dev/null +++ b/packages/bot-processor/src/effects/sell.ts @@ -0,0 +1,15 @@ +import type { ExchangeCode } from "@opentrader/types"; +import { SELL } from "./types"; +import { makeEffect } from "./utils"; + +export type SellPayload = { + exchange: ExchangeCode; + pair: string; // doesn't have effect, instead will be used the pair from buy effect + quantity: number; // doesn't have effect, instead will be used the quantity from buy effect + price?: number; // if not provided, it will be a market order + orderType?: "Limit" | "Market"; +}; + +export function sell(payload: SellPayload, ref = "0") { + return makeEffect(SELL, payload, ref); +} diff --git a/packages/bot-processor/src/effects/smart-trade.ts b/packages/bot-processor/src/effects/smart-trade.ts new file mode 100644 index 00000000..8323e184 --- /dev/null +++ b/packages/bot-processor/src/effects/smart-trade.ts @@ -0,0 +1,57 @@ +import type { OrderStatusEnum } from "@opentrader/types"; +import type { SmartTrade } from "../types"; +import { + GET_SMART_TRADE, + CANCEL_SMART_TRADE, + CREATE_SMART_TRADE, + REPLACE_SMART_TRADE, + USE_SMART_TRADE, +} from "./types"; +import { makeEffect } from "./utils"; + +// Default smart trade reference +const DEFAULT_REF = "0"; + +export type CreateSmartTradePayload = { + buy: { + price: number; + }; + sell: { + price: number; + }; + quantity: number; +}; +export type UseSmartTradePayload = { + buy: { + status?: OrderStatusEnum; // default to Idle + price: number; + }; + sell?: { + status?: OrderStatusEnum; // default to Idle + price: number; + }; + quantity: number; +}; + +export function useSmartTrade(params: UseSmartTradePayload, ref = DEFAULT_REF) { + return makeEffect(USE_SMART_TRADE, params, ref); +} + +export function getSmartTrade(ref = DEFAULT_REF) { + return makeEffect(GET_SMART_TRADE, undefined, ref); +} + +export function createSmartTrade( + payload: CreateSmartTradePayload, + ref = DEFAULT_REF, +) { + return makeEffect(CREATE_SMART_TRADE, payload, ref); +} + +export function cancelSmartTrade(ref = DEFAULT_REF) { + return makeEffect(CANCEL_SMART_TRADE, undefined, ref); +} + +export function replaceSmartTrade(payload: SmartTrade, ref = DEFAULT_REF) { + return makeEffect(REPLACE_SMART_TRADE, payload, ref); +} diff --git a/packages/bot-processor/src/effects/types/base-effect.ts b/packages/bot-processor/src/effects/types/base-effect.ts new file mode 100644 index 00000000..1ad0527f --- /dev/null +++ b/packages/bot-processor/src/effects/types/base-effect.ts @@ -0,0 +1,7 @@ +import { EffectType } from "./effect-types"; + +export type BaseEffect = { + type: T; + ref: R; + payload: P; +}; diff --git a/packages/bot-processor/src/effects/common/types/effect-types.ts b/packages/bot-processor/src/effects/types/effect-types.ts similarity index 80% rename from packages/bot-processor/src/effects/common/types/effect-types.ts rename to packages/bot-processor/src/effects/types/effect-types.ts index 86128518..5a052a01 100644 --- a/packages/bot-processor/src/effects/common/types/effect-types.ts +++ b/packages/bot-processor/src/effects/types/effect-types.ts @@ -1,4 +1,7 @@ export const USE_SMART_TRADE = "USE_SMART_TRADE"; +export const USE_TRADE = "USE_TRADE"; +export const BUY = "BUY"; +export const SELL = "SELL"; export const REPLACE_SMART_TRADE = "REPLACE_SMART_TRADE"; export const GET_SMART_TRADE = "GET_SMART_TRADE"; export const CREATE_SMART_TRADE = "CREATE_SMART_TRADE"; @@ -8,6 +11,9 @@ export const USE_INDICATORS = "USE_INDICATORS"; export type EffectType = | typeof USE_SMART_TRADE + | typeof USE_TRADE + | typeof BUY + | typeof SELL | typeof REPLACE_SMART_TRADE | typeof GET_SMART_TRADE | typeof CREATE_SMART_TRADE diff --git a/packages/bot-processor/src/effects/types/index.ts b/packages/bot-processor/src/effects/types/index.ts new file mode 100644 index 00000000..11256a28 --- /dev/null +++ b/packages/bot-processor/src/effects/types/index.ts @@ -0,0 +1,2 @@ +export * from "./base-effect"; +export * from "./effect-types"; diff --git a/packages/bot-processor/src/effects/useExchange.ts b/packages/bot-processor/src/effects/useExchange.ts index 758d43bf..cb376857 100644 --- a/packages/bot-processor/src/effects/useExchange.ts +++ b/packages/bot-processor/src/effects/useExchange.ts @@ -1,7 +1,10 @@ -import { USE_EXCHANGE } from "./common/types/effect-types"; -import type { UseExchangeEffect } from "./common/types/use-exchange-effect"; -import { makeEffect } from "./utils/make-effect"; +import { USE_EXCHANGE } from "./types"; +import { makeEffect } from "./utils"; -export function useExchange(): UseExchangeEffect { - return makeEffect(USE_EXCHANGE); +/** + * If no label is provided, the default exchange linked to the bot will be used. + * If provided, an external exchange with the given label will be used. + */ +export function useExchange(label?: string) { + return makeEffect(USE_EXCHANGE, label, undefined); } diff --git a/packages/bot-processor/src/effects/useIndicators.ts b/packages/bot-processor/src/effects/useIndicators.ts index 651ec2a7..734ea311 100644 --- a/packages/bot-processor/src/effects/useIndicators.ts +++ b/packages/bot-processor/src/effects/useIndicators.ts @@ -1,14 +1,17 @@ import type { IndicatorBarSize, IndicatorName } from "@opentrader/types"; -import type { UseIndicatorsEffect } from "./common/types/use-indicators-effect"; -import { USE_INDICATORS } from "./common/types/effect-types"; -import { makeEffect } from "./utils/make-effect"; +import { makeEffect } from "./utils"; +import { USE_INDICATORS } from "./types"; export function useIndicators( indicators: IndicatorName[], barSize: IndicatorBarSize, -): UseIndicatorsEffect { - return makeEffect(USE_INDICATORS, { - indicators, - barSize, - }); +) { + return makeEffect( + USE_INDICATORS, + { + indicators, + barSize, + }, + undefined, + ); } diff --git a/packages/bot-processor/src/effects/useSmartTrade.ts b/packages/bot-processor/src/effects/useSmartTrade.ts deleted file mode 100644 index 26f5c637..00000000 --- a/packages/bot-processor/src/effects/useSmartTrade.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { USE_SMART_TRADE } from "./common/types/effect-types"; -import type { - UseSmartTradeEffect, - UseSmartTradePayload, -} from "./common/types/use-smart-trade-effect"; -import { makeEffect } from "./utils/make-effect"; - -export function useSmartTrade( - ref: string, - params: UseSmartTradePayload, -): UseSmartTradeEffect { - return makeEffect(USE_SMART_TRADE, params, ref); -} diff --git a/packages/bot-processor/src/effects/useTrade.ts b/packages/bot-processor/src/effects/useTrade.ts new file mode 100644 index 00000000..cf37bdb6 --- /dev/null +++ b/packages/bot-processor/src/effects/useTrade.ts @@ -0,0 +1,20 @@ +import type { ExchangeCode, OrderSide } from "@opentrader/types"; +import { makeEffect } from "./utils"; +import { USE_TRADE } from "./types"; + +export type UseTradePayload = { + exchange: ExchangeCode; + pair: string; + side: OrderSide; + quantity: number; + tp?: number; + sl?: number; + price?: number; // if not provided, it will be a market order + entryOrderType?: "Limit" | "Market"; + takeProfitOrderType?: "Limit" | "Market"; + stopLossOrderType?: "Limit" | "Market"; +}; + +export function useTrade(params: UseTradePayload, ref = "") { + return makeEffect(USE_TRADE, params, ref); +} diff --git a/packages/bot-processor/src/effects/utils/index.ts b/packages/bot-processor/src/effects/utils/index.ts new file mode 100644 index 00000000..d44cf8d5 --- /dev/null +++ b/packages/bot-processor/src/effects/utils/index.ts @@ -0,0 +1,19 @@ +import type { BaseEffect, EffectType } from "../types"; + +export const makeEffect = ( + type: T, + payload: P, + ref: R, +): BaseEffect => { + return { + ref, + type, + payload, + }; +}; + +export function isEffect( + effect: unknown, +): effect is BaseEffect { + return !!(effect as BaseEffect)?.type; +} diff --git a/packages/bot-processor/src/effects/utils/isCancelSmartTradeEffect.ts b/packages/bot-processor/src/effects/utils/isCancelSmartTradeEffect.ts deleted file mode 100644 index c8d090c6..00000000 --- a/packages/bot-processor/src/effects/utils/isCancelSmartTradeEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CancelSmartTradeEffect } from "../common/types/cancel-smart-trade-effect"; -import { CANCEL_SMART_TRADE } from "../common/types/effect-types"; - -export function isCancelSmartTradeEffect( - effect: unknown, -): effect is CancelSmartTradeEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === CANCEL_SMART_TRADE; -} diff --git a/packages/bot-processor/src/effects/utils/isCreateSmartTradeEffect.ts b/packages/bot-processor/src/effects/utils/isCreateSmartTradeEffect.ts deleted file mode 100644 index 020e6cad..00000000 --- a/packages/bot-processor/src/effects/utils/isCreateSmartTradeEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CreateSmartTradeEffect } from "../common/types/create-smart-trade-effect"; -import { CREATE_SMART_TRADE } from "../common/types/effect-types"; - -export function isCreateSmartTradeEffect( - effect: unknown, -): effect is CreateSmartTradeEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === CREATE_SMART_TRADE; -} diff --git a/packages/bot-processor/src/effects/utils/isGetSmartTradeEffect.ts b/packages/bot-processor/src/effects/utils/isGetSmartTradeEffect.ts deleted file mode 100644 index a169bbd1..00000000 --- a/packages/bot-processor/src/effects/utils/isGetSmartTradeEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { GetSmartTradeEffect } from "../common/types/get-smart-trade-effect"; -import { GET_SMART_TRADE } from "../common/types/effect-types"; - -export function isGetSmartTradeEffect( - effect: unknown, -): effect is GetSmartTradeEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === GET_SMART_TRADE; -} diff --git a/packages/bot-processor/src/effects/utils/isReplaceSmartTradeEffect.ts b/packages/bot-processor/src/effects/utils/isReplaceSmartTradeEffect.ts deleted file mode 100644 index 96731cf1..00000000 --- a/packages/bot-processor/src/effects/utils/isReplaceSmartTradeEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { REPLACE_SMART_TRADE } from "../common/types/effect-types"; -import type { ReplaceSmartTradeEffect } from "../common/types/replace-smart-trade-effect"; - -export function isReplaceSmartTradeEffect( - effect: unknown, -): effect is ReplaceSmartTradeEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === REPLACE_SMART_TRADE; -} diff --git a/packages/bot-processor/src/effects/utils/isUseExchangeEffect.ts b/packages/bot-processor/src/effects/utils/isUseExchangeEffect.ts deleted file mode 100644 index f48ef153..00000000 --- a/packages/bot-processor/src/effects/utils/isUseExchangeEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { USE_EXCHANGE } from "../common/types/effect-types"; -import type { UseExchangeEffect } from "../common/types/use-exchange-effect"; - -export function isUseExchangeEffect( - effect: unknown, -): effect is UseExchangeEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === USE_EXCHANGE; -} diff --git a/packages/bot-processor/src/effects/utils/isUseIndicatorsEffect.ts b/packages/bot-processor/src/effects/utils/isUseIndicatorsEffect.ts deleted file mode 100644 index a5aa08a1..00000000 --- a/packages/bot-processor/src/effects/utils/isUseIndicatorsEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { UseIndicatorsEffect } from "../common/types/use-indicators-effect"; -import { USE_INDICATORS } from "../common/types/effect-types"; - -export function isUseIndicatorsEffect( - effect: unknown, -): effect is UseIndicatorsEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === USE_INDICATORS; -} diff --git a/packages/bot-processor/src/effects/utils/isUseSmartTradeEffect.ts b/packages/bot-processor/src/effects/utils/isUseSmartTradeEffect.ts deleted file mode 100644 index 9cb81097..00000000 --- a/packages/bot-processor/src/effects/utils/isUseSmartTradeEffect.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { USE_SMART_TRADE } from "../common/types/effect-types"; -import type { UseSmartTradeEffect } from "../common/types/use-smart-trade-effect"; - -export function isUseSmartTradeEffect( - effect: unknown, -): effect is UseSmartTradeEffect { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- this is required - return (effect && (effect as any).type) === USE_SMART_TRADE; -} diff --git a/packages/bot-processor/src/effects/utils/make-effect.ts b/packages/bot-processor/src/effects/utils/make-effect.ts deleted file mode 100644 index 2c0dadaa..00000000 --- a/packages/bot-processor/src/effects/utils/make-effect.ts +++ /dev/null @@ -1,16 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- need investigation -// @ts-nocheck -import type { BaseEffect } from "../common/types/base-effect"; -import type { EffectType } from "../common/types/effect-types"; - -export const makeEffect = ( - type: T, - payload?: P, - ref?: K, -): BaseEffect => { - return { - ref, - type, - payload, - }; -}; diff --git a/packages/bot-processor/src/index.ts b/packages/bot-processor/src/index.ts index b3abae34..a9fe7a89 100644 --- a/packages/bot-processor/src/index.ts +++ b/packages/bot-processor/src/index.ts @@ -1,6 +1,4 @@ -export * from "./bot-processor"; export * from "./effects"; export * from "./types"; -export * from "./bot-manager"; +export * from "./strategy-runner"; export * from "./bot-control"; -export * from "./smart-trade.service"; diff --git a/packages/bot-processor/src/strategy-runner.ts b/packages/bot-processor/src/strategy-runner.ts new file mode 100644 index 00000000..ffc0a8b9 --- /dev/null +++ b/packages/bot-processor/src/strategy-runner.ts @@ -0,0 +1,95 @@ +import type { IExchange } from "@opentrader/exchanges"; +import { BotControl } from "./bot-control"; +import { effectRunnerMap } from "./effect-runner"; +import { isEffect } from "./effects"; +import type { + BotTemplate, + IBotConfiguration, + IBotControl, + IStore, + TBotContext, +} from "./types"; +import { createContext } from "./utils/createContext"; + +export class StrategyRunner { + constructor( + private control: IBotControl, + private botConfig: T, + private exchange: IExchange, + private botTemplate: BotTemplate, + ) {} + + async start() { + const context = createContext( + this.control, + this.botConfig, + this.exchange, + "start", + ); + + await this.runTemplate(context); + } + + async stop() { + const context = createContext( + this.control, + this.botConfig, + this.exchange, + "stop", + ); + + await this.runTemplate(context); + } + + async process() { + const context = createContext( + this.control, + this.botConfig, + this.exchange, + "process", + ); + + await this.runTemplate(context); + } + + private async runTemplate(context: TBotContext): Promise { + const generator = this.botTemplate(context); + + let item = generator.next(); + + for (; !item.done; ) { + if (item.value instanceof Promise) { + const result = await item.value; + + item = generator.next(result); + } else if (isEffect(item.value)) { + const effect = item.value; + const effectRunner = effectRunnerMap[effect.type]; + + item = generator.next(await effectRunner(effect, context)); + } else { + console.log(item.value); + throw new Error("Unsupported effect"); + } + } + } +} + +export function createStrategyRunner(options: { + store: IStore; + exchange: IExchange; + botConfig: T; + botTemplate: BotTemplate; +}) { + const { botConfig, store, exchange, botTemplate } = options; + + const botControl = new BotControl(store, botConfig); + const manager = new StrategyRunner( + botControl, + botConfig, + exchange, + botTemplate, + ); + + return manager; +} diff --git a/packages/bot-processor/src/types/bot/bot-context.type.ts b/packages/bot-processor/src/types/bot/bot-context.type.ts index f8de826a..080c6d47 100644 --- a/packages/bot-processor/src/types/bot/bot-context.type.ts +++ b/packages/bot-processor/src/types/bot/bot-context.type.ts @@ -1,7 +1,12 @@ +import type { IExchange } from "@opentrader/exchanges"; import type { IBotControl } from "./bot-control.interface"; import type { IBotConfiguration } from "./bot-configuration.interface"; export type TBotContext = { + /** + * Default exchange instance. + */ + exchange: IExchange; /** * Bot control panel */ diff --git a/packages/bot-processor/src/types/bot/bot-control.interface.ts b/packages/bot-processor/src/types/bot/bot-control.interface.ts index 54ebf15a..ad64705c 100644 --- a/packages/bot-processor/src/types/bot/bot-control.interface.ts +++ b/packages/bot-processor/src/types/bot/bot-control.interface.ts @@ -1,5 +1,6 @@ -import type { UseSmartTradePayload } from "../../effects/common/types/use-smart-trade-effect"; -import type { SmartTrade } from "../smart-trade/smart-trade.type"; +import type { IExchange } from "@opentrader/exchanges"; +import type { UseSmartTradePayload } from "../../effects"; +import type { SmartTrade } from "../smart-trade"; export interface IBotControl { /** @@ -9,6 +10,11 @@ export interface IBotControl { getSmartTrade: (ref: string) => Promise; + updateSmartTrade: ( + ref: string, + payload: Pick, + ) => Promise; + createSmartTrade: ( ref: string, payload: UseSmartTradePayload, @@ -22,4 +28,6 @@ export interface IBotControl { replaceSmartTrade: (ref: string, payload: SmartTrade) => Promise; cancelSmartTrade: (ref: string) => Promise; + + getExchange: (label: string) => Promise; } diff --git a/packages/bot-processor/src/types/smart-trade/index.ts b/packages/bot-processor/src/types/smart-trade/index.ts index 1c880e22..b32d39b9 100644 --- a/packages/bot-processor/src/types/smart-trade/index.ts +++ b/packages/bot-processor/src/types/smart-trade/index.ts @@ -1,2 +1,3 @@ export * from "./smart-trade.type"; -export * from "./smart-trade-service.interface"; +export * from "./smart-trade.service"; +export * from "./trade.service"; diff --git a/packages/bot-processor/src/types/smart-trade/smart-trade-service.interface.ts b/packages/bot-processor/src/types/smart-trade/smart-trade-service.interface.ts deleted file mode 100644 index 7df76629..00000000 --- a/packages/bot-processor/src/types/smart-trade/smart-trade-service.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReplaceSmartTradeEffect } from "../../effects"; -import type { SmartTrade } from "./smart-trade.type"; - -export type ISmartTradeService = SmartTrade & { - /** - * Create a new SmartTrade with same buy/sell orders - */ - replace: () => ReplaceSmartTradeEffect; - - /** - * Return `true` if buy and sell orders were filled - */ - isCompleted: () => boolean; -}; diff --git a/packages/bot-processor/src/smart-trade.service.ts b/packages/bot-processor/src/types/smart-trade/smart-trade.service.ts similarity index 55% rename from packages/bot-processor/src/smart-trade.service.ts rename to packages/bot-processor/src/types/smart-trade/smart-trade.service.ts index ea7b8786..3b9bd880 100644 --- a/packages/bot-processor/src/smart-trade.service.ts +++ b/packages/bot-processor/src/types/smart-trade/smart-trade.service.ts @@ -1,8 +1,6 @@ import { OrderStatusEnum } from "@opentrader/types"; -import type { CancelSmartTradeEffect } from "./effects/common/types/cancel-smart-trade-effect"; -import type { ReplaceSmartTradeEffect } from "./effects"; -import { cancelSmartTrade, replaceSmartTrade } from "./effects"; -import type { SmartTrade } from "./types"; +import { cancelSmartTrade, replaceSmartTrade } from "../../effects"; +import type { SmartTrade } from "./smart-trade.type"; export class SmartTradeService { buy: SmartTrade["buy"]; @@ -22,15 +20,15 @@ export class SmartTradeService { /** * Create a new SmartTrade with same buy/sell orders */ - replace(): ReplaceSmartTradeEffect { - return replaceSmartTrade(this.ref, this.smartTrade); + replace() { + return replaceSmartTrade(this.smartTrade, this.ref); } - cancel(): CancelSmartTradeEffect { + cancel() { return cancelSmartTrade(this.ref); } isCompleted(): boolean { - return this.smartTrade.sell.status === OrderStatusEnum.Filled; + return this.smartTrade.sell?.status === OrderStatusEnum.Filled; } } diff --git a/packages/bot-processor/src/types/smart-trade/smart-trade.type.ts b/packages/bot-processor/src/types/smart-trade/smart-trade.type.ts index a6f66cbd..a55dd399 100644 --- a/packages/bot-processor/src/types/smart-trade/smart-trade.type.ts +++ b/packages/bot-processor/src/types/smart-trade/smart-trade.type.ts @@ -13,13 +13,15 @@ export type Order = { updatedAt: number; }; -type SmartTradeBase = { +type SmartTradeBuilder = { id: number | string; // @todo remove this prop, the bot processor is not required to know the ID of the bot ref: string; -}; - -export type SmartTrade = SmartTradeBase & { quantity: number; buy: Order; - sell: Order; + sell: WithSell extends true ? Order : undefined; }; + +export type SmartTradeBuyOnly = SmartTradeBuilder; +export type SmartTradeWithSell = SmartTradeBuilder; + +export type SmartTrade = SmartTradeBuyOnly | SmartTradeWithSell; diff --git a/packages/bot-processor/src/types/smart-trade/trade.service.ts b/packages/bot-processor/src/types/smart-trade/trade.service.ts new file mode 100644 index 00000000..45d9912a --- /dev/null +++ b/packages/bot-processor/src/types/smart-trade/trade.service.ts @@ -0,0 +1,27 @@ +import { OrderStatusEnum } from "@opentrader/types"; +import { cancelSmartTrade } from "../../effects"; +import type { SmartTrade } from "./smart-trade.type"; + +export class TradeService { + buy: SmartTrade["buy"]; + sell: SmartTrade["sell"]; + + constructor( + private ref: string, + private smartTrade: SmartTrade, + ) { + // Instead of assigning prop by prop + // it is possible to use `Object.assign(this, smartTrade)` + // but types are lost in this case + this.buy = smartTrade.buy; + this.sell = smartTrade.sell; + } + + cancel() { + return cancelSmartTrade(this.ref); + } + + isCompleted() { + return this.smartTrade.sell?.status === OrderStatusEnum.Filled; + } +} diff --git a/packages/bot-processor/src/types/store/store.interface.ts b/packages/bot-processor/src/types/store/store.interface.ts index 43b8ce77..6c2287c5 100644 --- a/packages/bot-processor/src/types/store/store.interface.ts +++ b/packages/bot-processor/src/types/store/store.interface.ts @@ -1,5 +1,6 @@ -import type { UseSmartTradePayload } from "../../effects/common/types/use-smart-trade-effect"; -import type { SmartTrade } from "../smart-trade/smart-trade.type"; +import type { IExchange } from "@opentrader/exchanges"; +import type { UseSmartTradePayload } from "../../effects"; +import type { SmartTrade } from "../smart-trade"; export interface IStore { stopBot: (botId: number) => Promise; @@ -10,10 +11,18 @@ export interface IStore { botId: number, ) => Promise; + updateSmartTrade: ( + ref: string, + payload: Pick, + botId: number, + ) => Promise; + /** * If `true` then SmartTrade was canceled with success. * @param ref - SmartTrade ref * @param botId - Bot ID */ cancelSmartTrade: (ref: string, botId: number) => Promise; + + getExchange: (label: string) => Promise; } diff --git a/packages/bot-processor/src/utils/createContext.ts b/packages/bot-processor/src/utils/createContext.ts index e821718f..0107ca9c 100644 --- a/packages/bot-processor/src/utils/createContext.ts +++ b/packages/bot-processor/src/utils/createContext.ts @@ -1,14 +1,16 @@ -import type { IBotConfiguration, IBotControl } from "../types"; -import type { TBotContext } from "../types/bot/bot-context.type"; +import type { IExchange } from "@opentrader/exchanges"; +import type { IBotConfiguration, IBotControl, TBotContext } from "../types"; export function createContext( control: IBotControl, config: T, + exchange: IExchange, command: "start" | "stop" | "process", // @todo add type in file ): TBotContext { return { control, config, + exchange, command, onStart: command === "start", onStop: command === "stop", diff --git a/packages/bot-templates/src/templates/grid-bot.ts b/packages/bot-templates/src/templates/grid-bot.ts index a3018b08..5c209f93 100644 --- a/packages/bot-templates/src/templates/grid-bot.ts +++ b/packages/bot-templates/src/templates/grid-bot.ts @@ -8,18 +8,12 @@ import type { import { cancelSmartTrade, useExchange, - useIndicators, useSmartTrade, } from "@opentrader/bot-processor"; import { computeGridLevelsFromCurrentAssetPrice } from "@opentrader/tools"; -import type { IGetMarketPriceResponse, XCandle } from "@opentrader/types"; +import type { IGetMarketPriceResponse } from "@opentrader/types"; export function* gridBot(ctx: TBotContext) { - // const candle1m: XCandle<"SMA10" | "SMA15"> = yield useIndicators( - // ["SMA10", "SMA15", "SMA30"], - // "1m", - // ); - // const candle5m: XCandle<"SMA10"> = yield useIndicators(["SMA10"], "5m"); const { config: bot, onStart, onStop } = ctx; const exchange: IExchange = yield useExchange(); @@ -48,17 +42,20 @@ export function* gridBot(ctx: TBotContext) { } for (const [index, grid] of gridLevels.entries()) { - const smartTrade: SmartTradeService = yield useSmartTrade(`${index}`, { - buy: { - price: grid.buy.price, - status: grid.buy.status, + const smartTrade: SmartTradeService = yield useSmartTrade( + { + buy: { + price: grid.buy.price, + status: grid.buy.status, + }, + sell: { + price: grid.sell.price, + status: grid.sell.status, + }, + quantity: grid.buy.quantity, // or grid.sell.quantity }, - sell: { - price: grid.sell.price, - status: grid.sell.status, - }, - quantity: grid.buy.quantity, // or grid.sell.quantity - }); + `${index}`, + ); if (smartTrade.isCompleted()) { yield smartTrade.replace(); diff --git a/packages/bot-templates/src/templates/index.ts b/packages/bot-templates/src/templates/index.ts index 81b81aee..69bfd107 100644 --- a/packages/bot-templates/src/templates/index.ts +++ b/packages/bot-templates/src/templates/index.ts @@ -1,4 +1,3 @@ export * from "./grid-bot"; export * from "./grid-bot-lite"; -export * from "./low-cap"; export * from "./debug"; diff --git a/packages/bot-templates/src/templates/low-cap.ts b/packages/bot-templates/src/templates/low-cap.ts deleted file mode 100644 index 6eddc8b2..00000000 --- a/packages/bot-templates/src/templates/low-cap.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - IBotConfiguration, - TBotContext, - SmartTradeService, -} from "@opentrader/bot-processor"; -import { - cancelSmartTrade, - useExchange, - useIndicators, - useSmartTrade, -} from "@opentrader/bot-processor"; -import type { IExchange } from "@opentrader/exchanges"; -import type { XCandle } from "@opentrader/types"; - -export interface LowCapBotConfig extends IBotConfiguration { - settings: any; -} - -export function* lowCap(ctx: TBotContext) { - const candle1m: XCandle<"SMA10" | "SMA15"> = yield useIndicators( - ["SMA10", "SMA15"], - "1m", - ); - // const candle5m: XCandle<"SMA10"> = yield useIndicators(["SMA10"], "5m"); - const { config: bot, onStart, onStop } = ctx; - - if (onStop) { - yield cancelSmartTrade("0"); - - return; - } - - const smartTrade: SmartTradeService = yield useSmartTrade("0", { - buy: { - price: 42000, - }, - sell: { - price: 50000, - }, - quantity: 0.01, - }); - - console.log("LowCap: Bot template run."); - console.log("smartTrade.isCompleted()", smartTrade.isCompleted()); - console.log("candle1m", candle1m); - - const exchange: IExchange = yield useExchange(); -} diff --git a/packages/bot/src/processing/exchange-accounts.watcher.ts b/packages/bot/src/processing/exchange-accounts.watcher.ts index 91f2fd87..6acb69bc 100644 --- a/packages/bot/src/processing/exchange-accounts.watcher.ts +++ b/packages/bot/src/processing/exchange-accounts.watcher.ts @@ -1,5 +1,5 @@ import type { IWatchOrder } from "@opentrader/types"; -import { BotProcessing } from "@opentrader/processing"; +import { BotProcessing, SmartTradeExecutor } from "@opentrader/processing"; import type { OrderWithSmartTrade, ExchangeAccountWithCredentials, @@ -50,6 +50,8 @@ export class ExchangeAccountsWatcher { logger.info( `🔋 onOrderFilled: Order #${order.id}: ${order.exchangeOrderId} was filled with price ${exchangeOrder.filledPrice}`, ); + const smartTrade = await SmartTradeExecutor.fromId(order.id); + await smartTrade.next(); const bot = await BotProcessing.fromSmartTradeId(order.smartTrade.id); diff --git a/packages/db/src/entities/smart-trade.entity.ts b/packages/db/src/entities/smart-trade.entity.ts index b5f7c97b..176e1fb7 100644 --- a/packages/db/src/entities/smart-trade.entity.ts +++ b/packages/db/src/entities/smart-trade.entity.ts @@ -23,13 +23,22 @@ type EntryOrderBuilder = }; type TakeProfitOrderBuilder = - TakeProfitType extends "Order" + TakeProfitType extends "None" ? { - takeProfitOrder: OrderEntity; + takeProfitOrder: null; } - : { - takeProfitOrders: OrderEntity[]; - }; + : TakeProfitType extends "Order" + ? { + takeProfitOrder: OrderEntity; + } + : { + takeProfitOrders: OrderEntity[]; + }; + +export type SmartTradeEntity_Order_None = SmartTradeEntityBuilder< + "Order", + "None" +>; export type SmartTradeEntity_Order_Order = SmartTradeEntityBuilder< "Order", @@ -49,6 +58,7 @@ export type SmartTradeEntity_Ladder_Ladder = SmartTradeEntityBuilder< >; export type SmartTradeEntity = + | SmartTradeEntity_Order_None | SmartTradeEntity_Order_Order | SmartTradeEntity_Order_Ladder | SmartTradeEntity_Ladder_Order @@ -92,7 +102,19 @@ export function toSmartTradeEntity( .map(toOrderEntity); }; - if (entryType === "Order" && takeProfitType === "Order") { + if (entryType === "Order" && takeProfitType === "None") { + return { + ...other, + orders, + + type, + entryType, + takeProfitType, + + entryOrder: findSingleEntryOrder(), + takeProfitOrder: null, + }; + } else if (entryType === "Order" && takeProfitType === "Order") { return { ...other, orders, diff --git a/packages/prisma/src/migrations/20240516154507_smart_trade_optional_take_profit/migration.sql b/packages/prisma/src/migrations/20240516154507_smart_trade_optional_take_profit/migration.sql new file mode 100644 index 00000000..4c19c384 --- /dev/null +++ b/packages/prisma/src/migrations/20240516154507_smart_trade_optional_take_profit/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TakeProfitType" ADD VALUE 'None'; diff --git a/packages/prisma/src/schema.prisma b/packages/prisma/src/schema.prisma index 0cedf7c6..99711ad7 100644 --- a/packages/prisma/src/schema.prisma +++ b/packages/prisma/src/schema.prisma @@ -74,6 +74,7 @@ enum EntryType { enum TakeProfitType { Order Ladder + None } enum EntityType { diff --git a/packages/processing/jest.config.js b/packages/processing/jest.config.js new file mode 100644 index 00000000..9fb637a8 --- /dev/null +++ b/packages/processing/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + moduleDirectories: ['node_modules', ''], +}; diff --git a/packages/processing/package.json b/packages/processing/package.json index 6ad164de..34c25f65 100644 --- a/packages/processing/package.json +++ b/packages/processing/package.json @@ -5,17 +5,22 @@ "main": "src/index.ts", "types": "src/index.ts", "scripts": { + "test": "jest", "build": "tsc", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix" }, "author": "bludnic", "devDependencies": { + "@jest/globals": "^29.7.0", "@opentrader/eslint-config": "workspace:*", "@opentrader/tsconfig": "workspace:*", "@opentrader/types": "workspace:*", + "@types/jest": "^29.5.12", "@types/node": "^20.12.11", "eslint": "8.54.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", "typescript": "^5.2.2" }, "dependencies": { @@ -24,6 +29,7 @@ "@opentrader/db": "workspace:*", "@opentrader/exchanges": "workspace:*", "@opentrader/logger": "workspace:*", + "@prisma/client": "5.13.0", "ccxt": "4.1.48" } } diff --git a/packages/processing/src/bot/bot-store-adapter.ts b/packages/processing/src/bot/bot-store-adapter.ts index 5e8f3702..5389aefe 100644 --- a/packages/processing/src/bot/bot-store-adapter.ts +++ b/packages/processing/src/bot/bot-store-adapter.ts @@ -4,7 +4,9 @@ import type { UseSmartTradePayload, } from "@opentrader/bot-processor"; import { xprisma, toSmartTradeEntity } from "@opentrader/db"; -import { SmartTradeProcessor } from "../smart-trade"; +import { exchangeProvider } from "@opentrader/exchanges"; +import { logger } from "@opentrader/logger"; +import { SmartTradeExecutor } from "../executors"; import { toPrismaSmartTrade, toSmartTradeIteratorResult } from "./utils"; export class BotStoreAdapter implements IStore { @@ -31,7 +33,7 @@ export class BotStoreAdapter implements IStore { }); return toSmartTradeIteratorResult(toSmartTradeEntity(smartTrade)); - } catch { + } catch (err) { return null; // throws error if not found } } @@ -41,17 +43,17 @@ export class BotStoreAdapter implements IStore { payload: UseSmartTradePayload, botId: number, ) { - console.log(`[BotStoreAdapter] createSmartTrade (key:${ref})`); - const bot = await xprisma.bot.findUnique({ where: { id: botId, }, }); if (!bot) { - throw new Error( - `[BotStoreAdapter] getSmartTrade(): botId ${botId} not found`, + logger.error( + `BotStoreAdapter: Cannot cancel SmartTrade with ref "${ref}". Reason: Bot with ID ${botId} not found.`, ); + + throw new Error("Bot not found"); } const exchangeSymbolId = `${bot.baseCurrency}/${bot.quoteCurrency}`; @@ -84,15 +86,22 @@ export class BotStoreAdapter implements IStore { }, }); - console.log( - `[BotStoreAdapter] Smart Trade with (key:${ref}) was saved to DB`, - ); + logger.info(`BotStoreAdapter: SmartTrade with ref "${ref}" created`); return toSmartTradeIteratorResult(toSmartTradeEntity(smartTrade)); } - async cancelSmartTrade(ref: string, botId: number) { - console.log(`[BotStoreAdapter] cancelSmartTrade (ref:${ref})`); + async updateSmartTrade( + ref: string, + payload: Pick, + botId: number, + ): Promise { + if (!payload.sell) { + logger.error( + `BotStoreAdapter: Cannot update SmartTrade with ref "${ref}". Reason: "payload.sell" not provided. Payload: ${JSON.stringify(payload)}}`, + ); + return null; + } const bot = await xprisma.bot.findUnique({ where: { @@ -100,9 +109,103 @@ export class BotStoreAdapter implements IStore { }, }); if (!bot) { - throw new Error( - `[BotStoreAdapter] getSmartTrade(): botId ${botId} not found`, + logger.error( + `BotStoreAdapter: Cannot cancel SmartTrade with ref "${ref}". Reason: Bot with ID ${botId} not found.`, ); + return null; + } + + try { + let smartTrade = await xprisma.smartTrade.findFirstOrThrow({ + where: { + type: "Trade", + ref, + bot: { + id: bot.id, + }, + }, + include: { + orders: true, + exchangeAccount: true, + }, + }); + const entryOrder = smartTrade.orders.find( + (order) => order.entityType === "EntryOrder", + ); + + if (!entryOrder) { + throw new Error("EntryOrder not found in SmartTrade"); + } + + const tpOrder = smartTrade.orders.find( + (order) => order.entityType === "TakeProfitOrder", + ); + if (tpOrder) { + logger.info( + `BotStoreAdapter: Updating SmartTrade with "${ref}". TakeProfitOrder already placed. Skipping.`, + ); + + return toSmartTradeIteratorResult(toSmartTradeEntity(smartTrade)); + } + + await xprisma.order.create({ + data: { + entityType: "TakeProfitOrder", + type: "Limit", + side: "Sell", + price: payload.sell.price, + quantity: entryOrder.quantity, // @todo multiply by 0.99 for safety amount + smartTrade: { + connect: { + id: smartTrade.id, + }, + }, + }, + include: { + smartTrade: { + include: { + orders: true, + exchangeAccount: true, + }, + }, + }, + }); + + smartTrade = await xprisma.smartTrade.update({ + where: { + id: smartTrade.id, + }, + data: { + takeProfitType: "Order", + }, + include: { + orders: true, + exchangeAccount: true, + }, + }); + + logger.info( + `BotStoreAdapter: SmartTrade with ref "${ref}" updated. TakeProfitOrder placed.`, + ); + + return toSmartTradeIteratorResult(toSmartTradeEntity(smartTrade)); + } catch (err) { + return null; // return null if smartTrade not found + } + } + + async cancelSmartTrade(ref: string, botId: number) { + const bot = await xprisma.bot.findUnique({ + where: { + id: botId, + }, + }); + if (!bot) { + logger.error( + `BotStoreAdapter: Cannot cancel SmartTrade with ref "${ref}". Reason: Bot with ID ${botId} not found.`, + ); + + return false; } const smartTrade = await xprisma.smartTrade.findFirst({ @@ -119,17 +222,35 @@ export class BotStoreAdapter implements IStore { }, }); if (!smartTrade) { - console.log("[BotStoreAdapter] SmartTrade not found"); + logger.warn( + `BotStoreAdapter: Cannot cancel SmartTrade with ref "${ref}". Reason: SmartTrade not found`, + ); return false; } - const processor = new SmartTradeProcessor( + const smartTradeExecutor = SmartTradeExecutor.create( smartTrade, smartTrade.exchangeAccount, ); - - await processor.cancelOrders(); + await smartTradeExecutor.cancelOrders(); return true; } + + async getExchange(label: string) { + const exchangeAccount = await xprisma.exchangeAccount.findFirst({ + where: { + label, + }, + }); + + if (!exchangeAccount) { + logger.error( + `BotStoreAdapter: ExchangeAccount with label "${label}" not found`, + ); + return null; + } + + return exchangeProvider.fromAccount(exchangeAccount); + } } diff --git a/packages/processing/src/bot/bot.processing.ts b/packages/processing/src/bot/bot.processing.ts index 85e70fb0..fe59fc6c 100644 --- a/packages/processing/src/bot/bot.processing.ts +++ b/packages/processing/src/bot/bot.processing.ts @@ -1,10 +1,11 @@ import type { IBotConfiguration } from "@opentrader/bot-processor"; -import { BotProcessor } from "@opentrader/bot-processor"; +import { createStrategyRunner } from "@opentrader/bot-processor"; import { findTemplate } from "@opentrader/bot-templates"; import { exchangeProvider } from "@opentrader/exchanges"; import type { TBot } from "@opentrader/db"; import { xprisma } from "@opentrader/db"; -import { SmartTradeProcessor } from "../smart-trade"; +import { logger } from "@opentrader/logger"; +import { SmartTradeExecutor } from "../executors"; import { BotStoreAdapter } from "./bot-store-adapter"; export class BotProcessing { @@ -150,7 +151,7 @@ export class BotProcessing { const storeAdapter = new BotStoreAdapter(() => this.stop()); const botTemplate = findTemplate(this.bot.template); - const processor = BotProcessor.create({ + const processor = createStrategyRunner({ store: storeAdapter, exchange, botConfig: configuration, @@ -182,14 +183,20 @@ export class BotProcessing { }, }); + logger.info(`Found ${smartTrades.length} pending orders for placement`); + for (const smartTrade of smartTrades) { const { exchangeAccount } = smartTrade; - const smartTradeService = new SmartTradeProcessor( + logger.info( + `Executed next() for SmartTrade { id: ${smartTrade.id}, symbol: ${smartTrade.exchangeSymbolId}, exchangeCode: ${exchangeAccount.exchangeCode} }`, + ); + + const smartTradeExecutor = SmartTradeExecutor.create( smartTrade, exchangeAccount, ); - await smartTradeService.placeNext(); + await smartTradeExecutor.next(); } console.log( diff --git a/packages/processing/src/bot/utils/toPrismaSmartTrade.ts b/packages/processing/src/bot/utils/toPrismaSmartTrade.ts index 5d541abb..e489ae3c 100644 --- a/packages/processing/src/bot/utils/toPrismaSmartTrade.ts +++ b/packages/processing/src/bot/utils/toPrismaSmartTrade.ts @@ -39,16 +39,19 @@ export function toPrismaSmartTrade( $Enums.OrderSide.Buy, $Enums.EntityType.EntryOrder, ); - const sellOrderData = toPrismaOrder( - sell, - quantity, - $Enums.OrderSide.Sell, - $Enums.EntityType.TakeProfitOrder, - ); + + const sellOrderData = sell + ? toPrismaOrder( + sell, + quantity, + $Enums.OrderSide.Sell, + $Enums.EntityType.TakeProfitOrder, + ) + : undefined; return { entryType: "Order", - takeProfitType: "Order", + takeProfitType: sell ? "Order" : "None", ref, type: $Enums.SmartTradeType.Trade, @@ -58,7 +61,7 @@ export function toPrismaSmartTrade( orders: { createMany: { - data: [buyOrderData, sellOrderData], + data: sellOrderData ? [buyOrderData, sellOrderData] : [buyOrderData], }, }, diff --git a/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts b/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts index 594e26a8..b80167fe 100644 --- a/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts +++ b/packages/processing/src/bot/utils/toSmartTradeIteratorResult.ts @@ -39,7 +39,7 @@ export function toSmartTradeIteratorResult( if (entryOrder.type === "Market") { throw new Error("Order type Market is not supported yet"); } - if (takeProfitOrder.type === "Market") { + if (takeProfitOrder?.type === "Market") { throw new Error("Order type Market is not supported yet"); } @@ -53,11 +53,13 @@ export function toSmartTradeIteratorResult( createdAt: entryOrder.createdAt.getTime(), updatedAt: entryOrder.updatedAt.getTime(), }, - sell: { - status: toProcessorOrderStatus(takeProfitOrder.status), - price: takeProfitOrder.price, - createdAt: takeProfitOrder.createdAt.getTime(), - updatedAt: takeProfitOrder.updatedAt.getTime(), - }, + sell: takeProfitOrder + ? { + status: toProcessorOrderStatus(takeProfitOrder.status), + price: takeProfitOrder.price, + createdAt: takeProfitOrder.createdAt.getTime(), + updatedAt: takeProfitOrder.updatedAt.getTime(), + } + : undefined, }; } diff --git a/packages/processing/src/executors/index.ts b/packages/processing/src/executors/index.ts new file mode 100644 index 00000000..75d15734 --- /dev/null +++ b/packages/processing/src/executors/index.ts @@ -0,0 +1 @@ +export * from "./smart-trade.executor"; diff --git a/packages/processing/src/executors/order/order-executor.spec.ts b/packages/processing/src/executors/order/order-executor.spec.ts new file mode 100644 index 00000000..f889d833 --- /dev/null +++ b/packages/processing/src/executors/order/order-executor.spec.ts @@ -0,0 +1,113 @@ +import { + ExchangeAccountWithCredentials, + SmartTradeWithOrders, + xprisma, +} from "@opentrader/db"; +import { exchangeProvider, IExchange } from "@opentrader/exchanges"; +import { Order } from "@opentrader/db"; +import { OrderExecutor } from "./order.executor"; +import { createTrade, getExchangeAccount } from "../../utils/test"; + +describe("OrderExecutor", () => { + let exchangeAccount: ExchangeAccountWithCredentials; + + let smartTrade: SmartTradeWithOrders; + let order: Order; + let orderExecutor: OrderExecutor; + let symbol: string; + + let exchange: IExchange; + + beforeAll(async () => { + exchangeAccount = await getExchangeAccount(); + exchange = exchangeProvider.fromAccount(exchangeAccount); + }); + + beforeEach(async () => { + smartTrade = await createTrade({ + symbol: "BTC/USDT", + entry: { + type: "Limit", + side: "Buy", + price: 10000, + quantity: 0.0001, + }, + }); + order = smartTrade.orders.find((o) => o.entityType === "EntryOrder")!; + symbol = `${smartTrade.baseCurrency}/${smartTrade.quoteCurrency}`; + }); + + afterEach(async () => { + if (orderExecutor) { + await orderExecutor.cancel(); + } + + if (smartTrade) { + await xprisma.smartTrade.delete({ + where: { + id: smartTrade.id, + }, + }); + } + }); + + it("should cancel the order with status Idle", async () => { + orderExecutor = new OrderExecutor(order, exchange, symbol); + + expect(orderExecutor.status).toBe("Idle"); + + const cancelled = await orderExecutor.cancel(); + + expect(orderExecutor.status).toBe("Revoked"); + expect(cancelled).toBe(true); + }); + + it("should cancel the order with status Placed", async () => { + orderExecutor = new OrderExecutor(order, exchange, symbol); + await orderExecutor.place(); + + expect(orderExecutor.status).toBe("Placed"); + + const cancelled = await orderExecutor.cancel(); + + expect(orderExecutor.status).toBe("Canceled"); + expect(cancelled).toBe(true); + }); + + it("should skip execution if the order is already canceled", async () => { + orderExecutor = new OrderExecutor(order, exchange, symbol); + + await orderExecutor.place(); + await orderExecutor.cancel(); + + const cancelled = await orderExecutor.cancel(); + expect(cancelled).toBe(false); + }); + + it("should place the order", async () => { + orderExecutor = new OrderExecutor(order, exchange, symbol); + + const placed = await orderExecutor.place(); + expect(placed).toBe(true); + expect(orderExecutor.status).toBe("Placed"); + }); + + it("should skip placing the order if the order is already placed", async () => { + orderExecutor = new OrderExecutor(order, exchange, symbol); + + await orderExecutor.place(); + + const placed = await orderExecutor.place(); + expect(placed).toBe(false); + }); + + it("should skip placing the order if the order is already canceled", async () => { + orderExecutor = new OrderExecutor(order, exchange, symbol); + + await orderExecutor.place(); + await orderExecutor.cancel(); + + const placed = await orderExecutor.place(); + expect(placed).toBe(false); + }); +}); diff --git a/packages/processing/src/executors/order/order.executor.ts b/packages/processing/src/executors/order/order.executor.ts new file mode 100644 index 00000000..7f64b4bf --- /dev/null +++ b/packages/processing/src/executors/order/order.executor.ts @@ -0,0 +1,187 @@ +import type { IExchange } from "@opentrader/exchanges"; +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 { OrderNotFound } from "ccxt"; + +export class OrderExecutor { + order: OrderEntity; + exchange: IExchange; + symbol: string; // maybe migrate Order schema to support symbol + + constructor(order: Order, exchange: IExchange, symbol: string) { + this.order = toOrderEntity(order); + this.exchange = exchange; + this.symbol = symbol; + } + + static async fromId(id: number, exchange: IExchange, symbol: string) { + const order = await xprisma.order.findUniqueOrThrow({ + where: { + id, + }, + }); + + return new OrderExecutor(toOrderEntity(order), exchange, symbol); + } + + /** + * Places the order on the exchange and updates the status in the database. + * Returns `true` if the order was placed successfully. + */ + async place(): Promise { + if (this.order.status !== "Idle") { + logger.error( + `Cannot place the order: Order { id: ${this.order.id}, status: ${this.order.status} }. Order was already placed before. Skip execution.`, + ); + + return false; + } + + if (this.order.type === "Limit") { + const exchangeOrder = await this.exchange.placeLimitOrder({ + symbol: this.symbol, + side: this.order.side === "Buy" ? "buy" : "sell", + price: this.order.price, + quantity: this.order.quantity, + }); + + // Update status to Placed + // Save exchange orderId to DB + await xprisma.order.update({ + where: { + id: this.order.id, + }, + data: { + status: "Placed", + exchangeOrderId: exchangeOrder.orderId, + placedAt: new Date(), // maybe use Exchange time (if possible) + }, + }); + await this.pullOrder(); + + return true; + } else if (this.order.type === "Market") { + throw new Error("Market order is not supported yet"); + } + + return false; + } + + /** + * Returns true if the order was canceled successfully. + */ + async cancel(): Promise { + if (["Canceled", "Revoked", "Deleted"].includes(this.order.status)) { + logger.warn( + `Cannot cancel already canceled order: Order { id: ${this.order.id}, status: ${this.order.status} }. Skip execution.`, + ); + + return false; + } + + if (this.order.status === "Idle") { + 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} }`, + ); + + return true; + } + + if (this.order.status === "Placed") { + assertHasExchangeOrderId(this.order); + + try { + await this.exchange.cancelLimitOrder({ + orderId: this.order.exchangeOrderId, + symbol: this.symbol, + }); + await xprisma.order.updateStatus("Canceled", this.order.id); + await this.pullOrder(); + + logger.info( + `Order was canceled (Placed → Canceled): Order { id: ${this.order.id}, status: ${this.order.status} }`, + ); + + return true; + } catch (err) { + if (err instanceof OrderNotFound) { + await xprisma.order.updateStatus("Deleted", this.order.id); + await this.pullOrder(); + + logger.warn( + `Order was canceled (Placed → Deleted). Attempted to cancel order that was not found on exchange: Order { id: ${this.order.id} }. Marked as Deleted.`, + ); + + return true; + } + logger.error( + { + err, + order: this.order, + }, + `Unexpected error occurred while canceling order: Order { id: ${this.order.id} }`, + ); + throw err; // @todo retry + } + } + + 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.`, + ); + + return false; + } + + return false; + } + + /** + * Pulls the order from the database to update the status. + */ + private async pullOrder() { + const order = await xprisma.order.findUniqueOrThrow({ + where: { + id: this.order.id, + }, + }); + + this.order = toOrderEntity(order); + } + + get type() { + return this.order.type; + } + + get status() { + return this.order.status; + } + + get price() { + return this.order.price; + } + + get filledPrice() { + return this.order.filledPrice; + } + + get filledAt() { + return this.order.filledAt; + } + + get placedAt() { + return this.order.placedAt; + } +} diff --git a/packages/processing/src/executors/smart-trade-executor.interface.ts b/packages/processing/src/executors/smart-trade-executor.interface.ts new file mode 100644 index 00000000..26e3030a --- /dev/null +++ b/packages/processing/src/executors/smart-trade-executor.interface.ts @@ -0,0 +1,15 @@ +export interface ISmartTradeExecutor { + /** + * Execute the next step in the smart trade, e.g. place an order. + * Return `true` if the step was executed, `false` otherwise. + */ + next: () => Promise; + + /** + * Cancel all orders linked to the smart trade. + * Return number of cancelled orders. + */ + cancelOrders: () => Promise; + + get status(): "Entering" | "Exiting" | "Finished" +} diff --git a/packages/processing/src/executors/smart-trade.executor.ts b/packages/processing/src/executors/smart-trade.executor.ts new file mode 100644 index 00000000..860a7d6b --- /dev/null +++ b/packages/processing/src/executors/smart-trade.executor.ts @@ -0,0 +1,47 @@ +import type { + ExchangeAccountWithCredentials, + SmartTradeWithOrders, +} from "@opentrader/db"; +import { xprisma } from "@opentrader/db"; +import { exchangeProvider } from "@opentrader/exchanges"; +import type { ISmartTradeExecutor } from "./smart-trade-executor.interface"; +import { TradeExecutor } from "./trade/trade.executor"; + +/** + * Combine all type of SmartTrades into one executor. + */ +export class SmartTradeExecutor { + static create( + smartTrade: SmartTradeWithOrders, + exchangeAccount: ExchangeAccountWithCredentials, + ): ISmartTradeExecutor { + const exchange = exchangeProvider.fromAccount(exchangeAccount); + + switch (smartTrade.type) { + case "Trade": + return new TradeExecutor(smartTrade, exchange); + default: + throw new Error(`Unknown SmartTrade type: ${smartTrade.type}`); + } + } + + static async fromId(id: number): Promise { + const smartTrade = await xprisma.smartTrade.findUniqueOrThrow({ + where: { + id, + }, + include: { + orders: true, + exchangeAccount: true, + }, + }); + const exchange = exchangeProvider.fromAccount(smartTrade.exchangeAccount); + + switch (smartTrade.type) { + case "Trade": + return new TradeExecutor(smartTrade, exchange); + default: + throw new Error(`Unknown SmartTrade type: ${smartTrade.type}`); + } + } +} diff --git a/packages/processing/src/executors/trade/trade-executor.spec.ts b/packages/processing/src/executors/trade/trade-executor.spec.ts new file mode 100644 index 00000000..182e8964 --- /dev/null +++ b/packages/processing/src/executors/trade/trade-executor.spec.ts @@ -0,0 +1,102 @@ +import { + ExchangeAccountWithCredentials, + SmartTradeWithOrders, + xprisma, +} from "@opentrader/db"; +import { exchangeProvider, IExchange } from "@opentrader/exchanges"; +import { TradeExecutor } from "./trade.executor"; +import { + createTrade, + getExchangeAccount, + updateEntryOrder, +} from "../../utils/test"; + +describe("TradeExecutor", () => { + let exchangeAccount: ExchangeAccountWithCredentials; + + let smartTrade: SmartTradeWithOrders; + let tradeExecutor: TradeExecutor; + + let exchange: IExchange; + + beforeAll(async () => { + exchangeAccount = await getExchangeAccount(); + exchange = exchangeProvider.fromAccount(exchangeAccount); + }); + + beforeEach(async () => { + smartTrade = await createTrade({ + symbol: "BTC/USDT", + entry: { + type: "Limit", + side: "Buy", + price: 10000, + quantity: 0.0001, + }, + takeProfit: { + type: "Limit", + side: "Sell", + price: 1000000, + quantity: 0.0001, + }, + }); + }); + + afterEach(async () => { + if (tradeExecutor) { + await tradeExecutor.cancelOrders(); + } + + if (smartTrade) { + await xprisma.smartTrade.delete({ + where: { + id: smartTrade.id, + }, + }); + } + }); + + it("should cancel the position with status Idle", async () => { + tradeExecutor = new TradeExecutor(smartTrade, exchange); + + expect(tradeExecutor.status).toBe("Entering"); + + const cancelledCount = await tradeExecutor.cancelOrders(); + + expect(tradeExecutor.status).toBe("Finished"); + expect(cancelledCount).toBe(2); + }); + + it("should cancel the position when entry order is Placed", async () => { + tradeExecutor = new TradeExecutor(smartTrade, exchange); + + await tradeExecutor.next(); + expect(tradeExecutor.status).toBe("Entering"); + + const cancelled = await tradeExecutor.cancelOrders(); + expect(tradeExecutor.status).toBe("Finished"); + expect(cancelled).toBe(2); + }); + + it("should cancel the position when entry order is Filled", async () => { + tradeExecutor = new TradeExecutor(smartTrade, exchange); + + await tradeExecutor.next(); + expect(tradeExecutor.status).toBe("Entering"); + + await updateEntryOrder( + { + status: "Filled", + filledPrice: 10000, + filledAt: new Date(), + }, + smartTrade, + ); + await tradeExecutor.pull(); + expect(tradeExecutor.status).toBe("Existing"); + + const cancelled = await tradeExecutor.cancelOrders(); + expect(tradeExecutor.status).toBe("Finished"); + expect(cancelled).toBe(1); + }); +}); diff --git a/packages/processing/src/executors/trade/trade.executor.ts b/packages/processing/src/executors/trade/trade.executor.ts new file mode 100644 index 00000000..5278b8cf --- /dev/null +++ b/packages/processing/src/executors/trade/trade.executor.ts @@ -0,0 +1,207 @@ +import { xprisma } 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"; +import type { ISmartTradeExecutor } from "../smart-trade-executor.interface"; +import { OrderExecutor } from "../order/order.executor"; + +export class TradeExecutor implements ISmartTradeExecutor { + smartTrade: SmartTradeWithOrders; + exchange: IExchange; + + constructor(smartTrade: SmartTradeWithOrders, exchange: IExchange) { + this.smartTrade = smartTrade; + this.exchange = exchange; + } + + static create( + smartTrade: SmartTradeWithOrders, + exchangeAccount: ExchangeAccountWithCredentials, + ) { + const exchange = exchangeProvider.fromAccount(exchangeAccount); + + return new TradeExecutor(smartTrade, exchange); + } + + static async fromId(id: number) { + const smartTrade = await xprisma.smartTrade.findUniqueOrThrow({ + where: { + id, + }, + include: { + orders: true, + exchangeAccount: true, + }, + }); + + const exchange = exchangeProvider.fromAccount(smartTrade.exchangeAccount); + + return new TradeExecutor(smartTrade, exchange); + } + + static async fromOrderId(orderId: number) { + const order = await xprisma.order.findUniqueOrThrow({ + where: { + id: orderId, + }, + include: { + smartTrade: { + include: { + orders: true, + exchangeAccount: true, + }, + }, + }, + }); + + const exchange = exchangeProvider.fromAccount( + order.smartTrade.exchangeAccount, + ); + + return new TradeExecutor(order.smartTrade, exchange); + } + + static async fromExchangeOrderId(exchangeOrderId: string) { + const order = await xprisma.order.findFirstOrThrow({ + where: { + exchangeOrderId, + }, + include: { + smartTrade: { + include: { + orders: true, + exchangeAccount: true, + }, + }, + }, + }); + + const exchange = exchangeProvider.fromAccount( + order.smartTrade.exchangeAccount, + ); + + return new TradeExecutor(order.smartTrade, exchange); + } + + /** + * Places the entry order and take profit order on the exchange. + * 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", + ); + + if (entryOrder.status === "Idle") { + const orderExecutor = new OrderExecutor( + entryOrder, + this.exchange, + this.smartTrade.exchangeSymbolId, + ); + await orderExecutor.place(); + await this.pull(); + + 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.exchangeSymbolId, + ); + await orderExecutor.place(); + await this.pull(); + + logger.info( + `Take profit order was placed: Position { id: ${this.smartTrade.id}, entryOrderStatus: ${entryOrder.status}, takeProfitOrderStatus: ${takeProfitOrder.status} }`, + ); + + return true; + } + + logger.info( + `Nothing to do: Position { id: ${this.smartTrade.id}, entryOrderStatus: ${entryOrder.status}, takeProfitOrderStatus: ${takeProfitOrder?.status} }`, + ); + 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 orderExecutor = new OrderExecutor( + order, + this.exchange, + this.smartTrade.exchangeSymbolId, + ); + + const cancelled = await orderExecutor.cancel(); + allOrders.push(cancelled); + } + + await this.pull(); + + const cancelledOrders = allOrders.filter((cancelled) => cancelled); + logger.info( + `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/index.ts b/packages/processing/src/index.ts index 7b3e0a44..e4c30d84 100644 --- a/packages/processing/src/index.ts +++ b/packages/processing/src/index.ts @@ -1,3 +1,4 @@ export * from "./bot"; export * from "./smart-trade"; export * from "./exchange-account"; +export * from "./executors"; diff --git a/packages/processing/src/smart-trade/index.ts b/packages/processing/src/smart-trade/index.ts index 744d5ce3..525af385 100644 --- a/packages/processing/src/smart-trade/index.ts +++ b/packages/processing/src/smart-trade/index.ts @@ -1 +1 @@ -export * from "./smart-trade.processor"; +export * from "./smart-trade.synchronizer"; diff --git a/packages/processing/src/smart-trade/smart-trade.processor.ts b/packages/processing/src/smart-trade/smart-trade.synchronizer.ts similarity index 53% rename from packages/processing/src/smart-trade/smart-trade.processor.ts rename to packages/processing/src/smart-trade/smart-trade.synchronizer.ts index 082f16cf..f631e3b9 100644 --- a/packages/processing/src/smart-trade/smart-trade.processor.ts +++ b/packages/processing/src/smart-trade/smart-trade.synchronizer.ts @@ -6,15 +6,11 @@ import type { SmartTradeWithOrders, } from "@opentrader/db"; import { - assertHasExchangeOrderId, assertIsOrderBased, toSmartTradeEntity, xprisma, } from "@opentrader/db"; -import type { - IGetLimitOrderResponse, - IPlaceLimitOrderResponse, -} from "@opentrader/types"; +import type { IGetLimitOrderResponse } from "@opentrader/types"; import { OrderNotFound } from "ccxt"; type SyncParams = { @@ -28,7 +24,7 @@ type SyncParams = { ) => Promise | void; }; -export class SmartTradeProcessor { +export class SmartTradeSynchronizer { private exchange: IExchange; private smartTrade: SmartTradeEntity_Order_Order; private exchangeAccount: ExchangeAccountWithCredentials; @@ -58,7 +54,7 @@ export class SmartTradeProcessor { }, }); - return new SmartTradeProcessor(smartTrade, smartTrade.exchangeAccount); + return new SmartTradeSynchronizer(smartTrade, smartTrade.exchangeAccount); } static async fromOrderId(orderId: number) { @@ -76,113 +72,11 @@ export class SmartTradeProcessor { }, }); - return new SmartTradeProcessor(smartTrade, smartTrade.exchangeAccount); - } - - /** - * Will place Entry Order if not placed before. - * OR - * Will place takeProfit order if Entry Order was filled. - */ - async placeNext() { - const { entryOrder, takeProfitOrder } = this.smartTrade; - - const orderPendingPlacement = - entryOrder.status === "Idle" - ? entryOrder - : entryOrder.status === "Filled" && takeProfitOrder.status === "Idle" - ? takeProfitOrder - : null; - - if (orderPendingPlacement) { - const exchangeOrder = await this.placeOrder(orderPendingPlacement); - console.log( - `⚙️ @SmartTradeProcessor.placeNext(): Order #${orderPendingPlacement.id} placed`, - ); - console.log(exchangeOrder); - } - } - - private async placeOrder( - order: OrderEntity, - ): Promise { - if (order.type === "Market") { - throw new Error("placeOrder: Market order is not supported yet"); - } - - const exchangeOrder = await this.exchange.placeLimitOrder({ - symbol: this.smartTrade.exchangeSymbolId, - side: order.side === "Buy" ? "buy" : "sell", // @todo map helper - price: order.price, - quantity: order.quantity, - }); - - // Update status to Placed - // Save exchange orderId to DB - await xprisma.order.update({ - where: { - id: order.id, - }, - data: { - status: "Placed", - exchangeOrderId: exchangeOrder.orderId, - placedAt: new Date(), // maybe use Exchange time (if possible) - }, - }); - - return exchangeOrder; - } - - /** - * Will cancel all orders that belongs to the SmartTrade - */ - async cancelOrders() { - console.log( - "⚙️ @opentrader/processing: SmartTradeProcessor.cancelOrders()", - ); - const { entryOrder, takeProfitOrder } = this.smartTrade; - - await this.cancelOrder(entryOrder); - await this.cancelOrder(takeProfitOrder); - } - - private async cancelOrder(order: OrderEntity) { - // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- "Canceled" | "Revoked" | "Deleted" doesn't require to be processed - switch (order.status) { - case "Idle": - await xprisma.order.updateStatus("Revoked", order.id); - console.log(` Canceled order #${order.id}: Idle -> Revoked`); - return; - case "Placed": - assertHasExchangeOrderId(order); - - try { - await this.exchange.cancelLimitOrder({ - orderId: order.exchangeOrderId, - symbol: this.smartTrade.exchangeSymbolId, - }); - await xprisma.order.updateStatus("Canceled", order.id); - - console.log(` Canceled order #${order.id}: Placed -> Canceled`); - } catch (err) { - if (err instanceof OrderNotFound) { - await xprisma.order.updateStatus("Deleted", order.id); - - console.log( - ` Canceled order #${order.id}: Placed -> Deleted (order not found on the exchange)`, - ); - } else { - throw err; - } - } - return; - case "Filled": - await xprisma.order.removeRef(order.id); - } + return new SmartTradeSynchronizer(smartTrade, smartTrade.exchangeAccount); } /** - * Sync orders: `exchange -> db` + * Manual syncing orders with the exchange. Update statuses in DB. * - Sync order status `Placed -> Filled` * - Save `fee` to DB if order was filled */ @@ -199,7 +93,7 @@ export class SmartTradeProcessor { private async syncOrder(order: OrderEntity, params: SyncParams) { const { onFilled, onCanceled } = params; - console.log("⚙️ @opentrader/processing"); + console.log(`SmartTradeSynchronizer: Syncing order (id: ${order.id}) status with the exchange`); if (!order.exchangeOrderId) { throw new Error("Order: Missing `exchangeOrderId`"); diff --git a/packages/processing/src/utils/test.ts b/packages/processing/src/utils/test.ts new file mode 100644 index 00000000..c0c08f39 --- /dev/null +++ b/packages/processing/src/utils/test.ts @@ -0,0 +1,139 @@ +// Only for testing purposes. Don't export this file. +import type { + $Enums, + ExchangeAccountWithCredentials, + SmartTradeWithOrders, +} from "@opentrader/db"; +import { xprisma } from "@opentrader/db"; + +export const TEST_ACCOUNT_LABEL = "TEST"; + +type OrderParams = { + type: $Enums.OrderType; + side: $Enums.OrderSide; + price: number; + quantity: number; +}; + +type CreateTradeParams = { + symbol: string; + entry: OrderParams; + takeProfit?: OrderParams; +}; + +export async function createTrade( + params: CreateTradeParams, + exchangeLabel = TEST_ACCOUNT_LABEL, +): Promise { + const { symbol, entry, takeProfit } = params; + const [baseCurrency, quoteCurrency] = symbol.split("/"); + + const exchangeAccount = await xprisma.exchangeAccount.findFirstOrThrow({ + where: { + label: exchangeLabel, + }, + }); + + // @todo Array + const orders: (OrderParams & { entityType: $Enums.EntityType })[] = []; + orders.push({ + entityType: "EntryOrder", + ...entry, + }); + if (takeProfit) { + orders.push({ + entityType: "TakeProfitOrder", + ...takeProfit, + }); + } + + return xprisma.smartTrade.create({ + data: { + entryType: "Order", + takeProfitType: "Order", + + ref: null, + type: "Trade", + exchangeSymbolId: params.symbol, + baseCurrency, + quoteCurrency, + + orders: { + createMany: { + data: orders, + }, + }, + + exchangeAccount: { + connect: { + id: exchangeAccount.id, + }, + }, + + owner: { + connect: { + id: exchangeAccount.ownerId, + }, + }, + }, + include: { + exchangeAccount: true, + orders: true, + }, + }); +} + +export async function getExchangeAccount( + label = TEST_ACCOUNT_LABEL, +): Promise { + return xprisma.exchangeAccount.findFirstOrThrow({ + where: { + label, + }, + }); +} + +type UpdateOrderParams = { + price?: number; + filledPrice?: number; + filledAt?: Date; + quantity?: number; + status?: $Enums.OrderStatus; +}; + +export async function updateOrder( + params: UpdateOrderParams, + trade: SmartTradeWithOrders, + entityType: $Enums.EntityType, +) { + const entryOrder = trade.orders.find( + (order) => order.entityType === "EntryOrder", + ); + + if (!entryOrder) { + throw new Error("Entry order not found"); + } + + return xprisma.order.update({ + where: { + id: entryOrder.id, + }, + data: { + ...params, + }, + }); +} + +export async function updateEntryOrder( + params: UpdateOrderParams, + trade: SmartTradeWithOrders, +) { + return updateOrder(params, trade, "EntryOrder"); +} + +export async function updateTakeProfitOrder( + params: UpdateOrderParams, + trade: SmartTradeWithOrders, +) { + return updateOrder(params, trade, "TakeProfitOrder"); +} diff --git a/packages/trpc/src/routers/private/bot/cron-place-pending-orders/handler.ts b/packages/trpc/src/routers/private/bot/cron-place-pending-orders/handler.ts index 03260a23..efb5192c 100644 --- a/packages/trpc/src/routers/private/bot/cron-place-pending-orders/handler.ts +++ b/packages/trpc/src/routers/private/bot/cron-place-pending-orders/handler.ts @@ -1,5 +1,5 @@ import { xprisma } from "@opentrader/db"; -import { SmartTradeProcessor } from "@opentrader/processing"; +import { SmartTradeExecutor } from "@opentrader/processing"; import type { Context } from "../../../../utils/context"; import type { TCronPlacePendingOrdersInputSchema } from "./schema"; @@ -39,12 +39,12 @@ export async function cronPlacePendingOrders({ input }: Options) { } for (const smartTrade of smartTrades) { - const processor = new SmartTradeProcessor( + const processor = SmartTradeExecutor.create( smartTrade, smartTrade.exchangeAccount, ); - await processor.placeNext(); + await processor.next(); } return { diff --git a/packages/trpc/src/routers/private/bot/sync-orders/handler.ts b/packages/trpc/src/routers/private/bot/sync-orders/handler.ts index 207b5b31..79e39f35 100644 --- a/packages/trpc/src/routers/private/bot/sync-orders/handler.ts +++ b/packages/trpc/src/routers/private/bot/sync-orders/handler.ts @@ -1,4 +1,8 @@ -import { BotProcessing, SmartTradeProcessor } from "@opentrader/processing"; +import { + BotProcessing, + SmartTradeSynchronizer, + SmartTradeExecutor, +} from "@opentrader/processing"; import type { OrderEntity } from "@opentrader/db"; import { xprisma } from "@opentrader/db"; import type { IGetLimitOrderResponse } from "@opentrader/types"; @@ -60,12 +64,12 @@ export async function syncOrders({ input }: Options) { }; for (const smartTrade of smartTrades) { - const processor = new SmartTradeProcessor( + const smartTradeSynchronizer = new SmartTradeSynchronizer( smartTrade, smartTrade.exchangeAccount, ); - await processor.sync({ + await smartTradeSynchronizer.sync({ onFilled, onCanceled, }); @@ -91,12 +95,12 @@ export async function syncOrders({ input }: Options) { }); for (const smartTrade of pendingSmartTrades) { - const processor = new SmartTradeProcessor( + const processor = SmartTradeExecutor.create( smartTrade, smartTrade.exchangeAccount, ); - await processor.placeNext(); + await processor.next(); } return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c04cd5c..1d695b0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -707,10 +707,16 @@ importers: '@opentrader/logger': specifier: workspace:* version: link:../logger + '@prisma/client': + specifier: 5.13.0 + version: 5.13.0(prisma@5.13.0) ccxt: specifier: 4.1.48 version: 4.1.48 devDependencies: + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@opentrader/eslint-config': specifier: workspace:* version: link:../eslint-config @@ -720,12 +726,21 @@ importers: '@opentrader/types': specifier: workspace:* version: link:../types + '@types/jest': + specifier: ^29.5.12 + version: 29.5.12 '@types/node': specifier: ^20.12.11 version: 20.12.11 eslint: specifier: 8.54.0 version: 8.54.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.12.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.2.2)) + ts-jest: + specifier: ^29.1.2 + version: 29.1.2(@babel/core@7.23.3)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.3))(jest@29.7.0(@types/node@20.12.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.12.11)(typescript@5.2.2)))(typescript@5.2.2) typescript: specifier: ^5.2.2 version: 5.2.2 @@ -8765,7 +8780,7 @@ snapshots: '@jest/types': 29.6.3 '@types/node': 20.12.11 chalk: 4.1.2 - ci-info: 3.8.0 + ci-info: 3.9.0 graceful-fs: 4.2.11 picomatch: 2.3.1