192 lines
7.4 KiB
TypeScript
192 lines
7.4 KiB
TypeScript
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.");
|
|
}
|
|
}
|