Calculating Impermanent Loss in Real-Time on Solana using the GoldRush Streaming API - Part 3

Zeeshan Jawed
Content Writer
Deploy a 24/7 Solana LP monitoring service on Railway. Add Discord alerts, cron scheduling, and automated rebalancing triggers.

Introduction: From Manual Report to Automated Guardian

In Part 1, we built the "Anchor"—a critical SQLite database of our historical LP deposits, complete with the exact price and quantity at the time of entry. In Part 2, we built the "Brain"—a powerful calculation engine that ingests live Solana data from the GoldRush Streaming API to compare our "Anchor" state against the "Current Reality," successfully calculating our precise Impermanent Loss and True PnL.

We have built a powerful analysis tool. But it has one fatal flaw: it is manual.

You still have to npx ts-node src/monitor.ts to see your PnL. If a market-moving event happens at 3 AM, your script is asleep, and so are you. This is not automation; it's a chore.

In this final part, we build the "Hands and Voice" of our system. We will elevate our monitor.ts script from a simple report into a persistent, 24/7 automated service. We will:

  1. Refactor our calculation logic into a reusable module.

  2. Build an Alerting System that sends rich Discord notifications when our positions are at risk.

  3. Implement a Scheduler using node-cron to run our monitor every 5 minutes.

  4. Discuss Deployment & Hardening using pm2 and how to handle the "Withdrawal" problem.

  5. Explore the "Final Frontier": Automated LP Rebalancing.

By the end of this guide, you will have a complete, autonomous guardian watching your DeFi assets, freeing you from the tyranny of manual-checking.

Step 1: Refactoring the Monitor into a Reusable Module

Our src/monitor.ts script from Part 2 is a great monolith, but it's not reusable. It both calculates and prints. We need to separate these concerns.

We will refactor src/monitor.ts to export a single function, runMonitor(), that returns the array of PnL reports instead of logging them.

Refactored src/monitor.ts:

