diff --git a/.env.copy b/.env.copy index 07681b9..64ca203 100644 --- a/.env.copy +++ b/.env.copy @@ -8,7 +8,7 @@ COMMITMENT_LEVEL=confirmed # Bot LOG_LEVEL=trace -ONE_TOKEN_AT_A_TIME=true +MAX_TOKENS_AT_THE_TIME=1 PRE_LOAD_EXISTING_MARKETS=false CACHE_NEW_MARKETS=false # default or warp or jito @@ -34,6 +34,8 @@ PRICE_CHECK_INTERVAL=2000 PRICE_CHECK_DURATION=600000 TAKE_PROFIT=40 STOP_LOSS=20 +TRAILING_STOP_LOSS=true +SKIP_SELLING_IF_LOST_MORE_THAN=90 SELL_SLIPPAGE=20 # Filters diff --git a/README.md b/README.md index 92f00ae..4f1fd3a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ You should see the following output: #### Bot - `LOG_LEVEL` - Set logging level, e.g., `info`, `debug`, `trace`, etc. -- `ONE_TOKEN_AT_A_TIME` - Set to `true` to process buying one token at a time. +- `MAX_TOKENS_AT_A_TIME` - Set to `1` to process buying one token at a time. - `COMPUTE_UNIT_LIMIT` - Compute limit used to calculate fees. - `COMPUTE_UNIT_PRICE` - Compute price used to calculate fees. - `PRE_LOAD_EXISTING_MARKETS` - Bot will load all existing markets in memory on start. @@ -72,6 +72,9 @@ You should see the following output: - Take profit is calculated based on quote mint. - `STOP_LOSS` - Percentage loss at which to stop the loss. - Stop loss is calculated based on quote mint. +- `TRAILING_STOP_LOSS` - Set to `true` to use trailing stop loss. +- `SKIP_SELLING_IF_LOST_MORE_THAN` - If token loses more than X% of value, bot will not try to sell + - This config is useful if you find yourself in a situation when rugpull happen, and you failed to sell. In this case there is a big loss of value, and sometimes it's more beneficial to keep the token, instead of selling it for almost nothing. - `SELL_SLIPPAGE` - Slippage %. #### Snipe list diff --git a/bot.ts b/bot.ts index 835aca8..21df56f 100644 --- a/bot.ts +++ b/bot.ts @@ -19,22 +19,19 @@ import { MarketCache, PoolCache, SnipeListCache } from './cache'; import { PoolFilters } from './filters'; import { TransactionExecutor } from './transactions'; import { createPoolKeys, logger, NETWORK, sleep } from './helpers'; -import { Mutex } from 'async-mutex'; +import { Semaphore } from 'async-mutex'; import BN from 'bn.js'; import { WarpTransactionExecutor } from './transactions/warp-transaction-executor'; import { JitoTransactionExecutor } from './transactions/jito-rpc-transaction-executor'; export interface BotConfig { wallet: Keypair; - checkRenounced: boolean; - checkFreezable: boolean; - checkBurned: boolean; minPoolSize: TokenAmount; maxPoolSize: TokenAmount; quoteToken: Token; quoteAmount: TokenAmount; quoteAta: PublicKey; - oneTokenAtATime: boolean; + maxTokensAtTheTime: number; useSnipeList: boolean; autoSell: boolean; autoBuyDelay: number; @@ -45,6 +42,8 @@ export interface BotConfig { unitPrice: number; takeProfit: number; stopLoss: number; + trailingStopLoss: boolean; + skipSellingIfLostMoreThan: number; buySlippage: number; sellSlippage: number; priceCheckInterval: number; @@ -61,8 +60,9 @@ export class Bot { private readonly snipeListCache?: SnipeListCache; // one token at the time - private readonly mutex: Mutex; + private readonly semaphore: Semaphore; private sellExecutionCount = 0; + private readonly stopLoss = new Map(); public readonly isWarp: boolean = false; public readonly isJito: boolean = false; @@ -76,7 +76,7 @@ export class Bot { this.isWarp = txExecutor instanceof WarpTransactionExecutor; this.isJito = txExecutor instanceof JitoTransactionExecutor; - this.mutex = new Mutex(); + this.semaphore = new Semaphore(config.maxTokensAtTheTime); this.poolFilters = new PoolFilters(connection, { quoteToken: this.config.quoteToken, minPoolSize: this.config.minPoolSize, @@ -115,18 +115,18 @@ export class Bot { await sleep(this.config.autoBuyDelay); } - if (this.config.oneTokenAtATime) { - if (this.mutex.isLocked() || this.sellExecutionCount > 0) { - logger.debug( - { mint: poolState.baseMint.toString() }, - `Skipping buy because one token at a time is turned on and token is already being processed`, - ); - return; - } - - await this.mutex.acquire(); + const numberOfActionsBeingProcessed = + this.config.maxTokensAtTheTime - this.semaphore.getValue() + this.sellExecutionCount; + if (this.semaphore.isLocked() || numberOfActionsBeingProcessed >= this.config.maxTokensAtTheTime) { + logger.debug( + { mint: poolState.baseMint.toString() }, + `Skipping buy because max tokens to process at the same time is ${this.config.maxTokensAtTheTime} and currently ${numberOfActionsBeingProcessed} tokens is being processed`, + ); + return; } + await this.semaphore.acquire(); + try { const [market, mintAta] = await Promise.all([ this.marketStorage.get(poolState.marketId.toString()), @@ -190,16 +190,12 @@ export class Bot { } catch (error) { logger.error({ mint: poolState.baseMint.toString(), error }, `Failed to buy token`); } finally { - if (this.config.oneTokenAtATime) { - this.mutex.release(); - } + this.semaphore.release(); } } public async sell(accountId: PublicKey, rawAccount: RawAccount) { - if (this.config.oneTokenAtATime) { - this.sellExecutionCount++; - } + this.sellExecutionCount++; try { logger.trace({ mint: rawAccount.mint }, `Processing new token...`); @@ -227,10 +223,14 @@ export class Bot { const market = await this.marketStorage.get(poolData.state.marketId.toString()); const poolKeys: LiquidityPoolKeysV4 = createPoolKeys(new PublicKey(poolData.id), poolData.state, market); - await this.priceMatch(tokenAmountIn, poolKeys); - for (let i = 0; i < this.config.maxSellRetries; i++) { try { + const shouldSell = await this.waitForSellSignal(tokenAmountIn, poolKeys); + + if (!shouldSell) { + return; + } + logger.info( { mint: rawAccount.mint }, `Send sell transaction attempt: ${i + 1}/${this.config.maxSellRetries}`, @@ -276,9 +276,7 @@ export class Bot { } catch (error) { logger.error({ mint: rawAccount.mint.toString(), error }, `Failed to sell token`); } finally { - if (this.config.oneTokenAtATime) { - this.sellExecutionCount--; - } + this.sellExecutionCount--; } } @@ -390,19 +388,27 @@ export class Bot { return false; } - private async priceMatch(amountIn: TokenAmount, poolKeys: LiquidityPoolKeysV4) { + private async waitForSellSignal(amountIn: TokenAmount, poolKeys: LiquidityPoolKeysV4) { if (this.config.priceCheckDuration === 0 || this.config.priceCheckInterval === 0) { - return; + return true; } const timesToCheck = this.config.priceCheckDuration / this.config.priceCheckInterval; const profitFraction = this.config.quoteAmount.mul(this.config.takeProfit).numerator.div(new BN(100)); const profitAmount = new TokenAmount(this.config.quoteToken, profitFraction, true); const takeProfit = this.config.quoteAmount.add(profitAmount); + let stopLoss: TokenAmount; + + if (!this.stopLoss.get(poolKeys.baseMint.toString())) { + const lossFraction = this.config.quoteAmount.mul(this.config.stopLoss).numerator.div(new BN(100)); + const lossAmount = new TokenAmount(this.config.quoteToken, lossFraction, true); + stopLoss = this.config.quoteAmount.subtract(lossAmount); + + this.stopLoss.set(poolKeys.baseMint.toString(), stopLoss); + } else { + stopLoss = this.stopLoss.get(poolKeys.baseMint.toString())!; + } - const lossFraction = this.config.quoteAmount.mul(this.config.stopLoss).numerator.div(new BN(100)); - const lossAmount = new TokenAmount(this.config.quoteToken, lossFraction, true); - const stopLoss = this.config.quoteAmount.subtract(lossAmount); const slippage = new Percent(this.config.sellSlippage, 100); let timesChecked = 0; @@ -419,7 +425,39 @@ export class Bot { amountIn: amountIn, currencyOut: this.config.quoteToken, slippage, - }).amountOut; + }).amountOut as TokenAmount; + + if (this.config.trailingStopLoss) { + const trailingLossFraction = amountOut.mul(this.config.stopLoss).numerator.div(new BN(100)); + const trailingLossAmount = new TokenAmount(this.config.quoteToken, trailingLossFraction, true); + const trailingStopLoss = amountOut.subtract(trailingLossAmount); + + if (trailingStopLoss.gt(stopLoss)) { + logger.trace( + { mint: poolKeys.baseMint.toString() }, + `Updating trailing stop loss from ${stopLoss.toFixed()} to ${trailingStopLoss.toFixed()}`, + ); + this.stopLoss.set(poolKeys.baseMint.toString(), trailingStopLoss); + stopLoss = trailingStopLoss; + } + } + + if (this.config.skipSellingIfLostMoreThan > 0) { + const stopSellingFraction = this.config.quoteAmount + .mul(this.config.skipSellingIfLostMoreThan) + .numerator.div(new BN(100)); + + const stopSellingAmount = new TokenAmount(this.config.quoteToken, stopSellingFraction, true); + + if (amountOut.lt(stopSellingAmount)) { + logger.debug( + { mint: poolKeys.baseMint.toString() }, + `Token dropped more than ${this.config.skipSellingIfLostMoreThan}%, sell stopped. Initial: ${this.config.quoteAmount.toFixed()} | Current: ${amountOut.toFixed()}`, + ); + this.stopLoss.delete(poolKeys.baseMint.toString()); + return false; + } + } logger.debug( { mint: poolKeys.baseMint.toString() }, @@ -427,10 +465,12 @@ export class Bot { ); if (amountOut.lt(stopLoss)) { + this.stopLoss.delete(poolKeys.baseMint.toString()); break; } if (amountOut.gt(takeProfit)) { + this.stopLoss.delete(poolKeys.baseMint.toString()); break; } @@ -441,5 +481,7 @@ export class Bot { timesChecked++; } } while (timesChecked < timesToCheck); + + return true; } } diff --git a/helpers/constants.ts b/helpers/constants.ts index 96ca547..c1e1dfc 100644 --- a/helpers/constants.ts +++ b/helpers/constants.ts @@ -25,7 +25,7 @@ export const RPC_WEBSOCKET_ENDPOINT = retrieveEnvVariable('RPC_WEBSOCKET_ENDPOIN // Bot export const LOG_LEVEL = retrieveEnvVariable('LOG_LEVEL', logger); -export const ONE_TOKEN_AT_A_TIME = retrieveEnvVariable('ONE_TOKEN_AT_A_TIME', logger) === 'true'; +export const MAX_TOKENS_AT_THE_TIME = Number(retrieveEnvVariable('MAX_TOKENS_AT_THE_TIME', logger)); export const COMPUTE_UNIT_LIMIT = Number(retrieveEnvVariable('COMPUTE_UNIT_LIMIT', logger)); export const COMPUTE_UNIT_PRICE = Number(retrieveEnvVariable('COMPUTE_UNIT_PRICE', logger)); export const PRE_LOAD_EXISTING_MARKETS = retrieveEnvVariable('PRE_LOAD_EXISTING_MARKETS', logger) === 'true'; @@ -46,9 +46,11 @@ export const AUTO_SELL_DELAY = Number(retrieveEnvVariable('AUTO_SELL_DELAY', log export const MAX_SELL_RETRIES = Number(retrieveEnvVariable('MAX_SELL_RETRIES', logger)); export const TAKE_PROFIT = Number(retrieveEnvVariable('TAKE_PROFIT', logger)); export const STOP_LOSS = Number(retrieveEnvVariable('STOP_LOSS', logger)); +export const TRAILING_STOP_LOSS = retrieveEnvVariable('TRAILING_STOP_LOSS', logger) === 'true'; export const PRICE_CHECK_INTERVAL = Number(retrieveEnvVariable('PRICE_CHECK_INTERVAL', logger)); export const PRICE_CHECK_DURATION = Number(retrieveEnvVariable('PRICE_CHECK_DURATION', logger)); export const SELL_SLIPPAGE = Number(retrieveEnvVariable('SELL_SLIPPAGE', logger)); +export const SKIP_SELLING_IF_LOST_MORE_THAN = Number(retrieveEnvVariable('SKIP_SELLING_IF_LOST_MORE_THAN', logger)); // Filters export const FILTER_CHECK_INTERVAL = Number(retrieveEnvVariable('FILTER_CHECK_INTERVAL', logger)); diff --git a/index.ts b/index.ts index e443979..f277c0f 100644 --- a/index.ts +++ b/index.ts @@ -14,17 +14,12 @@ import { RPC_WEBSOCKET_ENDPOINT, PRE_LOAD_EXISTING_MARKETS, LOG_LEVEL, - CHECK_IF_MUTABLE, - CHECK_IF_MINT_IS_RENOUNCED, - CHECK_IF_FREEZABLE, - CHECK_IF_BURNED, QUOTE_MINT, MAX_POOL_SIZE, MIN_POOL_SIZE, QUOTE_AMOUNT, PRIVATE_KEY, USE_SNIPE_LIST, - ONE_TOKEN_AT_A_TIME, AUTO_SELL_DELAY, MAX_SELL_RETRIES, AUTO_SELL, @@ -45,6 +40,14 @@ import { FILTER_CHECK_INTERVAL, FILTER_CHECK_DURATION, CONSECUTIVE_FILTER_MATCHES, + MAX_TOKENS_AT_THE_TIME, + CHECK_IF_MINT_IS_RENOUNCED, + CHECK_IF_FREEZABLE, + CHECK_IF_BURNED, + CHECK_IF_MUTABLE, + CHECK_IF_SOCIALS, + TRAILING_STOP_LOSS, + SKIP_SELLING_IF_LOST_MORE_THAN, } from './helpers'; import { version } from './package.json'; import { WarpTransactionExecutor } from './transactions/warp-transaction-executor'; @@ -80,10 +83,8 @@ function printDetails(wallet: Keypair, quoteToken: Token, bot: Bot) { logger.info(`Wallet: ${wallet.publicKey.toString()}`); logger.info('- Bot -'); + logger.info(`Using transaction executor: ${TRANSACTION_EXECUTOR}`); - logger.info( - `Using ${TRANSACTION_EXECUTOR} executer: ${bot.isWarp || bot.isJito || (TRANSACTION_EXECUTOR === 'default' ? true : false)}`, - ); if (bot.isWarp || bot.isJito) { logger.info(`${TRANSACTION_EXECUTOR} fee: ${CUSTOM_FEE}`); } else { @@ -91,7 +92,7 @@ function printDetails(wallet: Keypair, quoteToken: Token, bot: Bot) { logger.info(`Compute Unit price (micro lamports): ${botConfig.unitPrice}`); } - logger.info(`Single token at the time: ${botConfig.oneTokenAtATime}`); + logger.info(`Max tokens at the time: ${botConfig.maxTokensAtTheTime}`); logger.info(`Pre load existing markets: ${PRE_LOAD_EXISTING_MARKETS}`); logger.info(`Cache new markets: ${CACHE_NEW_MARKETS}`); logger.info(`Log level: ${LOG_LEVEL}`); @@ -112,6 +113,8 @@ function printDetails(wallet: Keypair, quoteToken: Token, bot: Bot) { logger.info(`Price check duration: ${botConfig.priceCheckDuration} ms`); logger.info(`Take profit: ${botConfig.takeProfit}%`); logger.info(`Stop loss: ${botConfig.stopLoss}%`); + logger.info(`Trailing stop loss: ${botConfig.trailingStopLoss}`); + logger.info(`Skip selling if lost more than: ${botConfig.skipSellingIfLostMoreThan}%`); logger.info('- Snipe list -'); logger.info(`Snipe list: ${botConfig.useSnipeList}`); @@ -125,9 +128,11 @@ function printDetails(wallet: Keypair, quoteToken: Token, bot: Bot) { logger.info(`Filter check interval: ${botConfig.filterCheckInterval} ms`); logger.info(`Filter check duration: ${botConfig.filterCheckDuration} ms`); logger.info(`Consecutive filter matches: ${botConfig.consecutiveMatchCount}`); - logger.info(`Check renounced: ${botConfig.checkRenounced}`); - logger.info(`Check freezable: ${botConfig.checkFreezable}`); - logger.info(`Check burned: ${botConfig.checkBurned}`); + logger.info(`Check renounced: ${CHECK_IF_MINT_IS_RENOUNCED}`); + logger.info(`Check freezable: ${CHECK_IF_FREEZABLE}`); + logger.info(`Check burned: ${CHECK_IF_BURNED}`); + logger.info(`Check mutable: ${CHECK_IF_MUTABLE}`); + logger.info(`Check socials: ${CHECK_IF_SOCIALS}`); logger.info(`Min pool size: ${botConfig.minPoolSize.toFixed()}`); logger.info(`Max pool size: ${botConfig.maxPoolSize.toFixed()}`); } @@ -165,14 +170,11 @@ const runListener = async () => { const botConfig = { wallet, quoteAta: getAssociatedTokenAddressSync(quoteToken.mint, wallet.publicKey), - checkRenounced: CHECK_IF_MINT_IS_RENOUNCED, - checkFreezable: CHECK_IF_FREEZABLE, - checkBurned: CHECK_IF_BURNED, minPoolSize: new TokenAmount(quoteToken, MIN_POOL_SIZE, false), maxPoolSize: new TokenAmount(quoteToken, MAX_POOL_SIZE, false), quoteToken, quoteAmount: new TokenAmount(quoteToken, QUOTE_AMOUNT, false), - oneTokenAtATime: ONE_TOKEN_AT_A_TIME, + maxTokensAtTheTime: MAX_TOKENS_AT_THE_TIME, useSnipeList: USE_SNIPE_LIST, autoSell: AUTO_SELL, autoSellDelay: AUTO_SELL_DELAY, @@ -183,6 +185,8 @@ const runListener = async () => { unitPrice: COMPUTE_UNIT_PRICE, takeProfit: TAKE_PROFIT, stopLoss: STOP_LOSS, + trailingStopLoss: TRAILING_STOP_LOSS, + skipSellingIfLostMoreThan: SKIP_SELLING_IF_LOST_MORE_THAN, buySlippage: BUY_SLIPPAGE, sellSlippage: SELL_SLIPPAGE, priceCheckInterval: PRICE_CHECK_INTERVAL,