first commit
This commit is contained in:
191
src/bot.ts
Normal file
191
src/bot.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { MeteoraWrapper } from "./meteora";
|
||||
import logger from "./utils/logger";
|
||||
import { REBALANCE_THRESHOLD_PERCENT, COMPOUND_THRESHOLD_USD, JLP_USD_ESTIMATE, SOL_USD_ESTIMATE } from "./utils/config";
|
||||
import { getSwapQuote, executeSwap } from "./swap";
|
||||
import { PublicKey } from "@solana/web3.js";
|
||||
import { BN } from "@coral-xyz/anchor";
|
||||
|
||||
export class DLMMBot {
|
||||
private meteora: MeteoraWrapper;
|
||||
|
||||
constructor() {
|
||||
this.meteora = new MeteoraWrapper();
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.meteora.init();
|
||||
}
|
||||
|
||||
async rebalance(): Promise<void> {
|
||||
const startTime = new Date().toISOString();
|
||||
try {
|
||||
logger.info(`--- Portfolio Status Report [${startTime}] ---`);
|
||||
|
||||
// Force refresh of pool data in each cycle
|
||||
await this.meteora.init();
|
||||
|
||||
const activeBin = await this.meteora.getActiveBin();
|
||||
const positions = await this.meteora.getPositions();
|
||||
|
||||
logger.info(`Active Bin ID: ${activeBin.binId}`);
|
||||
logger.info(`Number of active positions: ${positions.length}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
logger.info(
|
||||
"No active positions found. Checking for tokens to deposit..."
|
||||
);
|
||||
await this.handleInitialDeposit(activeBin.binId);
|
||||
return;
|
||||
}
|
||||
|
||||
let priceRangeRebalance = false;
|
||||
for (const position of positions) {
|
||||
const lowerBinId = position.positionData.lowerBinId;
|
||||
const upperBinId = position.positionData.upperBinId;
|
||||
const range = upperBinId - lowerBinId;
|
||||
|
||||
const threshold = Math.floor(range * REBALANCE_THRESHOLD_PERCENT);
|
||||
const lowerBoundary = lowerBinId + threshold;
|
||||
const upperBoundary = upperBinId - threshold;
|
||||
|
||||
const distanceToLower = activeBin.binId - lowerBoundary;
|
||||
const distanceToUpper = upperBoundary - activeBin.binId;
|
||||
|
||||
logger.info(`Position ${position.publicKey.toBase58()}: Range [${lowerBinId}, ${upperBinId}], Threshold Boundary [${lowerBoundary}, ${upperBoundary}]`);
|
||||
logger.info(`Distance to boundaries: Lower=+${distanceToLower}, Upper=+${distanceToUpper}`);
|
||||
|
||||
if (activeBin.binId <= lowerBoundary || activeBin.binId >= upperBoundary) {
|
||||
logger.info("Active bin reached boundary. Price range rebalance required.");
|
||||
priceRangeRebalance = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let rebalanceRequired = priceRangeRebalance;
|
||||
let totalValueUSD = 0;
|
||||
let hasSubstantialBalance = false;
|
||||
|
||||
if (!rebalanceRequired) {
|
||||
// Value-based compound check
|
||||
const { balanceX, balanceY } = await this.meteora.getBalances();
|
||||
const tokenX = this.meteora.dlmmPool?.tokenX;
|
||||
const tokenY = this.meteora.dlmmPool?.tokenY;
|
||||
|
||||
if (!tokenX || !tokenY) {
|
||||
logger.warn("Pool tokens not available for value calculation.");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalX = balanceX.toNumber() / 10 ** (tokenX.mint as any).decimals;
|
||||
const totalY = balanceY.toNumber() / 10 ** (tokenY.mint as any).decimals;
|
||||
|
||||
const isXSol = tokenX.mint.address.toBase58() === "So11111111111111111111111111111111111111112";
|
||||
const isYSol = tokenY.mint.address.toBase58() === "So11111111111111111111111111111111111111112";
|
||||
|
||||
let valueX = 0;
|
||||
let valueY = 0;
|
||||
|
||||
if (isXSol) {
|
||||
valueX = totalX * SOL_USD_ESTIMATE;
|
||||
valueY = totalY * JLP_USD_ESTIMATE;
|
||||
} else if (isYSol) {
|
||||
valueY = totalY * SOL_USD_ESTIMATE;
|
||||
valueX = totalX * JLP_USD_ESTIMATE;
|
||||
} else {
|
||||
valueX = totalX * JLP_USD_ESTIMATE;
|
||||
valueY = totalY * JLP_USD_ESTIMATE;
|
||||
}
|
||||
|
||||
totalValueUSD = valueX + valueY;
|
||||
hasSubstantialBalance = totalValueUSD >= COMPOUND_THRESHOLD_USD;
|
||||
|
||||
if (hasSubstantialBalance) {
|
||||
logger.info(`Substantial wallet balance detected ($${totalValueUSD.toFixed(2)}). Triggering value-based compounding...`);
|
||||
rebalanceRequired = true;
|
||||
} else {
|
||||
logger.info(`Portfolio healthy. Uninvested balance ($${totalValueUSD.toFixed(2)}) is below threshold ($${COMPOUND_THRESHOLD_USD}).`);
|
||||
logger.info(`--- Cycle Complete [${new Date().toISOString()}] ---`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.meteora.withdrawAll();
|
||||
// Claim fees after withdrawal to consolidate balances
|
||||
await this.meteora.claimFees();
|
||||
|
||||
const { balanceX, balanceY } = await this.meteora.getBalances();
|
||||
if (balanceX.isZero() && balanceY.isZero()) {
|
||||
logger.warn("No balances available to re-deposit after withdrawal.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Financial Safety: Only skip if NOT a price-range rebalance and value is too low
|
||||
const estimatedFeeUSD = 0.005 * SOL_USD_ESTIMATE;
|
||||
if (!priceRangeRebalance && totalValueUSD < estimatedFeeUSD) {
|
||||
logger.warn(`Skipping compounding: Total uninvested value ($${totalValueUSD.toFixed(2)}) is less than estimated transaction fees ($${estimatedFeeUSD.toFixed(2)}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.meteora.deposit(balanceX, balanceY, activeBin.binId);
|
||||
logger.info("Rebalance/Compounding completed successfully.");
|
||||
|
||||
logger.info(`--- Cycle Complete [${new Date().toISOString()}] ---`);
|
||||
} catch (error: any) {
|
||||
logger.error(`CRITICAL: Error during rebalance cycle: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInitialDeposit(activeBinId: number): Promise<void> {
|
||||
let { balanceX, balanceY } = await this.meteora.getBalances();
|
||||
|
||||
if (balanceX.isZero() && balanceY.isZero()) {
|
||||
logger.info("No token balances found for initial deposit.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Balanced investment logic: if one side is zero, swap 50%
|
||||
const SOL_MINT = "So11111111111111111111111111111111111111112";
|
||||
const pool = this.meteora.dlmmPool;
|
||||
if (!pool) {
|
||||
logger.error("Pool not initialized during handleInitialDeposit.");
|
||||
return;
|
||||
}
|
||||
const isXSol = pool.tokenX.mint.address.toBase58() === SOL_MINT;
|
||||
const isYSol = pool.tokenY.mint.address.toBase58() === SOL_MINT;
|
||||
|
||||
if (balanceX.isZero() || balanceY.isZero()) {
|
||||
logger.info("Single asset detected. Performing 50/50 swap to balance the position...");
|
||||
try {
|
||||
if (isXSol && !balanceX.isZero()) {
|
||||
const swapAmount = balanceX.div(new BN(2)).toNumber();
|
||||
logger.info(`Swapping ${swapAmount / 1e9} SOL for TokenY...`);
|
||||
const quote = await getSwapQuote(SOL_MINT, pool.tokenY.mint.address.toBase58(), swapAmount);
|
||||
await executeSwap(quote);
|
||||
} else if (isYSol && !balanceY.isZero()) {
|
||||
const swapAmount = balanceY.div(new BN(2)).toNumber();
|
||||
logger.info(`Swapping ${swapAmount / 1e9} SOL for TokenX...`);
|
||||
const quote = await getSwapQuote(SOL_MINT, pool.tokenX.mint.address.toBase58(), swapAmount);
|
||||
await executeSwap(quote);
|
||||
} else {
|
||||
logger.warn("Only non-SOL asset detected. Proceeding with single-sided deposit (no auto-swap for non-SOL yet).");
|
||||
}
|
||||
|
||||
// Refetch balances after swap
|
||||
({ balanceX, balanceY } = await this.meteora.getBalances());
|
||||
} catch (error: any) {
|
||||
logger.error(`Swap failed during initial deposit: ${error.message}. Proceeding with current balances.`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Initial deposit: X=${balanceX.toString()}, Y=${balanceY.toString()}`
|
||||
);
|
||||
await this.meteora.deposit(balanceX, balanceY, activeBinId);
|
||||
}
|
||||
|
||||
async compound(): Promise<void> {
|
||||
logger.info("Starting compounding...");
|
||||
await this.meteora.claimFees();
|
||||
logger.info("Compounding finished.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user