// src/monitor.ts import { GoldRushClient } from "@covalenthq/client-sdk"; import * as dotenv from "dotenv"; import { initDB } from "./db"; // Import from Part 1 import { Database } from "sqlite"; dotenv.config(); const API_KEY = process.env.GOLDRUSH_API_KEY!; const WALLET = process.env.WALLET_ADDRESS!; const client = new GoldRushClient(API_KEY); // --- 1. LIVE PRICE FETCHER --- async function fetchCurrentPrices(tokenAddresses: string[]): Promise<Map<string, number>> {     const priceMap = new Map<string, number>();     const resp = await client.PricingService.getTokenPrices(         "solana-mainnet", "USD", tokenAddresses     );     if (resp.error) {         console.error("Failed to fetch prices:", resp.error_message);         return priceMap;     }     resp.data.items.forEach(item => {         priceMap.set(item.contract_address, item.price);     });     return priceMap; } // --- 2. LIVE POOL STATE FETCHER --- async function fetchCurrentPoolState(poolAddress: string): Promise<any> {     const resp = await client.DEXService.getPoolByAddress("solana-mainnet", poolAddress);     if (resp.error || resp.data.items.length === 0) {         console.error(`Failed to fetch pool state for ${poolAddress}:`, resp.error_message);         return null;     }     const pool = resp.data.items[0];         // Safety check for valid data     if (!pool.total_liquidity || !pool.lp_token_decimals || !pool.token_0.reserve || !pool.token_1.reserve) {         console.warn(`Pool ${poolAddress} returned incomplete data. Skipping.`);         return null;     }     return {         current_total_lp_supply: parseFloat(pool.total_liquidity) / Math.pow(10, pool.lp_token_decimals),         token_0_symbol: pool.token_0.contract_ticker_symbol, // Need symbol for matching         current_reserve_a: parseFloat(pool.token_0.reserve) / Math.pow(10, pool.token_0.contract_decimals),         token_1_symbol: pool.token_1.contract_ticker_symbol, // Need symbol for matching         current_reserve_b: parseFloat(pool.token_1.reserve) / Math.pow(10, pool.token_1.contract_decimals),     }; } // --- 3. "HODL" VALUE --- function calculateHodlValue(position: any, currentPrices: Map<string, number>): number {     const priceA = currentPrices.get(position.token_a_address) || 0;     const priceB = currentPrices.get(position.token_b_address) || 0;     return (position.token_a_initial_amount * priceA) + (position.token_b_initial_amount * priceB); } // --- 4. "CURRENT LP" VALUE --- function calculateCurrentLpValue(     position: any, poolState: any, currentPrices: Map<string, number> ): { value: number, currentAmountA: number, currentAmountB: number } {         const userShare = position.lp_initial_amount / poolState.current_total_lp_supply;         let currentAmountA, currentAmountB;     // Match tokens by symbol to avoid order mismatch     if (position.token_a_symbol === poolState.token_0_symbol) {         currentAmountA = poolState.current_reserve_a * userShare;         currentAmountB = poolState.current_reserve_b * userShare;     } else {         currentAmountA = poolState.current_reserve_b * userShare;         currentAmountB = poolState.current_reserve_a * userShare;     }     const priceA = currentPrices.get(position.token_a_address) || 0;     const priceB = currentPrices.get(position.token_b_address) || 0;     const value = (currentAmountA * priceA) + (currentAmountB * priceB);         return { value, currentAmountA, currentAmountB }; } // --- 5. IL CALCULATION --- function calculateImpermanentLoss(     position: any, poolState: any, currentPrices: Map<string, number> ): any {     const hodlValue = calculateHodlValue(position, currentPrices);     const { value: currentLpValue } = calculateCurrentLpValue(position, poolState, currentPrices);         if (hodlValue === 0) return { hodlValue: 0, currentLpValue: 0, impermanentLossUSD: 0, impermanentLossPct: 0 };     const impermanentLossUSD = hodlValue - currentLpValue;     const impermanentLossPct = (impermanentLossUSD / hodlValue) * 100;         return { hodlValue, currentLpValue, impermanentLossUSD, impermanentLossPct }; } // --- 6. CONCENTRATED LIQUIDITY (CLMM) --- async function fetchConcentratedPosition(walletAddress: string, lpMintAddress: string): Promise<any> {     const resp = await client.DEXService.getLpPosition("solana-mainnet", walletAddress, lpMintAddress);     if (resp.error || resp.data.items.length === 0) {         console.error(`Failed to get CLMM position: ${lpMintAddress}`); return null;     }     const pos = resp.data.items[0];     return {         currentAmountA: parseFloat(pos.token_0.amount),         currentAmountB: parseFloat(pos.token_1.amount),         unclaimedFeesA: parseFloat(pos.fee_unclaimed_0),         unclaimedFeesB: parseFloat(pos.fee_unclaimed_1)     }; } async function calculateConcentratedPnL(position: any, currentPrices: Map<string, number>): Promise<any> {     const hodlValue = calculateHodlValue(position, currentPrices);     const clmmState = await fetchConcentratedPosition(WALLET, position.lp_mint_address);     if (!clmmState) return { error: "Failed to fetch CLMM state" };     const priceA = currentPrices.get(position.token_a_address) || 0;     const priceB = currentPrices.get(position.token_b_address) || 0;     const currentAssetValue = (clmmState.currentAmountA * priceA) + (clmmState.currentAmountB * priceB);     const feesValue = (clmmState.unclaimedFeesA * priceA) + (clmmState.unclaimedFeesB * priceB);     const totalCurrentValue = currentAssetValue + feesValue;     const netPnlVsHodl = totalCurrentValue - hodlValue;     const ilAmount = hodlValue - currentAssetValue;     return { hodlValue, currentLpValue: currentAssetValue, feesValue, totalCurrentValue, netPnlVsHodl, ilAmount }; } /** * MAIN EXPORTABLE FUNCTION * This is the refactor. We now return the report array. */ export async function runMonitor(): Promise<any[]> {     const db = await initDB();     const positions = await db.all("SELECT * FROM positions WHERE is_active = 1");     if (positions.length === 0) {         console.log("No active positions found.");         return [];     }         const allTokens = new Set<string>();     positions.forEach(p => {         allTokens.add(p.token_a_address);         allTokens.add(p.token_b_address);     });         const currentPrices = await fetchCurrentPrices(Array.from(allTokens));     const reports = [];     for (const pos of positions) {         let report: any = {             Pair: `${pos.token_a_symbol}/${pos.token_b_symbol}`,             Protocol: pos.protocol,             InitialValue: (pos.token_a_initial_amount * pos.token_a_initial_price) + (pos.token_b_initial_amount * pos.token_b_initial_price),         };                 try {             if (pos.protocol === "Raydium") { // Standard AMM                 const poolState = await fetchCurrentPoolState(pos.pool_address);                 if (!poolState) continue;                                 const { hodlValue, currentLpValue, impermanentLossUSD, impermanentLossPct } =                     calculateImpermanentLoss(pos, poolState, currentPrices);                 report = {                     ...report,                     HodlValue: hodlValue,                     CurrentLpValue: currentLpValue,                     IL_USD: impermanentLossUSD,                     IL_Pct: impermanentLossPct,                     FeesUSD: currentLpValue - (hodlValue - impermanentLossUSD), // Fees are the "gain" on top of the IL-adjusted value                     TotalPnL: currentLpValue - report.InitialValue                 };                         } else if (pos.protocol === "Orca") { // Concentrated Liquidity (CLMM)                 const pnl = await calculateConcentratedPnL(pos, currentPrices);                 if (pnl.error) continue;                                 report = {                     ...report,                     HodlValue: pnl.hodlValue,                     CurrentLpValue: pnl.currentLpValue,                     FeesUSD: pnl.feesValue,                     TotalPnL: (pnl.currentLpValue + pnl.feesValue) - report.InitialValue,                     IL_USD: pnl.ilAmount                 };             }         } catch (e) {             console.error(`Error processing position ${pos.id}:`, e.message);         }                 reports.push(report);     }         return reports; }

