UNPKG

opentrader

Version:

OpenTrader is a powerful open-source crypto trading bot designated to automate your trading strategies on various cryptocurrency exchanges.

749 lines (719 loc) 25.3 kB
import { createRequire } from 'module'; import { logPath, defaultSettings, saveSettings, appPath, getSettings } from './chunk-7GYOAC3Y.mjs'; import { logger, xprisma, findStrategy, exchangeCodeMapCCXT, Backtesting, CCXTCandlesProvider, tServer } from './chunk-PNY4QK66.mjs'; import { ExchangeCode, BarSize, calcGridLines } from './chunk-SWPT4CTA.mjs'; import { Command, Argument, Option } from 'commander'; import { dirname, join } from 'node:path'; import * as fs from 'node:fs'; import { mkdirSync, writeFileSync, readFileSync as readFileSync$1 } from 'node:fs'; import superjson from 'superjson'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { fileURLToPath } from 'url'; import JSON5 from 'json5'; import { pro } from 'ccxt'; import { fileURLToPath as fileURLToPath$1 } from 'node:url'; import { spawn } from 'node:child_process'; import { execa } from 'execa'; import { existsSync, readFileSync, watchFile, createReadStream } from 'fs'; import { createInterface } from 'readline'; import { prettyFactory } from 'pino-pretty'; createRequire(import.meta.url); if (typeof globalThis.__dirname === "undefined") { globalThis.__dirname = new URL('.', import.meta.url).pathname; } if (typeof globalThis.__filename === "undefined") { globalThis.__filename = new URL(import.meta.url).pathname; } // package.json var package_default = { version: "1.0.0-beta.29"}; // src/utils/command.ts function handle(asyncFunc) { return async (...args) => { try { const { result } = await asyncFunc(...args); if (result) { logger.info(result); } } catch (error) { console.error(error); } }; } var PASS_FILE = "pass"; var savePassword = (password) => { mkdirSync(appPath, { recursive: true }); writeFileSync(join(appPath, PASS_FILE), password); }; // src/api/set-password.ts async function setPassword(newPassword) { savePassword(newPassword); return { result: `Password saved successfully. Please restart the daemon.` }; } // src/commands/set-password.ts function setPasswordCommand(program2) { program2.command("set-password").description("Set admin password").addArgument(new Argument("<password>", "New password")).action(handle(setPassword)); } // src/utils/validate.ts function validateTimeframe(timeframe) { if (!timeframe) { return null; } const validTimeframes = Object.values(BarSize); if (validTimeframes.includes(timeframe)) { return timeframe; } throw new Error( `Invalid timeframe: ${timeframe}. Valid values are: ${validTimeframes.join(", ")}` ); } function validatePair(pair) { 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`); } function validateExchange(exchangeCodeParam) { if (!exchangeCodeParam) { throw new Error("Exchange is required"); } const validExchanges = Object.values(ExchangeCode); const exchangeCode = exchangeCodeParam.toUpperCase(); if (validExchanges.includes(exchangeCode)) { return exchangeCode; } throw new Error( `Invalid exchange: ${exchangeCode}. Valid values are: ${validExchanges.join(", ")}` ); } var createDaemonRpcClient = () => { const { host, port } = getSettings(); const DAEMON_URL = `http://${host}:${port}/api/trpc`; return createTRPCProxyClient({ transformer: superjson, links: [ httpBatchLink({ url: DAEMON_URL, headers: () => ({ Authorization: process.env.ADMIN_PASSWORD }) }) ] }); }; // src/api/exchanges/add.ts async function addExchangeAccount(options) { const daemonRpc = createDaemonRpcClient(); await daemonRpc.exchangeAccount.create.mutate({ name: options.name || options.label, label: options.label, exchangeCode: options.code, apiKey: options.key, secretKey: options.secret, password: options.password, isDemoAccount: options.demo, isPaperAccount: options.paper }); return { result: "Exchange account added successfully." }; } // src/commands/exchange/add.ts function addExchangeAccountCommand(program2) { program2.command("exchange-add").description("Add an exchange account").addOption(new Option("-e, --code <code>", "Exchange code").argParser(validateExchange).makeOptionMandatory(true)).addOption(new Option("-k, --key <key>", "API Key").makeOptionMandatory(true)).addOption(new Option("-s, --secret <secret>", "Secret Key")).addOption(new Option("-p, --password <password>", "Password. Required for some exchanges").default(null)).addOption(new Option("-d, --demo", "Is demo account?").default(false)).addOption(new Option("--paper", "Is paper account?").default(false)).addOption(new Option("-l, --label <label>", "Exchange label").default("DEFAULT")).addOption(new Option("-n, --name <name>", "Exchange name").default(null)).addOption(new Option("-c, --config <config>", "Config file")).action(handle(addExchangeAccount)); } // src/api/exchanges/update.ts async function updateExchangeAccount(options) { const daemonRpc = createDaemonRpcClient(); const exchangeAccounts = await daemonRpc.exchangeAccount.list.query(); const exchangeAccount = exchangeAccounts.find((account) => account.label === options.label); if (!exchangeAccount) { logger.error(`Exchange account with label "${options.label}" not found in DB. Create it first.`); return { result: void 0 }; } await daemonRpc.exchangeAccount.update.mutate({ id: exchangeAccount.id, body: { name: options.name || exchangeAccount.name, exchangeCode: options.code, apiKey: options.key, secretKey: options.secret, password: options.password, isDemoAccount: options.demo, isPaperAccount: options.paper } }); return { result: `Exchange account with label "${exchangeAccount.label}" updated successfully.` }; } // src/commands/exchange/update.ts function updateExchangeAccountCommand(program2) { program2.command("exchange-update").description("Update an exchange account").addOption(new Option("-e, --code <code>", "Exchange code").argParser(validateExchange).makeOptionMandatory(true)).addOption(new Option("-k, --key <key>", "API Key").makeOptionMandatory(true)).addOption(new Option("-s, --secret <secret>", "Secret Key").makeOptionMandatory(true)).addOption(new Option("-p, --password <password>", "Password. Required for some exchanges").default(null)).addOption(new Option("-d, --demo", "Is demo account?").default(false)).addOption(new Option("--paper", "Is paper account?").default(false)).addOption(new Option("-l, --label <label>", "Exchange label").default("DEFAULT")).addOption(new Option("-n, --name <name>", "Exchange name").default(null)).addOption(new Option("-c, --config <config>", "Config file")).action(handle(updateExchangeAccount)); } var __filename = fileURLToPath(import.meta.url); var __dirname = dirname(__filename); var rootDir = join(__dirname, ".."); var currDir = process.cwd(); var getConfigFilePath = (path, config) => fs.existsSync(`${path}/${config}`) ? `${path}/${config}` : false; function readBotConfig(configName = "config.json5") { const currDirConfigPath = getConfigFilePath(currDir, configName); const rootDirConfigPath = getConfigFilePath(rootDir, configName); const configPath = currDirConfigPath || rootDirConfigPath; if (!configPath) { throw new Error(`Missing ${configName} file in current or root directory`); } logger.info(`Using bot config file: ${configPath}`); const config = JSON5.parse(fs.readFileSync(configPath, "utf8")); return config; } function readExchangesConfig(configName = "exchanges.json5") { const currDirConfigPath = getConfigFilePath(currDir, configName); const rootDirConfigPath = getConfigFilePath(rootDir, configName); const configPath = currDirConfigPath || rootDirConfigPath; if (!configPath) { throw new Error(`Missing ${configName} file in current or root directory`); } logger.info(`Using exchanges config file: ${configPath}`); const config = JSON5.parse( fs.readFileSync(configPath, "utf8") ); return config; } // src/api/stop-command.ts async function stopCommand(options) { const daemonRpc = createDaemonRpcClient(); const config = readBotConfig(options.config); logger.debug(config, "Parsed bot config"); 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: botLabel } }); if (!bot) { logger.info(`Bot "${botLabel}" does not exists. Nothing to stop`); return { result: void 0 }; } try { findStrategy(bot.template); } catch (err) { logger.info(err.message); return { result: void 0 }; } logger.info(`Stopping bot "${bot.label}"...`); await daemonRpc.bot.stop.mutate({ botId: bot.id }); logger.info(`Bot "${bot.label}" stopped successfully`); return { result: void 0 }; } // src/commands/stop.ts function addStopCommand(program2) { program2.command("stop").description("Process stop command").addOption(new Option("-c, --config <config>", "Config file")).action(handle(stopCommand)); } async function runBacktest(strategyName, options) { const botConfig = readBotConfig(options.config); logger.debug(botConfig, "Parsed bot config"); let strategy; try { strategy = findStrategy(strategyName); } catch (err) { logger.info(err.message); return { result: void 0 }; } const { success: isValidSchema, error } = strategy.strategyFn.schema.strict().safeParse(botConfig.settings); if (!isValidSchema) { logger.error(error.message); logger.error(`The params for "${strategyName}" strategy are invalid. Check the "config.dev.json5"`); return { result: void 0 }; } const botTimeframe = options.timeframe || botConfig.timeframe || null; const botPair = options.pair || botConfig.pair; const ccxtExchange = exchangeCodeMapCCXT[options.exchange]; const exchange = new pro[ccxtExchange](); logger.info(`Using ${botPair} on ${options.exchange} exchange with ${botTimeframe} timeframe`); const backtesting = new Backtesting({ botConfig: { id: 0, symbol: botPair, exchangeCode: options.exchange, settings: botConfig.settings, timeframe: botTimeframe }, botTemplate: strategy.strategyFn }); return new Promise((resolve) => { const candles = []; const candleProvider = new CCXTCandlesProvider({ exchange, symbol: botPair, timeframe: botTimeframe, startDate: options.from, endDate: options.to }); candleProvider.on("candle", (candle) => candles.push(candle)); candleProvider.on("done", async () => { logger.info(`Fetched ${candles.length} candlesticks`); const report = await backtesting.run(candles); resolve({ result: report }); }); candleProvider.emit("start"); }); } // src/commands/backtest.ts function addBacktestCommand(program2) { program2.command("backtest").description("Backtesting a strategy").addArgument(new Argument("<strategy>", "Strategy name")).addOption( new Option("--from <from>", "Start date").argParser((dateISO) => new Date(dateISO)).default(/* @__PURE__ */ new Date("2024-03-01")) ).addOption( new Option("--to <to>", "End date").argParser((dateISO) => new Date(dateISO)).default(/* @__PURE__ */ new Date("2024-03-31")) ).addOption(new Option("-p, --pair <pair>", "Trading pair").argParser(validatePair)).addOption(new Option("-t, --timeframe <timeframe>", "Timeframe").default("1h")).addOption(new Option("-c, --config <config>", "Config file")).addOption( new Option("-e, --exchange <exchange>", "Exchange").argParser(validateExchange).default(ExchangeCode.OKX) ).action(handle(runBacktest)); } // src/api/grid-lines.ts function buildGridLines(maxPrice, minPrice, options) { const gridLines = calcGridLines( maxPrice, minPrice, options.lines, options.quantity ); console.table(gridLines); return { result: void 0 }; } // src/commands/grid-lines.ts function addGridLinesCommand(program2) { program2.command("grid-lines").description("Build grid lines by given parameters").addArgument(new Argument("<max>", "Max price").argParser(parseFloat)).addArgument(new Argument("<min>", "Min price").argParser(parseFloat)).addOption( new Option("-l, --lines <lines>", "Number of lines").argParser(parseFloat).default(10) ).addOption( new Option("-q, --quantity <quantity>", "Quantity").argParser(parseFloat).default(1) ).action(handle(buildGridLines)); } // src/utils/bot.ts async function createOrUpdateExchangeAccounts(exchangesConfig) { const exchangeAccounts = []; for (const [exchangeLabel, exchangeData] of Object.entries(exchangesConfig)) { let exchangeAccount = await xprisma.exchangeAccount.findFirst({ where: { label: exchangeLabel } }); if (exchangeAccount) { logger.info(`Exchange account "${exchangeLabel}" found in DB. Updating credentials...`); exchangeAccount = await xprisma.exchangeAccount.update({ where: { id: exchangeAccount.id }, data: { ...exchangeData, label: exchangeLabel, owner: { connect: { id: 1 } } } }); logger.info(`Exchange account "${exchangeLabel}" updated`); } else { logger.info(`Exchange account "${exchangeLabel}" not found. Adding to DB...`); exchangeAccount = await xprisma.exchangeAccount.create({ data: { ...exchangeData, label: exchangeLabel, owner: { connect: { id: 1 } } } }); logger.info(`Exchange account "${exchangeLabel}" created`); } exchangeAccounts.push(exchangeAccount); } return exchangeAccounts; } async function createOrUpdateBot(strategyName, options, botConfig, exchangeAccounts) { 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 exchangeAccount = exchangeAccounts.find((exchangeAccount2) => exchangeAccount2.label === exchangeLabel); if (!exchangeAccount) { throw new Error(`Exchange account with label "${exchangeLabel}" not found. Check the exchanges config file.`); } let bot = await xprisma.bot.custom.findFirst({ where: { label: botLabel } }); if (bot) { logger.info(`Bot "${botLabel}" found in DB. Updating...`); bot = await xprisma.bot.custom.update({ where: { id: bot.id }, data: { type: botType, name: botName, label: botLabel, template: botTemplate, timeframe: botTimeframe, symbol: botPair, settings: JSON.stringify(botConfig.settings), state: JSON.stringify({}), // resets bot state exchangeAccount: { connect: { id: exchangeAccount.id } }, owner: { connect: { id: 1 } } } }); logger.info(`Bot "${botLabel}" updated`); } else { logger.info(`Bot "${botLabel}" not found. Adding to DB...`); bot = await xprisma.bot.custom.create({ data: { type: botType, name: botName, label: botLabel, template: strategyName, timeframe: botTimeframe, symbol: botPair, settings: JSON.stringify(botConfig.settings), exchangeAccount: { connect: { id: exchangeAccount.id } }, owner: { connect: { id: 1 } } } }); logger.info(`Bot "${botLabel}" created`); } return bot; } async function resetProcessing(botId) { await xprisma.bot.custom.update({ where: { id: botId }, data: { processing: false } }); } // src/api/run-trading.ts async function runTrading(strategyName, options) { const daemonRpc = createDaemonRpcClient(); const config = readBotConfig(options.config); logger.debug(config, "Parsed bot config"); const exchangesConfig = readExchangesConfig(options.config); logger.debug(exchangesConfig, "Parsed exchanges config"); let strategy; try { strategy = findStrategy(strategyName); } catch (err) { logger.info(err.message); return { result: void 0 }; } const { success: isValidSchema, error } = strategy.strategyFn.schema.safeParse(config.settings); if (!isValidSchema) { logger.error(error.message); logger.error(`The params for "${strategyName}" strategy are invalid. Check the "config.dev.json5"`); return { result: void 0 }; } let bot = await xprisma.bot.custom.findUnique({ where: { label: config.label || "default" } }); const isDaemonRunning = await checkDaemonHealth(daemonRpc); if (!isDaemonRunning) { logger.info("Daemon is not running. Please start it before running the bot"); return { result: void 0 }; } 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`); } if (bot?.enabled) { logger.info(`Bot "${bot.label}" is already enabled. Cancelling previous orders...`); await tServer.bot.stop({ botId: bot.id }); logger.info(`The bot was stoped`); } const exchangeAccounts = await createOrUpdateExchangeAccounts(exchangesConfig); bot = await createOrUpdateBot(strategyName, options, config, exchangeAccounts); const result = await daemonRpc.bot.start.mutate({ botId: bot.id }); if (result) { logger.info(`Bot "${bot.label}" started succesfully`); } else { logger.error(`Bot "${bot.label}" failed to start. Check the daemon logs`); } return { result: void 0 }; } async function checkDaemonHealth(daemonRpc) { try { await daemonRpc.public.healhcheck.query(); return true; } catch (err) { return false; } } // src/commands/trade.ts function addTradeCommand(program2) { program2.command("trade").description("Live trading").addArgument(new Argument("<strategy>", "Strategy name").argOptional()).addOption(new Option("-c, --config <config>", "Config file")).addOption( new Option("-p, --pair <pair>", "Trading pair").argParser(validatePair) ).addOption(new Option("-e, --exchange <exchange>", "Exchange account")).addOption( new Option("-t, --timeframe <timeframe>", "Timeframe").argParser(validateTimeframe).default(null) ).action(handle(runTrading)); } var savePid = (pid) => { mkdirSync(appPath, { recursive: true }); writeFileSync(join(appPath, "pid"), pid.toString()); }; var getPid = () => { try { const pid = parseInt(readFileSync$1(join(appPath, "pid"), "utf8")); return isNaN(pid) ? null : pid; } catch (err) { return null; } }; var clearPid = () => { mkdirSync(appPath, { recursive: true }); writeFileSync(join(appPath, "pid"), ""); }; // src/api/up/index.ts var __filename2 = fileURLToPath$1(import.meta.url); var __dirname2 = dirname(__filename2); async function up(options) { const pid = getPid(); if (pid) { logger.warn(`OpenTrader already running [PID: ${pid}]`); return { result: void 0 }; } saveSettings({ host: options.host, port: options.port }); const daemonProcess = spawn("node", [join(__dirname2, "daemon.mjs")], { detached: options.detach, stdio: options.detach ? "ignore" : void 0 }); if (daemonProcess.pid === void 0) { throw new Error("OpenTrader process not started. PID is undefined."); } logger.debug(`OpenTrader daemon started with PID: ${daemonProcess.pid}`); if (options.detach) { daemonProcess.unref(); savePid(daemonProcess.pid); logger.info(`OpenTrader started as a daemon [PID: ${daemonProcess.pid}]`); } else { daemonProcess.stdout?.pipe(process.stdout); daemonProcess.stderr?.pipe(process.stderr); } return { result: void 0 }; } // src/commands/up.ts function addUpCommand(program2) { program2.command("up").addOption(new Option("-d, --detach", "Run in detached mode").default(false)).addOption(new Option("--host <host>", "Custom daemon host. Default to `localhost`").default(defaultSettings.host)).addOption(new Option("-p, --port <port>", "Custom daemon port. Default to `8000`").default(defaultSettings.port)).action(handle(up)); } // src/api/down.ts async function down(options) { const pid = getPid(); if (!pid) { logger.warn("OpenTrader already stopped."); return { result: void 0 }; } try { if (options.force) { process.kill(pid, "SIGKILL"); logger.info(`OpenTrader has been forcefully stopped [PID: ${[pid]}]`); } else { process.kill(pid, "SIGTERM"); logger.warn(`OpenTrader has been gracefully stopped [PID: ${[pid]}]`); } } catch (err) { logger.warn(`Failed to stop OpenTrader process [PID: ${pid}]. Retry with: opentrader down --force`); logger.error(err); } clearPid(); return { result: void 0 }; } // src/commands/down.ts function addDownCommand(program2) { program2.command("down").addOption( new Option("-f, --force", "Forcefully stop the daemon process").default( false ) ).action(handle(down)); } // src/api/status.ts async function status() { const pid = getPid(); if (pid) { logger.info(`Status: \u{1F7E2} Running [PID: ${pid}]`); } else { logger.info("Status: \u{1F534} Stopped"); } return { result: void 0 }; } // src/commands/status.ts function addStatusCommand(program2) { program2.command("status").action(handle(status)); } var __filename3 = fileURLToPath$1(import.meta.url); var __dirname3 = dirname(__filename3); var ROOT_DIR = join(__dirname3, "../"); var PRISMA_BIN = join(ROOT_DIR, "node_modules/prisma/build/index.js"); var PRISMA_SCHEMA = join(ROOT_DIR, "./schema.prisma"); async function db(operation) { if (operation === "migrate") { execa( `${PRISMA_BIN} migrate dev --schema ${PRISMA_SCHEMA} --skip-generate`, { stdio: "inherit", shell: true } ); logger.info("Database migrated successfully."); } else { throw new Error(`Operation ${operation} is not supported.`); } return { result: void 0 }; } // src/commands/db.ts function dbCommands(program2) { program2.command("db").description("Database operations").addArgument(new Argument("<operation>", "Operation")).action(handle(db)); } var pretty = prettyFactory({}); var prettyLog = (message) => { const parsedMessage = JSON.parse(message); const prettifiedMessage = pretty(parsedMessage); console.log(prettifiedMessage.replace(/\n/g, "")); }; // src/api/logs.ts async function logs(options) { const logFileExists = existsSync(logPath); if (!logFileExists) { logger.info("Log file does not exist. Nothing to show logs for."); return { result: void 0 }; } if (options.follow) { const logsData = readFileSync(logPath, "utf8"); const logsLines = logsData.split("\n"); for (const line of logsLines.slice(-10)) { const isBreak = line === ""; if (!isBreak) { prettyLog(line); } } 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: void 0 }; } // src/commands/logs.ts function addLogsCommand(program2) { program2.command("logs").addOption(new Option("-f, --follow", "Follow logs").default(false)).action(handle(logs)); } // src/cli.ts process.env.LOG_FILE = logPath; var program = new Command(); program.name("@opentrader/cli").description("CLI for OpenTrader").version(package_default.version, "-v, --version", "Output the OpenTrader version"); setPasswordCommand(program); addExchangeAccountCommand(program); updateExchangeAccountCommand(program); addBacktestCommand(program); addGridLinesCommand(program); addTradeCommand(program); addStopCommand(program); addUpCommand(program); addDownCommand(program); addStatusCommand(program); dbCommands(program); addLogsCommand(program); program.parse();