diff --git a/config.default.json5 b/config.default.json5 index 24e7ee56..612975bd 100644 --- a/config.default.json5 +++ b/config.default.json5 @@ -1,12 +1,10 @@ { - label: "default", settings: { highPrice: 0.8, lowPrice: 0.35, gridLevels: 6, quantityPerGrid: 10, }, - baseCurrency: "ADA", - quoteCurrency: "USDT", - exchangeCode: "OKX", + pair: "ADA/USDT", + exchange: "OKX", // specify exchange credentials in the `exchanges.json5` config using this key } diff --git a/packages/bot-processor/src/types/bot/bot-configuration.interface.ts b/packages/bot-processor/src/types/bot/bot-configuration.interface.ts index fecbc43c..204b550f 100644 --- a/packages/bot-processor/src/types/bot/bot-configuration.interface.ts +++ b/packages/bot-processor/src/types/bot/bot-configuration.interface.ts @@ -6,5 +6,4 @@ export type IBotConfiguration = { quoteCurrency: string; exchangeCode: ExchangeCode; settings: T; - label?: string; }; diff --git a/packages/bot/src/market-data/ccxt-candles.provider.ts b/packages/bot/src/market-data/ccxt-candles.provider.ts index 0bdd7bea..99c7762a 100644 --- a/packages/bot/src/market-data/ccxt-candles.provider.ts +++ b/packages/bot/src/market-data/ccxt-candles.provider.ts @@ -77,7 +77,7 @@ export class CCXTCandlesProvider this.on("start", () => { console.log( - "CandleProvider: Start fetching candles from", + `CandleProvider: Start fetching ${this.symbol} candles from`, this.startDate, "to", this.endDate, diff --git a/packages/cli/src/api/run-backtest.ts b/packages/cli/src/api/run-backtest.ts index 0f45c6f0..46d49f82 100644 --- a/packages/cli/src/api/run-backtest.ts +++ b/packages/cli/src/api/run-backtest.ts @@ -3,23 +3,26 @@ import { templates } from "@opentrader/bot-templates"; import { Backtesting } from "@opentrader/backtesting"; import { CCXTCandlesProvider } from "@opentrader/bot"; import { logger } from "@opentrader/logger"; -import type { BarSize, ICandlestick } from "@opentrader/types"; +import { exchangeCodeMapCCXT } from "@opentrader/exchanges"; +import type { BarSize, ExchangeCode, ICandlestick } from "@opentrader/types"; import type { CommandResult, ConfigName } from "../types"; import { readBotConfig } from "../config"; -import { exchangeCodeMapCCXT } from "@opentrader/exchanges"; + +type Options = { + config: ConfigName; + from: Date; + to: Date; + timeframe: BarSize; + pair: string; + exchange: ExchangeCode; +}; export async function runBacktest( strategyName: keyof typeof templates, - options: { - config: ConfigName; - from: Date; - to: Date; - timeframe: BarSize; - symbol: string; - }, + options: Options, ): Promise { - const config = readBotConfig(options.config); - logger.debug(config, "Parsed bot config"); + const botConfig = readBotConfig(options.config); + logger.debug(botConfig, "Parsed bot config"); const strategyExists = strategyName in templates; if (!strategyExists) { @@ -33,12 +36,26 @@ export async function runBacktest( }; } - const ccxtExchange = exchangeCodeMapCCXT[config.exchangeCode]; + const botTemplate = strategyName || botConfig.template; + const botTimeframe = options.timeframe || botConfig.timeframe || null; + const botPair = options.pair || botConfig.pair; + const [baseCurrency, quoteCurrency] = botPair.split("/"); + + const ccxtExchange = exchangeCodeMapCCXT[options.exchange]; const exchange = new ccxt[ccxtExchange](); + logger.info( + `Using ${botPair} on ${options.exchange} exchange with ${botTimeframe} timeframe`, + ); const backtesting = new Backtesting({ - botConfig: config, - botTemplate: templates[strategyName], + botConfig: { + id: 0, + baseCurrency, + quoteCurrency, + exchangeCode: options.exchange, + settings: botConfig.settings, + }, + botTemplate: templates[botTemplate], }); return new Promise((resolve) => { @@ -46,8 +63,8 @@ export async function runBacktest( const candleProvider = new CCXTCandlesProvider({ exchange, - symbol: options.symbol, - timeframe: options.timeframe, + symbol: botPair, + timeframe: botTimeframe, startDate: options.from, endDate: options.to, }); diff --git a/packages/cli/src/api/run-trading.ts b/packages/cli/src/api/run-trading.ts index 844b2e42..3a09ffc9 100644 --- a/packages/cli/src/api/run-trading.ts +++ b/packages/cli/src/api/run-trading.ts @@ -1,18 +1,28 @@ -import type { IBotConfiguration } from "@opentrader/bot-processor"; import { templates } from "@opentrader/bot-templates"; import { logger } from "@opentrader/logger"; import { Processor } from "@opentrader/bot"; import { xprisma } from "@opentrader/db"; import type { TBot, ExchangeAccountWithCredentials } from "@opentrader/db"; import { BotProcessing } from "@opentrader/processing"; -import type { CommandResult, ConfigName, ExchangeConfig } from "../types"; +import { BarSize } from "@opentrader/types"; +import type { + BotConfig, + CommandResult, + ConfigName, + ExchangeConfig, +} from "../types"; import { readBotConfig, readExchangesConfig } from "../config"; +type Options = { + config: ConfigName; + pair?: string; + exchange?: string; + timeframe?: BarSize; +}; + export async function runTrading( strategyName: keyof typeof templates, - options: { - config: ConfigName; - }, + options: Options, ): Promise { const config = readBotConfig(options.config); logger.debug(config, "Parsed bot config"); @@ -35,7 +45,12 @@ export async function runTrading( // Saving exchange accounts to DB if not exists const exchangeAccounts: ExchangeAccountWithCredentials[] = await createOrUpdateExchangeAccounts(exchangesConfig); - const bot = await createOrUpdateBot(strategyName, config, exchangeAccounts); + const bot = await createOrUpdateBot( + strategyName, + options, + config, + exchangeAccounts, + ); const processor = new Processor(exchangeAccounts, [bot]); await processor.onApplicationBootstrap(); @@ -127,40 +142,51 @@ async function createOrUpdateExchangeAccounts( return exchangeAccounts; } -async function createOrUpdateBot( +async function createOrUpdateBot( strategyName: string, - botConfig: IBotConfiguration, + options: Options, + botConfig: BotConfig, exchangeAccounts: ExchangeAccountWithCredentials[], ): Promise { + const exchangeLabel = options.exchange || botConfig.exchange; + const botType = botConfig.type || "Bot"; + const botName = botConfig.name || "Default bot"; + const botLabel = botConfig.label || "default"; + const botTemplate = strategyName || botConfig.template; + const botTimeframe = options.timeframe || botConfig.timeframe || null; + const botPair = options.pair || botConfig.pair; + const [baseCurrency, quoteCurrency] = botPair.split("/"); + const exchangeAccount = exchangeAccounts.find( - (exchangeAccount) => - exchangeAccount.exchangeCode === botConfig.exchangeCode, + (exchangeAccount) => exchangeAccount.label === exchangeLabel, ); if (!exchangeAccount) { throw new Error( - `Exchange account with code "${botConfig.exchangeCode}" not found to create the bot`, + `Exchange account with label "${exchangeLabel}" not found. Check the exchanges config file.`, ); } let bot = await xprisma.bot.custom.findFirst({ where: { - label: botConfig.label, + label: botLabel, }, }); if (bot) { - logger.info(`Bot "${botConfig.label}" found in DB. Updating...`); + logger.info(`Bot "${botLabel}" found in DB. Updating...`); + bot = await xprisma.bot.custom.update({ where: { id: bot.id, }, data: { - type: "Bot", - name: "Default bot", - label: botConfig.label, - template: strategyName, - baseCurrency: botConfig.baseCurrency, - quoteCurrency: botConfig.quoteCurrency, + type: botType, + name: botName, + label: botLabel, + template: botTemplate, + timeframe: botTimeframe, + baseCurrency, + quoteCurrency, settings: botConfig.settings as object, exchangeAccount: { connect: { @@ -175,17 +201,18 @@ async function createOrUpdateBot( }, }); - logger.info(`Bot "${botConfig.label}" updated`); + logger.info(`Bot "${botLabel}" updated`); } else { - logger.info(`Bot "${botConfig.label}" not found. Adding to DB...`); + logger.info(`Bot "${botLabel}" not found. Adding to DB...`); bot = await xprisma.bot.custom.create({ data: { - type: "Bot", - name: "Default bot", - label: botConfig.label, + type: botType, + name: botName, + label: botLabel, template: strategyName, - baseCurrency: botConfig.baseCurrency, - quoteCurrency: botConfig.quoteCurrency, + timeframe: botTimeframe, + baseCurrency, + quoteCurrency, settings: botConfig.settings as object, exchangeAccount: { connect: { @@ -200,7 +227,7 @@ async function createOrUpdateBot( }, }); - logger.info(`Bot "${botConfig.label}" created`); + logger.info(`Bot "${botLabel}" created`); } return bot; diff --git a/packages/cli/src/api/stop-command.ts b/packages/cli/src/api/stop-command.ts index b9d6b09c..6b56da87 100644 --- a/packages/cli/src/api/stop-command.ts +++ b/packages/cli/src/api/stop-command.ts @@ -14,14 +14,16 @@ export async function stopCommand(options: { const exchangesConfig = readExchangesConfig(options.config); logger.debug(exchangesConfig, "Parsed exchanges config"); + const botLabel = config.label || "default"; + const bot = await xprisma.bot.custom.findUnique({ where: { - label: config.label, + label: botLabel, }, }); if (!bot) { - logger.info(`Bot "${config.label}" does not exists. Nothing to stop`); + logger.info(`Bot "${botLabel}" does not exists. Nothing to stop`); return { result: undefined, diff --git a/packages/cli/src/commands/backtest.ts b/packages/cli/src/commands/backtest.ts index f4d6329a..3e478908 100644 --- a/packages/cli/src/commands/backtest.ts +++ b/packages/cli/src/commands/backtest.ts @@ -1,6 +1,8 @@ +import { ExchangeCode } from "@opentrader/types"; import type { Command } from "commander"; import { Argument, Option } from "commander"; import { DEFAULT_CONFIG_NAME } from "../config"; +import { validateExchange, validatePair } from "../utils/validate"; import { handle } from "../utils/command"; import * as api from "../api"; @@ -20,9 +22,7 @@ export function addBacktestCommand(program: Command) { .default(new Date("2024-01-07")), ) .addOption( - new Option("-s, --symbol ", "Symbol") - .argParser((symbol) => symbol.toUpperCase()) - .default("BTC/USDT"), + new Option("-p, --pair ", "Trading pair").argParser(validatePair), ) .addOption( new Option("-b, --timeframe ", "Timeframe").default("1h"), @@ -32,5 +32,10 @@ export function addBacktestCommand(program: Command) { DEFAULT_CONFIG_NAME, ), ) + .addOption( + new Option("-e, --exchange ", "Exchange") + .argParser(validateExchange) + .default(ExchangeCode.OKX), + ) .action(handle(api.runBacktest)); } diff --git a/packages/cli/src/commands/trade.ts b/packages/cli/src/commands/trade.ts index 72ce4f24..c4114af3 100644 --- a/packages/cli/src/commands/trade.ts +++ b/packages/cli/src/commands/trade.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { Argument, Option } from "commander"; import { DEFAULT_CONFIG_NAME } from "../config"; +import { validatePair, validateTimeframe } from "../utils/validate"; import { handle } from "../utils/command"; import * as api from "../api"; @@ -14,5 +15,14 @@ export function addTradeCommand(program: Command) { DEFAULT_CONFIG_NAME, ), ) + .addOption( + new Option("-p, --pair ", "Trading pair").argParser(validatePair), + ) + .addOption(new Option("-e, --exchange ", "Exchange account")) + .addOption( + new Option("-t, --timeframe ", "Timeframe") + .argParser(validateTimeframe) + .default(null), + ) .action(handle(api.runTrading)); } diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 24b24678..a41a85fa 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -2,8 +2,7 @@ import * as fs from "node:fs"; import { join } from "node:path"; import JSON5 from "json5"; import { logger } from "@opentrader/logger"; -import type { IBotConfiguration } from "@opentrader/bot-processor"; -import type { ConfigName, ExchangeConfig } from "./types"; +import { BotConfig, ConfigName, ExchangeConfig } from "./types"; const rootDir = join(__dirname, ".."); @@ -24,14 +23,12 @@ export const DEFAULT_CONFIG_NAME: ConfigName = */ export function readBotConfig( configName: ConfigName = DEFAULT_CONFIG_NAME, -): IBotConfiguration { +): BotConfig { const configFileName = `config.${configName}.json5`; const configPath = `${rootDir}/${configFileName}`; logger.info(`Using bot config file: ${configFileName}`); - const config = JSON5.parse>( - fs.readFileSync(configPath, "utf8"), - ); + const config = JSON5.parse>(fs.readFileSync(configPath, "utf8")); return config; } diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index b3a4d199..7ddaa9e0 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,4 +1,4 @@ -import type { ExchangeAccount } from "@prisma/client"; +import type { ExchangeAccount, Bot } from "@prisma/client"; export type ConfigName = "default" | "dev" | "prod"; @@ -15,3 +15,22 @@ export type ExchangeConfig = Pick< | "exchangeCode" | "isDemoAccount" >; + +export type BotConfig = Partial< + Pick +> & + Pick & { + /** + * Strategy params + */ + settings: S; + /** + * Exchange account label. + * This label should match the key in the exchanges config file (exchanges.json5). + */ + exchange: string; + /** + * Trading pair + */ + pair: string; + }; diff --git a/packages/cli/src/utils/validate.ts b/packages/cli/src/utils/validate.ts new file mode 100644 index 00000000..c5b017b6 --- /dev/null +++ b/packages/cli/src/utils/validate.ts @@ -0,0 +1,48 @@ +import { BarSize, ExchangeCode } from "@opentrader/types"; + +export function validateTimeframe(timeframe?: string | null): BarSize | null { + if (!timeframe) { + return null; + } + + const validTimeframes = Object.values(BarSize); + + if (validTimeframes.includes(timeframe as BarSize)) { + return timeframe as BarSize; + } + + throw new Error( + `Invalid timeframe: ${timeframe}. Valid values are: ${validTimeframes.join(", ")}`, + ); +} + +export function validatePair(pair?: string | null): string { + if (!pair) { + throw new Error("Trading pair is required"); + } + + const [baseCurrency, quoteCurrency] = pair.split("/"); + + if (baseCurrency && quoteCurrency) { + return pair.toUpperCase(); + } + + throw new Error(`Invalid trading pair: ${pair}. Expected format: BTC/USDT`); +} + +export function validateExchange(exchangeCodeParam?: string | null): string { + if (!exchangeCodeParam) { + throw new Error("Exchange is required"); + } + + const validExchanges = Object.values(ExchangeCode); + + const exchangeCode = exchangeCodeParam.toUpperCase() as ExchangeCode; + if (validExchanges.includes(exchangeCode)) { + return exchangeCode; + } + + throw new Error( + `Invalid exchange: ${exchangeCode}. Valid values are: ${validExchanges.join(", ")}`, + ); +}