Step 2: Implementing the Alerting Layer (Discord)

A console log is not an alert. We need our service to "scream" at us when a position is in danger. Discord webhooks are the easiest and most effective way for a developer to do this.

  1. Create a Webhook: In your Discord server, go to Server Settings -> Integrations -> Webhooks -> New Webhook. Name it and copy the Webhook URL.

  2. Add to .env:

DISCORD_WEBHOOK_URL= <Add a discord webhook url for your app>

Now, let's create a dedicated src/alerting.ts module.

// src/alerting.ts import axios from 'axios'; import * as dotenv from "dotenv"; dotenv.config(); const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL!; // Define our risk thresholds const IL_DANGER_THRESHOLD_PCT = 5.0; // 5% const PNL_DANGER_THRESHOLD_USD = -10.0; // -$10 /** * Checks a report and sends a Discord alert if it breaches a threshold. */ export async function checkAndAlert(report: any) {     let shouldAlert = false;     let color = 5814783; // Blue (Info)     let title = `[INFO] ${report.Pair} Position Update`;     let fields = [];     // --- Risk Logic ---     if (report.IL_Pct > IL_DANGER_THRESHOLD_PCT) {         shouldAlert = true;         color = 16734296; // Yellow (Warning)         title = `[⚠️ WARNING] ${report.Pair} High Impermanent Loss!`;     }     if (report.TotalPnL < PNL_DANGER_THRESHOLD_USD) {         shouldAlert = true;         color = 15548997; // Red (DANGER)         title = `[🚨 DANGER] ${report.Pair} Position is Unprofitable!`;     }         // --- (Optional) Send info-level alerts for positive positions ---     // if (!shouldAlert) {     //     title = `[✅ OK] ${report.Pair} Position Healthy`;     // }     // Only send if there's a problem     if (!shouldAlert) {         console.log(`[Monitor] Position ${report.Pair} is healthy.`);         return;     }     // --- Build Rich Embed ---     fields.push(         { name: "Total PnL", value: `$${report.TotalPnL.toFixed(2)}`, inline: true },         { name: "Impermanent Loss", value: `$${report.IL_USD.toFixed(2)} (${report.IL_Pct?.toFixed(2) || 'N/A'}%)`, inline: true },         { name: "Accrued Fees", value: `$${report.FeesUSD.toFixed(2)}`, inline: true }     );         fields.push(         { name: "Current Value (LP)", value: `$${report.CurrentLpValue.toFixed(2)}`, inline: false },         { name: "Value if HODL'd", value: `$${report.HodlValue.toFixed(2)}`, inline: false },         { name: "Initial Value", value: `$${report.InitialValue.toFixed(2)}`, inline: false }     );     const embed = {         title: title,         color: color,         fields: fields,         timestamp: new Date().toISOString()     };     try {         await axios.post(WEBHOOK_URL, {             username: "DeFi Guardian Bot",             avatar_url: "[https://i.imgur.com/g9n0d6g.png](https://i.imgur.com/g9n0d6g.png)",             embeds: [embed]         });         console.log(`[Alert] 🔔 Sent alert for ${report.Pair} to Discord.`);     } catch (e: any) {         console.error("Failed to send Discord alert:", e.message);     } }

