diff --git a/packages/cli/.eslintrc.js b/apps/cli/.eslintrc.js similarity index 70% rename from packages/cli/.eslintrc.js rename to apps/cli/.eslintrc.js index 3402c71b..1a0bc443 100644 --- a/packages/cli/.eslintrc.js +++ b/apps/cli/.eslintrc.js @@ -4,5 +4,7 @@ module.exports = { extends: ["@opentrader/eslint-config/module.js"], rules: { "import/namespace": "off", + "@typescript-eslint/no-var-requires": "off", + "import/first": "off", }, }; diff --git a/packages/cli/.gitignore b/apps/cli/.gitignore similarity index 100% rename from packages/cli/.gitignore rename to apps/cli/.gitignore diff --git a/packages/cli/LICENSE b/apps/cli/LICENSE similarity index 100% rename from packages/cli/LICENSE rename to apps/cli/LICENSE diff --git a/packages/cli/config.default.json5 b/apps/cli/config.default.json5 similarity index 100% rename from packages/cli/config.default.json5 rename to apps/cli/config.default.json5 diff --git a/packages/cli/config.dev.json5 b/apps/cli/config.dev.json5 similarity index 100% rename from packages/cli/config.dev.json5 rename to apps/cli/config.dev.json5 diff --git a/packages/cli/config.prod.json5 b/apps/cli/config.prod.json5 similarity index 100% rename from packages/cli/config.prod.json5 rename to apps/cli/config.prod.json5 diff --git a/packages/cli/exchanges.default.json5 b/apps/cli/exchanges.default.json5 similarity index 100% rename from packages/cli/exchanges.default.json5 rename to apps/cli/exchanges.default.json5 diff --git a/packages/cli/exchanges.dev.json5 b/apps/cli/exchanges.dev.json5 similarity index 100% rename from packages/cli/exchanges.dev.json5 rename to apps/cli/exchanges.dev.json5 diff --git a/packages/cli/exchanges.prod.json5 b/apps/cli/exchanges.prod.json5 similarity index 100% rename from packages/cli/exchanges.prod.json5 rename to apps/cli/exchanges.prod.json5 diff --git a/packages/cli/package.json b/apps/cli/package.json similarity index 76% rename from packages/cli/package.json rename to apps/cli/package.json index b11bc87b..41714946 100644 --- a/packages/cli/package.json +++ b/apps/cli/package.json @@ -1,11 +1,11 @@ { - "name": "@opentrader/cli", + "name": "opentrader", "version": "0.0.1", "description": "", "main": "src/index.ts", "types": "src/index.ts", "scripts": { - "build": "tsc", + "build": "webpack", "lint": "eslint . --quiet", "lint:fix": "eslint . --fix" }, @@ -17,8 +17,12 @@ "@opentrader/types": "workspace:*", "@types/node": "^20.12.11", "eslint": "8.54.0", + "ts-loader": "^9.5.1", "ts-node": "10.9.2", - "typescript": "5.4.5" + "typescript": "5.4.5", + "webpack": "^5.91.0", + "webpack-cli": "^5.1.4", + "webpack-node-externals": "^3.0.0" }, "dependencies": { "@opentrader/backtesting": "workspace:*", @@ -33,6 +37,9 @@ "@prisma/client": "5.15.0", "ccxt": "4.3.27", "commander": "^12.0.0", - "json5": "^2.2.3" + "jayson": "^4.1.0", + "json5": "^2.2.3", + "pino": "^9.0.0", + "pino-pretty": "^11.0.0" } } diff --git a/apps/cli/src/api/down.ts b/apps/cli/src/api/down.ts new file mode 100644 index 00000000..8880fb3b --- /dev/null +++ b/apps/cli/src/api/down.ts @@ -0,0 +1,41 @@ +import { logger } from "@opentrader/logger"; +import { CommandResult } from "../types"; +import { getPid, clearPid } from "../utils/pid"; + +type Options = { + force: boolean; +}; + +export async function down(options: Options): Promise { + const pid = getPid(); + + if (!pid) { + logger.warn("There is no running daemon process. Nothing to stop."); + return { + result: undefined, + }; + } + + try { + if (options.force) { + process.kill(pid, "SIGKILL"); + logger.info( + `Daemon process with PID ${pid} has been forcefully stopped.`, + ); + } else { + process.kill(pid, "SIGTERM"); + logger.info( + `Daemon process with PID ${pid} has been gracefully stopped.`, + ); + } + } catch (err) { + logger.info(`Failed to stop daemon process with PID ${pid}`); + logger.error(err); + } + + clearPid(); + + return { + result: undefined, + }; +} diff --git a/packages/cli/src/api/grid-lines.ts b/apps/cli/src/api/grid-lines.ts similarity index 100% rename from packages/cli/src/api/grid-lines.ts rename to apps/cli/src/api/grid-lines.ts diff --git a/packages/cli/src/api/index.ts b/apps/cli/src/api/index.ts similarity index 54% rename from packages/cli/src/api/index.ts rename to apps/cli/src/api/index.ts index 096e8166..2cdb607a 100644 --- a/packages/cli/src/api/index.ts +++ b/apps/cli/src/api/index.ts @@ -2,3 +2,7 @@ export * from "./grid-lines"; export * from "./run-backtest"; export * from "./run-trading"; export * from "./stop-command"; +export * from "./up/index"; +export * from "./down"; +export * from "./logs"; +export * from "./version"; diff --git a/apps/cli/src/api/logs.ts b/apps/cli/src/api/logs.ts new file mode 100644 index 00000000..f5b46440 --- /dev/null +++ b/apps/cli/src/api/logs.ts @@ -0,0 +1,85 @@ +import { logger } from "@opentrader/logger"; +import { + existsSync, + readFileSync, + createReadStream, + watchFile, +} from "fs"; +import { createInterface } from "readline"; +import { prettyLog } from "../utils/pretty-log"; +import { logPath } from "../utils/app-path"; +import { CommandResult } from "../types"; + +type Options = { + follow: boolean; +}; + +export async function logs(options: Options): Promise { + // const pid = getPid(); + + // if (!pid) { + // logger.info("Not running daemon process. Nothing to show logs for."); + + // return { + // result: undefined, + // }; + // } + + const logFileExists = existsSync(logPath); + + if (!logFileExists) { + logger.info("Log file does not exist. Nothing to show logs for."); + + return { + result: undefined, + }; + } + + if (options.follow) { + const logsData = readFileSync(logPath, "utf8"); + const logsLines = logsData.split("\n"); + + // Print the last 10 lines + for (const line of logsLines.slice(-10)) { + const isBreak = line === ""; + if (!isBreak) { + prettyLog(line); + } + } + + // Keep track of the last file size + let lastSize = 0; + + watchFile(logPath, (curr, prev) => { + if (curr.size > prev.size) { + const stream = createReadStream(logPath, { + start: lastSize, + end: curr.size, + }); + const rl = createInterface({ input: stream }); + + rl.on("line", (line) => { + prettyLog(line); + }); + + rl.on("close", () => { + lastSize = curr.size; + }); + } + }); + } else { + const logsData = readFileSync(logPath, "utf8"); + const logsLines = logsData.split("\n"); + + for (const line of logsLines) { + const isBreak = line === ""; + if (!isBreak) { + prettyLog(line); + } + } + } + + return { + result: undefined, + }; +} diff --git a/packages/cli/src/api/run-backtest.ts b/apps/cli/src/api/run-backtest.ts similarity index 85% rename from packages/cli/src/api/run-backtest.ts rename to apps/cli/src/api/run-backtest.ts index 46d49f82..8091fcac 100644 --- a/packages/cli/src/api/run-backtest.ts +++ b/apps/cli/src/api/run-backtest.ts @@ -1,5 +1,6 @@ import { pro as ccxt } from "ccxt"; import { templates } from "@opentrader/bot-templates"; +import { findStrategy } from "@opentrader/bot-templates/server"; import { Backtesting } from "@opentrader/backtesting"; import { CCXTCandlesProvider } from "@opentrader/bot"; import { logger } from "@opentrader/logger"; @@ -24,19 +25,17 @@ export async function runBacktest( const botConfig = readBotConfig(options.config); logger.debug(botConfig, "Parsed bot config"); - const strategyExists = strategyName in templates; - if (!strategyExists) { - const availableStrategies = Object.keys(templates).join(", "); - logger.info( - `Strategy "${strategyName}" does not exists. Available strategies: ${availableStrategies}`, - ); + let strategy: Awaited>; + try { + strategy = await findStrategy(strategyName); + } catch (err) { + logger.info((err as Error).message); return { result: undefined, }; } - const botTemplate = strategyName || botConfig.template; const botTimeframe = options.timeframe || botConfig.timeframe || null; const botPair = options.pair || botConfig.pair; const [baseCurrency, quoteCurrency] = botPair.split("/"); @@ -55,7 +54,7 @@ export async function runBacktest( exchangeCode: options.exchange, settings: botConfig.settings, }, - botTemplate: templates[botTemplate], + botTemplate: strategy.strategyFn, }); return new Promise((resolve) => { diff --git a/apps/cli/src/api/run-trading.ts b/apps/cli/src/api/run-trading.ts new file mode 100644 index 00000000..82fac8c1 --- /dev/null +++ b/apps/cli/src/api/run-trading.ts @@ -0,0 +1,32 @@ +import { templates } from "@opentrader/bot-templates"; +import { BarSize } from "@opentrader/types"; +import { Client } from "jayson/promise"; +import type { CommandResult, ConfigName } from "../types"; + +type Options = { + config: ConfigName; + pair?: string; + exchange?: string; + timeframe?: BarSize; +}; + +export async function runTrading( + strategyName: keyof typeof templates, + options: Options, +): Promise { + const client = Client.http({ + port: 8000, + }); + + const result = client.request("startBot", [ + strategyName, + options.config, + options.pair, + options.exchange, + options.timeframe, + ]); + + return { + result: undefined, + }; +} diff --git a/packages/cli/src/api/stop-command.ts b/apps/cli/src/api/stop-command.ts similarity index 79% rename from packages/cli/src/api/stop-command.ts rename to apps/cli/src/api/stop-command.ts index 6b56da87..305b4b2a 100644 --- a/packages/cli/src/api/stop-command.ts +++ b/apps/cli/src/api/stop-command.ts @@ -1,5 +1,5 @@ -import { templates } from "@opentrader/bot-templates"; -import { xprisma } from "@opentrader/db/dist"; +import { findStrategy } from "@opentrader/bot-templates/server"; +import { xprisma } from "@opentrader/db"; import { logger } from "@opentrader/logger"; import { BotProcessing } from "@opentrader/processing"; import type { CommandResult, ConfigName } from "../types"; @@ -30,12 +30,11 @@ export async function stopCommand(options: { }; } - const strategyExists = bot.template in templates; - if (!strategyExists) { - const availableStrategies = Object.keys(templates).join(", "); - logger.info( - `Strategy "${bot.template}" does not exists. Available strategies: ${availableStrategies}`, - ); + // check if bot strategy file exists + try { + await findStrategy(bot.template); + } catch (err) { + logger.info((err as Error).message); return { result: undefined, diff --git a/apps/cli/src/api/up/daemon.ts b/apps/cli/src/api/up/daemon.ts new file mode 100644 index 00000000..0e6b2683 --- /dev/null +++ b/apps/cli/src/api/up/daemon.ts @@ -0,0 +1,200 @@ +import { Server } from "jayson/promise"; +import { Processor } from "@opentrader/bot"; +import type { ExchangeAccountWithCredentials } from "@opentrader/db"; +import { xprisma } from "@opentrader/db"; +import { logger } from "@opentrader/logger"; +import type { BarSize } from "@opentrader/types"; +import { findStrategy } from "@opentrader/bot-templates/server"; +import { readBotConfig, readExchangesConfig } from "../../config"; +import { + createOrUpdateBot, + createOrUpdateExchangeAccounts, + resetProcessing, + startBot, + stopBot, +} from "../../utils/bot"; +import type { ConfigName } from "../../types"; + +let app: App | null = null; + +class App { + constructor(private processor: Processor) {} + + static async create() { + const exchangeAccounts = await xprisma.exchangeAccount.findMany(); + logger.info(`Found ${exchangeAccounts.length} exchange accounts`); + + const bot = await xprisma.bot.custom.findFirst({ + where: { + label: "default", + }, + }); + logger.info(`Found bot: ${bot ? bot.label : "none"}`); + + const processor = new Processor(exchangeAccounts, bot ? [bot] : []); + await processor.onApplicationBootstrap(); + + return new App(processor); + } + + async destroy() { + if (this.processor) { + await this.processor.beforeApplicationShutdown(); + } + } + + async restart() { + await this.destroy(); + + const exchangeAccounts = await xprisma.exchangeAccount.findMany(); + logger.info(`Found ${exchangeAccounts.length} exchange accounts`); + + const bot = await xprisma.bot.custom.findFirst({ + where: { + label: "default", + }, + }); + logger.info(`Found bot: ${bot ? bot.label : "none"}`); + + const processor = new Processor(exchangeAccounts, bot ? [bot] : []); + await processor.onApplicationBootstrap(); + } +} + +async function createApp() { + app = await App.create(); + logger.info("App created"); + + server.http().listen(8000); + logger.info("RPC Server started on port 8000"); +} +void createApp(); + +type Options = { + config: ConfigName; + pair?: string; + exchange?: string; + timeframe?: BarSize; +}; + +const server = Server({ + async startBot( + args: [ + strategyName: string, + configName: ConfigName, + pair?: string, + exchange?: string, + timeframe?: BarSize, + ], + ) { + const [strategyName, configName, pair, exchange, timeframe] = args; + const options = { + strategyName, + config: configName, + pair, + exchange, + timeframe, + }; + + const config = readBotConfig(options.config); + logger.debug(config, "Parsed bot config"); + + const exchangesConfig = readExchangesConfig(options.config); + logger.debug(exchangesConfig, "Parsed exchanges config"); + + let strategy: Awaited>; + try { + strategy = await findStrategy(strategyName); + } catch (err) { + logger.info((err as Error).message); + + return false; + } + + // Saving exchange accounts to DB if not exists + const exchangeAccounts: ExchangeAccountWithCredentials[] = + await createOrUpdateExchangeAccounts(exchangesConfig); + const bot = await createOrUpdateBot( + strategy.isCustom ? strategy.strategyFilePath : strategyName, + options, + config, + exchangeAccounts, + ); + + await app?.restart(); + + if (bot.enabled) { + logger.info( + `Bot "${bot.label}" is already enabled. Cancelling previous orders...`, + ); + await stopBot(bot.id); + logger.info(`The bot state was cleared`); + } + + if (bot.processing) { + logger.warn( + `Bot "${bot.label}" is already processing. It could happen because previous process was interrupted.`, + ); + await resetProcessing(bot.id); + logger.warn(`The bot processing state was cleared`); + } + + await startBot(bot.id); + logger.info(`Bot "${bot.label}" started`); + + return true; + }, + async stopBot(args: [configName: ConfigName]) { + const [configName] = args; + + const config = readBotConfig(configName); + logger.debug(config, "Parsed bot config"); + + const exchangesConfig = readExchangesConfig(configName); + logger.debug(exchangesConfig, "Parsed exchanges config"); + + const botLabel = config.label || "default"; + + const bot = await xprisma.bot.custom.findUnique({ + where: { + label: botLabel, + }, + }); + + if (!bot) { + logger.info(`Bot "${botLabel}" does not exists. Nothing to stop`); + + return false; + } + + try { + await findStrategy(bot.template); + } catch (err) { + logger.info((err as Error).message); + + return false; + } + + logger.info(`Processing stop command for bot "${bot.label}"...`); + await stopBot(bot.id); + logger.info(`Command stop processed successfully for bot "${bot.label}"`); + + return true; + }, +}); + +async function shutdown() { + logger.info("SIGTERM received"); + + if (app) { + await app.destroy(); + logger.info("App shutted down gracefully."); + } + + server.http().close(); + logger.info("RPC Server shutted down gracefully."); + + process.exit(0); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/apps/cli/src/api/up/index.ts b/apps/cli/src/api/up/index.ts new file mode 100644 index 00000000..f008b057 --- /dev/null +++ b/apps/cli/src/api/up/index.ts @@ -0,0 +1,56 @@ +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { logger } from "@opentrader/logger"; +import type { CommandResult } from "../../types"; +import { appPath } from "../../utils/app-path"; +import { getPid, savePid } from "../../utils/pid"; + +const isDevelopment = process.env.NODE_ENV !== "production"; + +type Options = { + detach: boolean; +}; + +export async function up(options: Options): Promise { + const pid = getPid(); + + if (pid) { + logger.warn(`Daemon process is already running with PID: ${pid}`); + + return { + result: undefined, + }; + } + + const daemonProcess = isDevelopment + ? spawn("ts-node", [join(__dirname, "daemon.ts")], { + detached: options.detach, + stdio: options.detach ? "ignore" : undefined, + }) + : spawn("node", [join(__dirname, "daemon.js")], { + detached: options.detach, + stdio: options.detach ? "ignore" : undefined, + }); + + if (daemonProcess.pid === undefined) { + throw new Error("Failed to start daemon process"); + } + + logger.info(`OpenTrader daemon started with PID: ${daemonProcess.pid}`); + + if (options.detach) { + daemonProcess.unref(); + savePid(daemonProcess.pid); + logger.info(`Daemon process detached and saved to ${appPath}`); + } else { + daemonProcess.stdout?.pipe(process.stdout); + daemonProcess.stderr?.pipe(process.stderr); + } + + console.log("Main process PID:", process.pid); + console.log("Daemon process PID:", daemonProcess.pid); + + return { + result: undefined, + }; +} diff --git a/apps/cli/src/api/version.ts b/apps/cli/src/api/version.ts new file mode 100644 index 00000000..ddf3ee04 --- /dev/null +++ b/apps/cli/src/api/version.ts @@ -0,0 +1,7 @@ +import packageJson from "../../package.json"; + +export async function version() { + return { + result: `OpenTrader version: ${packageJson.version}`, + }; +} diff --git a/packages/cli/src/commands/backtest.ts b/apps/cli/src/commands/backtest.ts similarity index 100% rename from packages/cli/src/commands/backtest.ts rename to apps/cli/src/commands/backtest.ts diff --git a/apps/cli/src/commands/down.ts b/apps/cli/src/commands/down.ts new file mode 100644 index 00000000..6cc69f87 --- /dev/null +++ b/apps/cli/src/commands/down.ts @@ -0,0 +1,14 @@ +import { Command, Option } from "commander"; +import { handle } from "../utils/command"; +import { down } from "../api"; + +export function addDownCommand(program: Command) { + program + .command("down") + .addOption( + new Option("-f, --force", "Forcefully stop the daemon process").default( + false, + ), + ) + .action(handle(down)); +} diff --git a/packages/cli/src/commands/grid-lines.ts b/apps/cli/src/commands/grid-lines.ts similarity index 100% rename from packages/cli/src/commands/grid-lines.ts rename to apps/cli/src/commands/grid-lines.ts diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts new file mode 100644 index 00000000..84301184 --- /dev/null +++ b/apps/cli/src/commands/logs.ts @@ -0,0 +1,10 @@ +import { Command, Option } from "commander"; +import { handle } from "../utils/command"; +import { logs } from "../api"; + +export function addLogsCommand(program: Command) { + program + .command("logs") + .addOption(new Option("-f, --follow", "Follow logs").default(false)) + .action(handle(logs)); +} diff --git a/packages/cli/src/commands/stop.ts b/apps/cli/src/commands/stop.ts similarity index 100% rename from packages/cli/src/commands/stop.ts rename to apps/cli/src/commands/stop.ts diff --git a/packages/cli/src/commands/trade.ts b/apps/cli/src/commands/trade.ts similarity index 92% rename from packages/cli/src/commands/trade.ts rename to apps/cli/src/commands/trade.ts index c4114af3..1d77e6d0 100644 --- a/packages/cli/src/commands/trade.ts +++ b/apps/cli/src/commands/trade.ts @@ -9,7 +9,7 @@ export function addTradeCommand(program: Command) { program .command("trade") .description("Live trading") - .addArgument(new Argument("", "Strategy name")) + .addArgument(new Argument("", "Strategy name").argOptional()) .addOption( new Option("-c, --config ", "Config file").default( DEFAULT_CONFIG_NAME, diff --git a/apps/cli/src/commands/up.ts b/apps/cli/src/commands/up.ts new file mode 100644 index 00000000..3c34a215 --- /dev/null +++ b/apps/cli/src/commands/up.ts @@ -0,0 +1,12 @@ +import { Command, Option } from "commander"; +import { handle } from "../utils/command"; +import { up } from "../api"; + +export function addUpCommand(program: Command) { + program + .command("up") + .addOption( + new Option("-d, --detach", "Run in detached mode").default(false), + ) + .action(handle(up)); +} diff --git a/apps/cli/src/commands/version.ts b/apps/cli/src/commands/version.ts new file mode 100644 index 00000000..7281d43f --- /dev/null +++ b/apps/cli/src/commands/version.ts @@ -0,0 +1,7 @@ +import { Command, Option } from "commander"; +import { handle } from "../utils/command"; +import { version } from "../api"; + +export function addVersionCommand(program: Command) { + program.command("version").action(handle(version)); +} diff --git a/packages/cli/src/config.ts b/apps/cli/src/config.ts similarity index 100% rename from packages/cli/src/config.ts rename to apps/cli/src/config.ts diff --git a/packages/cli/src/index.ts b/apps/cli/src/index.ts similarity index 75% rename from packages/cli/src/index.ts rename to apps/cli/src/index.ts index 6991caa7..6602f5f3 100644 --- a/packages/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -15,11 +15,19 @@ * * Repository URL: https://github.com/bludnic/opentrader */ + +import { logPath } from "./utils/app-path"; +process.env.LOG_FILE = logPath; + import { Command } from "commander"; import { addStopCommand } from "./commands/stop"; import { addBacktestCommand } from "./commands/backtest"; import { addGridLinesCommand } from "./commands/grid-lines"; import { addTradeCommand } from "./commands/trade"; +import { addUpCommand } from "./commands/up"; +import { addDownCommand } from "./commands/down"; +import { addLogsCommand } from "./commands/logs"; +import { addVersionCommand } from "./commands/version"; const program = new Command(); @@ -32,5 +40,9 @@ addBacktestCommand(program); addGridLinesCommand(program); addTradeCommand(program); addStopCommand(program); +addUpCommand(program); +addDownCommand(program); +addLogsCommand(program); +addVersionCommand(program); program.parse(); diff --git a/packages/cli/src/types.ts b/apps/cli/src/types.ts similarity index 100% rename from packages/cli/src/types.ts rename to apps/cli/src/types.ts diff --git a/apps/cli/src/utils/app-path.ts b/apps/cli/src/utils/app-path.ts new file mode 100644 index 00000000..6490ded4 --- /dev/null +++ b/apps/cli/src/utils/app-path.ts @@ -0,0 +1,7 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +const APP_DIR = ".opentrader"; + +export const appPath = join(homedir(), APP_DIR); +export const logPath = join(appPath, "log.log"); diff --git a/packages/cli/src/api/run-trading.ts b/apps/cli/src/utils/bot.ts similarity index 66% rename from packages/cli/src/api/run-trading.ts rename to apps/cli/src/utils/bot.ts index 87cdf315..0ec2587b 100644 --- a/packages/cli/src/api/run-trading.ts +++ b/apps/cli/src/utils/bot.ts @@ -1,89 +1,14 @@ -import { templates } from "@opentrader/bot-templates"; +import { ExchangeAccountWithCredentials, TBot, xprisma } from "@opentrader/db"; 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 { 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: Options, -): Promise { - const config = readBotConfig(options.config); - logger.debug(config, "Parsed bot config"); - - const exchangesConfig = readExchangesConfig(options.config); - logger.debug(exchangesConfig, "Parsed exchanges config"); - - const strategyExists = strategyName in templates; - if (!strategyExists) { - const availableStrategies = Object.keys(templates).join(", "); - logger.info( - `Strategy "${strategyName}" does not exists. Available strategies: ${availableStrategies}`, - ); - - return { - result: undefined, - }; - } - - // Saving exchange accounts to DB if not exists - const exchangeAccounts: ExchangeAccountWithCredentials[] = - await createOrUpdateExchangeAccounts(exchangesConfig); - const bot = await createOrUpdateBot( - strategyName, - options, - config, - exchangeAccounts, - ); - - const processor = new Processor(exchangeAccounts, [bot]); - await processor.onApplicationBootstrap(); - - if (bot.enabled) { - logger.info( - `Bot "${bot.label}" is already enabled. Cancelling previous orders...`, - ); - await stopBot(bot.id); - logger.info(`The bot state was cleared`); - } - - if (bot.processing) { - logger.warn( - `Bot "${bot.label}" is already processing. It could happen because previous process was interrupted.`, - ); - await resetProcessing(bot.id); - logger.warn(`The bot processing state was cleared`); - } - - await startBot(bot.id); - logger.info(`Bot "${bot.label}" started`); - - return { - result: undefined, - }; -} +import { BotConfig, ConfigName, ExchangeConfig } from "../types"; /** * Save exchange accounts to DB if not exists * @param exchangesConfig - Exchange accounts configuration */ -async function createOrUpdateExchangeAccounts( +export async function createOrUpdateExchangeAccounts( exchangesConfig: Record, ) { const exchangeAccounts: ExchangeAccountWithCredentials[] = []; @@ -142,9 +67,16 @@ async function createOrUpdateExchangeAccounts( return exchangeAccounts; } -async function createOrUpdateBot( +type CreateOrUpdateBotOptions = { + config: ConfigName; + pair?: string; + exchange?: string; + timeframe?: BarSize; +}; + +export async function createOrUpdateBot( strategyName: string, - options: Options, + options: CreateOrUpdateBotOptions, botConfig: BotConfig, exchangeAccounts: ExchangeAccountWithCredentials[], ): Promise { @@ -234,7 +166,7 @@ async function createOrUpdateBot( return bot; } -async function startBot(botId: number) { +export async function startBot(botId: number) { const botProcessor = await BotProcessing.fromId(botId); await botProcessor.processStartCommand(); @@ -243,7 +175,7 @@ async function startBot(botId: number) { await botProcessor.placePendingOrders(); } -async function enableBot(botId: number) { +export async function enableBot(botId: number) { await xprisma.bot.custom.update({ where: { id: botId, @@ -254,14 +186,14 @@ async function enableBot(botId: number) { }); } -async function stopBot(botId: number) { +export async function stopBot(botId: number) { const botProcessor = await BotProcessing.fromId(botId); await botProcessor.processStopCommand(); await disableBot(botId); } -async function disableBot(botId: number) { +export async function disableBot(botId: number) { await xprisma.bot.custom.update({ where: { id: botId, @@ -272,7 +204,7 @@ async function disableBot(botId: number) { }); } -async function resetProcessing(botId: number) { +export async function resetProcessing(botId: number) { await xprisma.bot.custom.update({ where: { id: botId, diff --git a/packages/cli/src/utils/command.ts b/apps/cli/src/utils/command.ts similarity index 100% rename from packages/cli/src/utils/command.ts rename to apps/cli/src/utils/command.ts diff --git a/apps/cli/src/utils/pid.ts b/apps/cli/src/utils/pid.ts new file mode 100644 index 00000000..fb21621e --- /dev/null +++ b/apps/cli/src/utils/pid.ts @@ -0,0 +1,28 @@ +import { join } from "node:path"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { appPath } from "./app-path"; + +/** + * Save daemon process PID to file + */ +export const savePid = (pid: number) => { + mkdirSync(appPath, { recursive: true }); + writeFileSync(join(appPath, "pid"), pid.toString()); +}; + +/** + * Return current daemon process PID + */ +export const getPid = () => { + try { + const pid = parseInt(readFileSync(join(appPath, "pid"), "utf8")); + return isNaN(pid) ? null : pid; + } catch (err) { + return null; + } +}; + +export const clearPid = () => { + mkdirSync(appPath, { recursive: true }); + writeFileSync(join(appPath, "pid"), ""); +}; diff --git a/apps/cli/src/utils/pretty-log.ts b/apps/cli/src/utils/pretty-log.ts new file mode 100644 index 00000000..9a4f9abd --- /dev/null +++ b/apps/cli/src/utils/pretty-log.ts @@ -0,0 +1,9 @@ +import { prettyFactory } from "pino-pretty"; + +const pretty = prettyFactory({}); + +export const prettyLog = (message: string) => { + const parsedMessage = JSON.parse(message); + const prettifiedMessage = pretty(parsedMessage); + console.log(prettifiedMessage.replace(/\n/g, "")); +}; diff --git a/packages/cli/src/utils/validate.ts b/apps/cli/src/utils/validate.ts similarity index 100% rename from packages/cli/src/utils/validate.ts rename to apps/cli/src/utils/validate.ts diff --git a/packages/cli/tsconfig.json b/apps/cli/tsconfig.json similarity index 100% rename from packages/cli/tsconfig.json rename to apps/cli/tsconfig.json diff --git a/apps/cli/webpack.config.js b/apps/cli/webpack.config.js new file mode 100644 index 00000000..3f5ccb87 --- /dev/null +++ b/apps/cli/webpack.config.js @@ -0,0 +1,40 @@ +const path = require("node:path"); +const nodeExternals = require("webpack-node-externals"); + +module.exports = { + target: "node", + entry: { + main: "./src/index.ts", + daemon: "./src/api/up/daemon.ts", + }, + mode: "production", + module: { + rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules/, + }, + ], + noParse: /\/dynamic-import.js$/, + }, + resolve: { + extensions: [".tsx", ".ts", ".js"], + }, + // in order to ignore all modules in node_modules folder + externals: [ + nodeExternals({ + allowlist: [ + /^@opentrader/, // bundle only `@opentrader/*` packages + ], + }), + ], + externalsPresets: { + node: true, // in order to ignore built-in modules like path, fs, etc. + }, + output: { + filename: "[name].js", + path: path.resolve(__dirname, "dist"), + clean: true, + }, +}; diff --git a/bin/dev.sh b/bin/dev.sh new file mode 100755 index 00000000..968a1f67 --- /dev/null +++ b/bin/dev.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Use "$@" to pass all additional command line arguments to your script +pnpm exec ts-node --transpile-only apps/cli/src/index.ts "$@" diff --git a/bin/opentrader.sh b/bin/opentrader.sh index 7db65707..8152d270 100755 --- a/bin/opentrader.sh +++ b/bin/opentrader.sh @@ -1,3 +1,3 @@ #!/bin/bash # Use "$@" to pass all additional command line arguments to your script -pnpm exec ts-node --transpile-only packages/cli/src/index.ts "$@" +node apps/cli/dist/main.js "$@" diff --git a/package.json b/package.json index a7910b95..95ff58a4 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "debug": "ts-node --transpile-only packages/cli/src/index.ts trade debug" }, "bin": { + "dev": "./bin/dev.sh", "opentrader": "./bin/opentrader.sh" } } diff --git a/packages/bot-templates/index.d.ts b/packages/bot-templates/index.d.ts new file mode 100644 index 00000000..3bd16e17 --- /dev/null +++ b/packages/bot-templates/index.d.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/bot-templates/jest.config.js b/packages/bot-templates/jest.config.js new file mode 100644 index 00000000..9fb637a8 --- /dev/null +++ b/packages/bot-templates/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/bot-templates/package.json b/packages/bot-templates/package.json index 13ddbb5b..79256fee 100644 --- a/packages/bot-templates/package.json +++ b/packages/bot-templates/package.json @@ -4,19 +4,29 @@ "description": "", "main": "src/index.ts", "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./dist": "./src/index.ts" + }, "scripts": { "build": "tsc", "lint": "eslint . --quiet", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "test": "jest" }, "author": "bludnic", "license": "Apache-2.0", "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.4.5" }, "dependencies": { diff --git a/packages/bot-templates/server.d.ts b/packages/bot-templates/server.d.ts new file mode 100644 index 00000000..abe846de --- /dev/null +++ b/packages/bot-templates/server.d.ts @@ -0,0 +1 @@ +export * from "./src/server"; diff --git a/packages/bot-templates/src/server/dynamic-import.d.ts b/packages/bot-templates/src/server/dynamic-import.d.ts new file mode 100644 index 00000000..f3b83c06 --- /dev/null +++ b/packages/bot-templates/src/server/dynamic-import.d.ts @@ -0,0 +1 @@ +export default function dynamicImport(path: string): any; diff --git a/packages/bot-templates/src/server/dynamic-import.js b/packages/bot-templates/src/server/dynamic-import.js new file mode 100644 index 00000000..c6ff02bb --- /dev/null +++ b/packages/bot-templates/src/server/dynamic-import.js @@ -0,0 +1,4 @@ +// Webpack replaces calls to `require()` from within a bundle. This module +// is not parsed by webpack and exports the real `require` +// NOTE: since the module is unparsed, do not use es6 exports +module.exports = require; diff --git a/packages/bot-templates/src/server/index.ts b/packages/bot-templates/src/server/index.ts new file mode 100644 index 00000000..abbc666b --- /dev/null +++ b/packages/bot-templates/src/server/index.ts @@ -0,0 +1,41 @@ +import { join } from "node:path"; +import type { BotTemplate } from "@opentrader/bot-processor"; +import * as templates from "../templates"; +import dynamicImport from "./dynamic-import"; + +type FindStrategyResult = { + strategyFn: BotTemplate; + isCustom: boolean; + strategyFilePath: string; // empty string if not a custom strategy +}; + +export async function findStrategy( + strategyNameOrFile: string, +): Promise { + let strategyFn; + + const isCustomStrategyFile = strategyNameOrFile.endsWith(".js"); + const customStrategyFilePath = strategyNameOrFile.startsWith("/") + ? strategyNameOrFile + : join(process.cwd(), strategyNameOrFile); + + const strategyExists = strategyNameOrFile in templates; + + if (isCustomStrategyFile) { + strategyFn = dynamicImport(customStrategyFilePath); + } else if (strategyExists) { + strategyFn = templates[strategyNameOrFile as keyof typeof templates]; + } else { + throw new Error( + `Strategy "${strategyNameOrFile}" does not exists. Use one of predefined strategies: ${Object.keys( + templates, + ).join(", ")}, or specify the path to custom strategy file.`, + ); + } + + return { + strategyFn, + isCustom: isCustomStrategyFile, + strategyFilePath: isCustomStrategyFile ? customStrategyFilePath : "", + }; +} diff --git a/packages/bot/src/processing/candles.processor.ts b/packages/bot/src/processing/candles.processor.ts index b3f452d5..74767da4 100644 --- a/packages/bot/src/processing/candles.processor.ts +++ b/packages/bot/src/processing/candles.processor.ts @@ -4,7 +4,7 @@ import type { TBot } from "@opentrader/db"; import { xprisma } from "@opentrader/db"; import { BotProcessing } from "@opentrader/processing"; import type { BarSize } from "@opentrader/types"; -import { findTemplate } from "@opentrader/bot-templates"; +import { findStrategy } from "@opentrader/bot-templates/server"; import type { CandleEvent } from "../channels"; import { CandlesChannel } from "../channels"; @@ -60,11 +60,11 @@ export class CandlesProcessor { return; } - const template = findTemplate(bot.template); + const { strategyFn } = await findStrategy(bot.template); await channel.add( symbol, bot.timeframe as BarSize, - template.requiredHistory, + strategyFn.requiredHistory, ); } diff --git a/packages/eslint-config/rules/rules.js b/packages/eslint-config/rules/rules.js index 27467166..2a7f1102 100644 --- a/packages/eslint-config/rules/rules.js +++ b/packages/eslint-config/rules/rules.js @@ -45,4 +45,5 @@ module.exports = { "@typescript-eslint/no-unused-vars": "warn", "no-unused-vars": "warn", "require-yield": "off", + "import/namespace": "off", }; diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index 0bebc43a..15b94e02 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -1,11 +1,33 @@ import pino from "pino"; -export const logger = pino({ - transport: { - target: "pino-pretty", - options: { - ignore: "pid,hostname", - sync: true, - }, - }, -}); +const logFile = process.env.LOG_FILE; + +export const logger = logFile + ? pino({ + transport: { + targets: [ + { + target: "pino-pretty", + options: { + ignore: "pid,hostname", + sync: true, + }, + }, + { + target: "pino/file", + options: { + destination: logFile, + }, + }, + ], + }, + }) + : pino({ + transport: { + target: "pino-pretty", + options: { + ignore: "pid,hostname", + sync: true, + }, + }, + }); diff --git a/packages/processing/src/bot/bot.processing.ts b/packages/processing/src/bot/bot.processing.ts index f9bd7f71..adc78d60 100644 --- a/packages/processing/src/bot/bot.processing.ts +++ b/packages/processing/src/bot/bot.processing.ts @@ -4,7 +4,7 @@ import type { MarketData, } from "@opentrader/bot-processor"; import { createStrategyRunner } from "@opentrader/bot-processor"; -import { findTemplate } from "@opentrader/bot-templates"; +import { findStrategy } from "@opentrader/bot-templates/server"; import { exchangeProvider } from "@opentrader/exchanges"; import type { TBot } from "@opentrader/db"; import { xprisma } from "@opentrader/db"; @@ -170,13 +170,13 @@ export class BotProcessing { }; const storeAdapter = new BotStoreAdapter(() => this.stop()); - const botTemplate = findTemplate(this.bot.template); + const { strategyFn } = await findStrategy(this.bot.template); const processor = createStrategyRunner({ store: storeAdapter, exchange, botConfig: configuration, - botTemplate, + botTemplate: strategyFn, }); return processor; diff --git a/packages/trpc/src/routers/private/bot/create-bot/handler.ts b/packages/trpc/src/routers/private/bot/create-bot/handler.ts index a21b1e27..2f74f4f9 100644 --- a/packages/trpc/src/routers/private/bot/create-bot/handler.ts +++ b/packages/trpc/src/routers/private/bot/create-bot/handler.ts @@ -1,5 +1,4 @@ -import { BotTemplate } from "@opentrader/bot-processor"; -import { findTemplate } from "@opentrader/bot-templates"; +import { findStrategy } from "@opentrader/bot-templates/server"; import { TRPCError } from "@trpc/server"; import { xprisma } from "@opentrader/db"; import { eventBus } from "../../../../event-bus"; @@ -32,9 +31,9 @@ export async function createBot({ ctx, input }: Options) { }); } - let strategy: BotTemplate; + let strategy: Awaited>; try { - strategy = findTemplate(data.template); + strategy = await findStrategy(data.template); } catch (err) { throw new TRPCError({ message: `Strategy ${data.template} not found`, @@ -42,7 +41,7 @@ export async function createBot({ ctx, input }: Options) { }); } - const parsed = strategy.schema.safeParse(data.settings); + const parsed = strategy.strategyFn.schema.safeParse(data.settings); if (!parsed.success) { throw new TRPCError({ message: `Invalid strategy params: ${parsed.error.message}`, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2f7fc52c..c85c1310 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: + - "apps/*" - "packages/*" - "pro/*"