Step 3: Creating the Master Service (index.ts)

Now we create the main entry point for our service. This script will import our monitor and alerter, then run them on a schedule using node-cron.

First, install the new dependencies:

npm install node-cron @types/node-cron axios

Now, create src/index.ts:

// src/index.ts import cron from 'node-cron'; import { runMonitor } from './monitor'; import { checkAndAlert } from './alerting'; // --- SERVICE CONFIGURATION --- const CRON_SCHEDULE = '*/5 * * * *'; // Run every 5 minutes let isJobRunning = false; // A simple lock to prevent overlap /** * The main job function that executes our PnL calculation and alerting. */ async function runJob() {     if (isJobRunning) {         console.warn("[Cron] Previous job still running. Skipping this cycle.");         return;     }         console.log(`[Cron] Starting new job at ${new Date().toISOString()}...`);     isJobRunning = true;     try {         // 1. Run the Calculation Engine         const reports = await runMonitor();         // 2. Feed reports to the Alerting Engine         if (reports.length > 0) {             console.table(reports.map(r => ({                 Pair: r.Pair,                 PnL: r.TotalPnL?.toFixed(2),                 IL_Pct: r.IL_Pct?.toFixed(2) || r.IL_USD?.toFixed(2),                 Fees: r.FeesUSD?.toFixed(2),             })));             for (const report of reports) {                 if(report.TotalPnL) { // Only alert on positions that fully calculated                     await checkAndAlert(report);                 }             }         }     } catch (e: any) {         console.error("Fatal error in job execution:", e.message);     } finally {         isJobRunning = false;         console.log(`[Cron] Job finished.`);     } } // --- SERVICE STARTUP --- console.log("✅ DeFi Monitoring Service Started."); console.log(`🕒 Scheduling job with schedule: ${CRON_SCHEDULE}`); // 1. Schedule the cron job cron.schedule(CRON_SCHEDULE, runJob); // 2. Run the job *once* immediately on startup console.log("Running initial job on startup..."); runJob();

Step 4: Operational Runbook (Deployment & Hardening) [Optional]

Your code is complete. But running it with npx ts-node src/index.ts on your laptop is not a "service." It will die when you close your laptop. We must deploy it as a persistent process on a server.

Deployment with PM2

PM2 is a process manager for Node.js. It will auto-start your script on server boot and restart it if it crashes.

  1. On your Server (e.g., an AWS EC2 t2.micro or a Raspberry Pi):

  2. Install PM2: npm install -g pm2

  3. Build your TypeScript: npx tsc (This creates a dist folder)

  4. Start the service:
    pm2 start dist/index.js --name "il-monitor"

  5. Save the process list to run on startup:

pm2 save pm2 startup

Your il-monitor service is now running 24/7. You can check its logs with pm2 logs il-monitor.

Hardening: The "Withdrawal" Problem

Our current logic has a flaw. It checks all is_active = 1 positions. But what happens when we withdraw from an LP? Our ingestor.ts from Part 1 only looks for deposits.

We need to enhance our ingestor.ts to also find withdrawals and mark them as inactive.

Quick Enhancement (Advanced ingestor.ts logic):

// Inside your findDepositTransactions loop in ingestor.ts... // --- HEURISTIC FOR WITHDRAWALS --- // 1 outgoing transfer (LP Token) // 2 incoming transfers (Token A + Token B) const outgoingLp = tx.transfers.filter((t: any) => t.from_address === WALLET && t.transfer_type === 'OUT' && t.contract_ticker_symbol.includes('LP')); const incomingAssets = tx.transfers.filter((t: any) => t.to_address === WALLET && t.transfer_type === 'IN' && !t.contract_ticker_symbol.includes('LP')); if (outgoingLp.length === 1 && incomingAssets.length === 2) {     // We found a withdrawal!     const lpMint = outgoingLp[0].contract_address;     console.log(`📉 Detected withdrawal for LP: ${lpMint}. Deactivating position.`);         // Mark this position as inactive in the DB     await db.run(         `UPDATE positions SET is_active = 0 WHERE lp_mint_address = ?`,         [lpMint]     ); }

By running this enhanced ingestor, your database becomes self-managing. The monitor will automatically stop checking positions you've already exited.

The Final Frontier: Automated LP Rebalancing

We now have a "Voice." The final step is to build "Hands."

Our checkAndAlert function can be modified to trigger an action instead of just sending a message.

This is extremely high-risk and for experts only.

The Logic:

Instead of sendAlert, you would call triggerRebalance(report).

// Conceptual code - DO NOT RUN IN PROD WITHOUT AUDITS import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js'; import { Jupiter } from '@jup-ag/core'; async function triggerRebalance(report: any) {     // If PnL is negative and IL is high...     if (report.TotalPnL < -100 && report.IL_Pct > 10) {         console.warn(`AUTOMATION: Withdrawing ${report.Pair} due to high loss!`);                 // 1. Load your bot's hot wallet         const keypair = Keypair.fromSecretKey(...);         const connection = new Connection(process.env.SOLANA_RPC_URL!);         // 2. Call the Raydium 'withdraw' instruction         // (This requires integrating the Raydium SDK)         // const withdrawTx = await raydium.withdraw(keypair, report.pool_address, ...);         // 3. To protect from MEV, send via a private RPC (e.g., Jito)         // const jitoClient = new JitoClient(process.env.JITO_RPC_URL, ...);         // await jitoClient.sendTransaction(withdrawTx);                 console.log("AUTOMATION: Withdrawal transaction sent privately.");     } }

This is the ultimate goal of an automated system: a bot that not only monitors your strategy but executes it for you, 24/7, based on pure, on-chain data.

4. Expected Operational Outputs

Since this is now an automated service, we have two places to check: the Console/Terminal (for debugging) and Discord (for user alerts).

A. Terminal Output (The "Heartbeat")

When you run npx ts-node src/index.ts (or view your Railway logs), you will see the service wake up, run its calculations, and go back to sleep.

Analysis of the Log:

  1. The Table: Shows a summary of all positions.

  2. The Logic: Notice RAY/USDC was skipped (Healthy), but SOL/USDC triggered an alert because PnL was negative (-12.50) and IL_Pct was high (5.40%).

B. Discord Output (The "Scream")

  1. Your phone buzzes. You open Discord. You see a Rich Embed notification in your private server.

C. Process Manager Output (PM2)

If you deployed using PM2, running pm2 list should show your guardian is online and using memory efficiently.

This confirms your bot has been running for 3 days (3D) without crashing.

Conclusion: Automating Your LP Strategy

This three-part journey has taken us from theory to a fully automated reality. We have built a system that is fundamentally superior to any "black box" commercial tool because we own every line of code and can audit every calculation.

  • In Part 1, we solved the "Anchor Problem" by building an ingestion engine with Covalent and SQLite to persist our historical deposit state.

  • In Part 2, we built the "Brain"—a sophisticated calculation engine that fetches live data to compute precise IL, accrued fees, and true PnL for both standard and concentrated liquidity pools.

  • In Part 3, we built the "Voice and Hands"—an automated 24/7 service that runs our calculations on a schedule, sends rich alerts to Discord, and provides the framework for programmatic rebalancing.

You are no longer a passive farmer hoping for the best. You are an Automated Strategist armed with real-time, on-chain data.

The market is a chaotic ocean, but you have just built your lighthouse.

Get Started

Get started with GoldRush API in minutes. Sign up for a free API key and start building.

Support

Explore multiple support options! From FAQs for self-help to real-time interactions on Discord.

Contact Sales

Interested in our professional or enterprise plans? Contact our sales team to learn more.