DeFi Bots Series — Part 7: The Monitor Test Saga (One-Sided Bids, Skew Rebalances, and Real PnL)

DeFi Bots Series — Part 7: The Monitor Test Saga (One-Sided Bids, Skew Rebalances, and Real PnL)

10/10/202517 min • defi
SolanaMeteoraDLMMJupiterSupabasePrivyRPCBotsTypeScriptSQL

TL;DR

  • Skew & anchors: monitor now nudges inventory back to a desired side using share anchors (e.g., keep ≥65% on USDC). If we drift, we rebalance even if in-range.
  • TVL/Fees gates: if a pool’s TVL drops below a floor or fee/TVL underperforms, we exit immediately (STOP_LOSS with a clear reason).
  • Lineage ledger: every open→rebalance→close cycle is tracked under a lineage_id, so PnL and HODL/IL math can be computed cleanly per campaign.
  • Base-funded opens + sweep: USDC funds everything (ExactIn), and we sweep leftovers back to USDC post-ops.
  • Real-world outcome: overnight run (15m ticks): roughly +3.0infees,3.0 in fees, −2.5 price PnL ⇒ net about +$0.5. Good signal the fee engine works—even in a sliding market—but we still rebalance too eagerly during downtrends.
  • Pool stats: we consolidate the retrieval of pool information using meteora's API so we can have more complex rebalance logic. We will also use this later to analyze new pools to open in automatic mode.

What changed (since Part 6)

1) Skew-aware rebalances (one-sided bid, but smarter)

I added a desired-side heuristic plus share anchors. When our inventory share on the desired side falls below anchorMinSharePct (default 65%), we suggest a REBALANCE even if the position is still in-range:

const desiredSide = this.getDesiredSideFor(p); // derived from strategy (one-sided bid → USDC)
const { usdA, usdB, tot, shareA, shareB } = usdShares(snap.invA_ui, snap.invB_ui, priceX, priceY);
 
const anchorMin = this.cfg.anchorMinSharePct ?? 0.65;
const minUsd    = this.cfg.skewCheckMinUsd ?? 25;
 
let skewSuggestsRebalance = false;
let skewReason: string | undefined;
 
if (tot >= minUsd) {
  if (desiredSide === LiquiditySide.TOKEN_B /* USDC */) {
    if (shareB < anchorMin) { skewSuggestsRebalance = true; skewReason = `inventory skew — want USDC side, have ${(shareB*100).toFixed(1)}%`; }
  } else {
    if (shareA < anchorMin) { skewSuggestsRebalance = true; skewReason = `inventory skew — want token side, have ${(shareA*100).toFixed(1)}%`; }
  }
}

This is the “feel” I was aiming for: don’t flip-flop constantly, but do correct inventory drift when fees + volatility pull us off our anchor.

2) TVL + fee/TVL gates

When liquidity thins or fees don’t justify the risk, we stop pretending:

const info = await meteora.getPoolInfo(p.pool_address);
const feeTvlPct24h = info?.feeTvlPct24h ?? null;
const tvlUsd = info?.tvlUsd ?? null;
 
const feeGate = this.cfg.minFeeTvlPct != null && feeTvlPct24h != null && feeTvlPct24h < this.cfg.minFeeTvlPct;
const tvlGate = this.cfg.minTvlUsd    != null && tvlUsd       != null && tvlUsd       < this.cfg.minTvlUsd;
 
if (tvlGate) {
  decision = "STOP_LOSS";
  reason   = `exit — TVL low ${formatUsdDynamic(tvlUsd!)} < ${formatUsdDynamic(this.cfg.minTvlUsd!)}`;
} else if (feeGate) {
  decision = "STOP_LOSS";
  reason   = `exit — fee/TVL ${feeTvlPct24h!.toFixed(2)}% < ${this.cfg.minFeeTvlPct!.toFixed(2)}%`;
} else {
  decision = this.decide(/* pnl, inRange, bins, thresholds, etc. */);
  if (decision === "HOLD" && skewSuggestsRebalance) decision = "REBALANCE";
}

UX tweak: Reasons surface directly in Telegram so I can see why we closed — not just that we closed.

3) Lineage: clean PnL, clean stories

Every flow row (DEPOSIT, WITHDRAWAL, FEE_CLAIM) now carries a lineage_id. A lineage starts on open, persists across rebalances, and ends on close. That lets us compute:

  • Cost basis per lineage: Σ(USD on deposits) − Σ(USD on withdrawals).
  • Realised fees per lineage: Σ(FEE_CLAIM USD).
  • HODL & IL baselines for the exact assets we deposited at the lineage start.

Schema below.

4) Base-funded opens and sweeps (recap)

Opens are funded from USDC using ExactIn swaps; I scale spend across sides by USD need + headroom, then verify balances. Leftovers get swept back to USDC:

await ensureFromBaseBalances({ /* ...headroomBps: 300, baseMint: USDC ... */ });
await sweepLeftoversTo({ sinkMint: USDC, mints: [tokenX, tokenY], minUsd: 2 });

RPC mode is passthrough for Jupiter txs (we respect its compute budget order); for Jito we still tip safely.

New SQL (schemas & indexes)

I removed the legacy strategy enum (and the old “one_side” enum), and replaced it with pragmatic knobs the monitor actually uses. Everything is pool-oriented (X/Y).

-- Flows (“ledger”)
create type lp_event as enum ('DEPOSIT','WITHDRAWAL','FEE_CLAIM');
 
create table if not exists lp_position_flows (
  id              uuid primary key default gen_random_uuid(),
  user_id         bigint not null,
  lineage_id      uuid   not null,      -- all ops in a campaign share this
  position_pubkey text   not null,
  pool_address    text   not null,
 
  token_a_mint    text   not null,      -- tokenX mint
  token_b_mint    text   not null,      -- tokenY mint
  amount_a        bigint not null,      -- raw units (pool decimals)
  amount_b        bigint not null,      -- raw units
  price_a         numeric not null,     -- USD @ event
  price_b         numeric not null,     -- USD @ event
 
  event_type      lp_event not null,
  timestamp       timestamptz not null default now()
);
 
create index on lp_position_flows (user_id, pool_address, timestamp desc);
create index on lp_position_flows (lineage_id, timestamp);
 
-- Positions (latest known on-chain state for a lineage)
create table if not exists lp_positions (
  position_pubkey   text primary key,
  user_id           bigint not null,
  lineage_id        uuid   not null,
  pool_address      text   not null,
 
  token_a_mint      text not null, -- tokenX
  token_b_mint      text not null, -- tokenY
  amount_a          bigint not null default 0, -- last observed on-chain (raw)
  amount_b          bigint not null default 0,
 
  min_bin_id        integer not null,
  max_bin_id        integer not null,
  interval          integer not null,
 
  -- Strategy knobs used by the monitor (replacing old enums):
  anchor_min_share_pct numeric not null default 0.65, -- desired side anchor
  follow_threshold_bins integer not null default 6,   -- min bins to chase before rebalance
  cooldown_min          integer not null default 10,
 
  base_mint          text not null default 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', -- USDC
  created_at         timestamptz not null default now(),
  updated_at         timestamptz not null default now()
);
 
-- Convenient lineage aggregates (cost basis, fees, etc.)
create materialized view if not exists lp_lineage_agg as
select
  lineage_id,
  user_id,
  pool_address,
  sum(case when event_type='DEPOSIT'   then (amount_a/1e6)*price_a + (amount_b/1e6)*price_b else 0 end) as usd_deposits,
  sum(case when event_type='WITHDRAWAL'then (amount_a/1e6)*price_a + (amount_b/1e6)*price_b else 0 end) as usd_withdrawals,
  sum(case when event_type='FEE_CLAIM' then (amount_a/1e6)*price_a + (amount_b/1e6)*price_b else 0 end) as usd_fees_realised,
  min(timestamp) as opened_at,
  max(timestamp) as last_event_at
from lp_position_flows
group by 1,2,3;
 
create index on lp_lineage_agg (user_id, pool_address, last_event_at desc);

TypeScript enums (consolidated)

export enum LiquidityStrategy {
  SYMMETRIC = 0,     // centered around active
  BID_ASK = 2,       // directional, shifting block
  FOLLOW = 4         // "walk with active" styles (if used)
}
// LiquiditySide is unchanged (TOKEN_A, TOKEN_B, BOTH);

Capturing pool stats with a new PoolInfo snapshot

We want to add more complex logic for considering rebalancing in the monitor flow. I start easy: if the pool fees/TVL drops below a threshold (as defined in the ``)

We define a new PoolInfo struct:

export interface PoolInfo {
  // canonical
  poolAddress: string;
  tokenAMint: string; // X
  tokenBMint: string; // Y
  tokenASymbol: string;
  tokenBSymbol: string;
 
  // core stats
  tvlUsd: number; // from `liquidity`
  volume24hUsd: number; // from `trade_volume_24h`
  fees24hUsd: number; // from `fees_24h`
  aprPct: number; // from `apr` (already %)
 
  // bin config
  binStepBps: number; // from `bin_step`
  activeBin?: number; // API value or on-chain backfill
 
  // per-interval series (raw API)
  fees: PoolDetail["fees"];
  feeTvlRatio: PoolDetail["fee_tvl_ratio"];
  volume: PoolDetail["volume"];
 
  // derived (normalized to percent)
  feeTvlPct30m: number | null;
  feeTvlPct1h: number | null;
  feeTvlPct24h: number | null;
 
  // raw API passthrough
  lastSyncedAt: string;
 
  // safe flags
  hide: boolean;
  isBlacklisted: boolean;
}

We still reuse the old implementation that relied on reading from meteora API:

export interface PoolDetail {
  // Basic info
  address: string;
  name: string;
  mint_x: string;
  mint_y: string;
  liquidity: string;
  bin_step: number;
 
  hide: boolean;
  is_blacklisted: boolean;
 
  // Trading volume related
  fees_24h: number;
  trade_volume_24h: number;
  current_price: number;
 
  // Yield rates
  apr: number;
 
  active_bin?: number;
  last_synced_at?: string;
 
  // Time interval statistics
  fees: {
    min_30: number;
    hour_1: number;
    hour_2: number;
    hour_4: number;
    hour_12: number;
    hour_24: number;
  };
 
  fee_tvl_ratio: {
    min_30: number;
    hour_1: number;
    hour_2: number;
    hour_4: number;
    hour_12: number;
    hour_24: number;
  };
 
  volume: {
    min_30: number;
    hour_1: number;
    hour_2: number;
    hour_4: number;
    hour_12: number;
    hour_24: number;
  };
}

But we now do not read on chain and differentiate between our old gRPC implementation. We do:

import { type PoolInfo as gRPCPoolInfo } from "@/generated/meteora"; // old legacy type from proto definitions
import type { PoolDetail, PoolInfo } from "./types"; // current usage
 
const DEFAULT_DLMM_API = "https://dlmm-api.meteora.ag";
 
export class MeteoraClient {
  constructor(
    private conn: Connection,
    private dlmmApi: string = DEFAULT_DLMM_API
  ) {}
 
  public getConn() {
    return this.conn;
  }
 
  /** fetch (or cache) a pool */
  async getPool(poolAddr: string): Promise<DLMM> {
    const now = Date.now();
    const c = this.pools.get(poolAddr);
    if (c && now - c.at < this.TTL_MS) return c.dlmm;
    const dlmm = await DLMM.create(this.conn, new PublicKey(poolAddr));
    this.pools.set(poolAddr, { dlmm, at: now });
    return dlmm;
  }
 
  /**
   * Gets detailed information about a specific pool
   * @param poolAddress The address of the pool
   * @returns Pool information
   */
  async getPoolInfo(poolAddress: string): Promise<PoolInfo | null> {
    const url = `${this.dlmmApi}/pair/${poolAddress}`;
    const res = await fetch(url);
    if (!res.ok) return null;
 
    const detail: PoolDetail = await res.json();
 
    if (!detail.active_bin) {
      const pool = await this.getPool(poolAddress);
      detail.active_bin = (await pool.getActiveBin()).binId;
    }
 
    const key = this.buildPairKey(detail.mint_x, detail.mint_y);
    const existing = MeteoraClient.pairCache.get(key)?.pools ?? new Map();
    existing.set(detail.address, detail);
    MeteoraClient.pairCache.set(key, { at: Date.now(), pools: existing });
 
    return poolDetailToInfo(detail);
  }
}

The monitor reads this once per tick and renders:

  • range vs active,
  • TVL + fee/TVL for gates,
  • bin deltas for follow rules.

Monitor exerp:

  private async monitorOnce(p: LPPosition, readConn: Connection) {
    // unchanged logic
    // ...
 
    const info = await meteora.getPoolInfo(p.pool_address);
    const feeTvlPct24h = info?.feeTvlPct24h ?? null;
    const tvlUsd = info?.tvlUsd ?? null;
    const feeGate =
      this.cfg.minFeeTvlPct != null &&
      feeTvlPct24h != null &&
      feeTvlPct24h < this.cfg.minFeeTvlPct;
    const tvlGate =
      this.cfg.minTvlUsd != null &&
      tvlUsd != null &&
      tvlUsd < this.cfg.minTvlUsd!;
 
    const takeProfit = this.cfg.takeProfitPct;
    const stopLoss = this.cfg.stopLossPct;
    const followBins = this.getFollowThresholdFor(p);
 
    let decision: Decision;
    let reason: string;
 
    if (tvlGate) {
      decision = "STOP_LOSS"; // semantic: we *close now*
      reason = `exit — TVL low ${formatUsdDynamic(
        tvlUsd!
      )} < ${formatUsdDynamic(this.cfg.minTvlUsd!)}`;
    } else if (feeGate) {
      decision = "STOP_LOSS"; // close now on poor fee/TVL
      reason = `exit — fee/TVL ${feeTvlPct24h!.toFixed(
        2
      )}% < ${this.cfg.minFeeTvlPct!.toFixed(2)}%`;
    } else {
      decision = this.decide( /* unchanged */)
    }
 
    // rest of the logic
  }

I also added a cli function to be able to analyze the current stats of a specific pool on-the-fly:

import { logger } from "@/bootstrap/logger";
import { createLocalExecutor } from "@/infra/tx-executor/executorFactory";
import { MeteoraClient } from "@/protocols/meteora/MeteoraClient";
import { defaultRpcProfile } from "@/rpc/connection";
import { safeStringify } from "@/utils/format";
 
export const executor = createLocalExecutor(defaultRpcProfile(), "rpc", {
  bundle: false,
});
export const meteora = new MeteoraClient(executor.readConnection);
 
function parseArgs() {
  const args = new Map<string, string>();
  for (let i = 2; i < process.argv.length; i += 2) {
    const k = process.argv[i]?.replace(/^--/, "");
    const v = process.argv[i + 1];
    if (k && v) args.set(k, v);
  }
  return {
    pool: args.get("pool"),
  };
}
 
async function main() {
  const { pool } = parseArgs();
 
  if (!pool) throw new Error("--pool is required");
  const poolInfo = await meteora.getPoolInfo(pool);
 
  logger.info(safeStringify(poolInfo));
}
 
main()
  .then(async () => {
    process.exit(0);
  })
  .catch(async (e) => {
    logger.error(`[closePosition] error: ${e?.message || e}`);
    process.exit(1);
  });

Let's run it, for example doing bun run scripts/meteora/poolInfo.ts --pool 9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj, we get:

{
  "poolAddress": "9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj",
  "tokenAMint": "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn",
  "tokenBMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  "tokenASymbol": "PUMP",
  "tokenBSymbol": "USDC",
  "tvlUsd": 2738276.664689444,
  "volume24hUsd": 1391460.7383979731,
  "fees24hUsd": 2677.418169193781,
  "aprPct": 0.09777748916753214,
  "binStepBps": 20,
  "activeBin": -2629,
  "fees": {
    "min_30": 9.185768413390045,
    "hour_1": 54.52230555646294,
    "hour_2": 73.38209555390985,
    "hour_4": 135.86839550760757,
    "hour_12": 1622.651075209011,
    "hour_24": 2677.418169193781
  },
  "feeTvlRatio": {
    "min_30": 0.0003354580102091996,
    "hour_1": 0.001991117488584612,
    "hour_2": 0.0026798641824686597,
    "hour_4": 0.004961821325786918,
    "hour_12": 0.05925811281721018,
    "hour_24": 0.09777748916753214
  },
  "volume": {
    "min_30": 4818.441289585227,
    "hour_1": 27924.90823387435,
    "hour_2": 37793.82579467147,
    "hour_4": 70056.56019490994,
    "hour_12": 839659.5788712362,
    "hour_24": 1391460.7383979731
  },
  "feeTvlPct30m": 0.03354580102091996,
  "feeTvlPct1h": 0.19911174885846122,
  "feeTvlPct24h": 9.777748916753213,
  "lastSyncedAt": "2025-10-10T20:22:15.420Z",
  "hide": false,
  "isBlacklisted": false
}

Overnight test (every 15 minutes)

I ran from 05:39 → 17:16 on pool 9Ux4...cKme, budget ~$50. The bot targets USDC-anchored one-sided bids: place below active, earn fees while buying dips, and periodically sell back into USDC if inventory skews too far into USELESS.

I pointed the monitor at USELESS–USDC and let it run from 05:39 → 17:16 with takeProfit=10%, stopLoss=4%, cooldownMin=5, and the skew anchor set for USDC.

Rhythm (selected checkpoints)

  • 05:39 — HOLD (in-range). 49.98value,feesunclaimed49.98 value, fees unclaimed 0.17.
  • 05:54 — REBALANCE (skew). Inventory flipped to 100% USELESS after a slide (active −503); bot claimed ~$0.26 then reset range to −504..−484.
  • 06:25 — REBALANCE (skew). Another slide to −513; claimed ~$0.49, reset to −513..−493.
  • 06:40 — REBALANCE (skew). Claimed ~$0.75, reset to −520..−500.
  • 07:11 — REBALANCE (skew). Claimed ~$0.85 (cumulative), reset to −522..−502.
  • 09:26, 09:42, 09:57 — REBALANCES (skew). Claimed bursts (0.31,0.31, 0.10, $0.00 on-chain math edge), marching the range with price.
  • 10:43 — REBALANCE (skew). Big slide to −534; claimed ~$2.10, range −535..−515.
  • 12:44 — REBALANCE (out-of-range above). Price ripped back up; claimed (~$0.24), range −508..−488.
  • 15:59 — REBALANCE (skew). Inventory ~18% USDC; claimed ~$0.21, kept width 20 bins.
  • 16:30 & 16:45 — REBALANCES (skew). More claims (0.39,0.39, 0.19) and follow-ups.
  • 17:00 — REBALANCE (skew). Claimed small amounts, widened a bit.
  • 17:16 — STOP LOSS (−4.56%). Closed the lineage at 49.23,realizedPnL49.23, realized PnL −2.51 (fees separate).

Count: ~15 rebalances across ~11.5h. That’s a lot of rotation in a gentle downtrend—great for fees, not great for churn.

A few representative Telegram prints:

AIGEXbot, [10/10/25 5:39]
📊 [USELESS-USDC](https://app.meteora.ag/dlmm/9Ux...Kme) [ET6m...ARQY](https://solscan.io/account/ET6...RQY)
    Liquidity: $49.981
     ▷ 0 USELESS
     ▷ 49.9964 USDC
    Current: $0.371833 ╏ bin -495
 
〚$0.368873 ⟺ $0.383912〛
│━━━━━━╏━━━━━━━━━━━━━━━━━━━━│
〚-499 ⟺ -479〛
 
 
Fees (unclaimed): $0.1690
 ▷ 0.224513 USELESS
 ▷ 0.0858590 USDC
Claimed fees: $0.00
PnL: $0.1658  (0.33%)
 
HOLD — in-range
AIGEXbot, [10/10/25 5:54]
📊 [USELESS-USDC](https://app.meteora.ag/dlmm/9Ux...Kme) [ET6m...ARQY](https://solscan.io/account/ET6...RQY)
      Liquidity: $49.516
        ▷ 135.498 USELESS
        ▷ 0 USDC
      Current: $0.365946 ╏ bin -503
 
〚$0.368882 ⟺ $0.383921〛
│╏━━━━━━━━━━━━━━━━━━━━━━━━━━│
〚-499 ⟺ -479〛
🔻 +4 bins (0.80%)
 
Fees (unclaimed): $0.2632
 ▷ 0.485385 USELESS
 ▷ 0.0858670 USDC
Claimed fees: $0.00
PnL: -$0.2055  (-0.41%)
 
→ REBALANCE — inventory skew — want USDC side, have 0.0%
AIGEXbot, [10/10/25 5:55]
🔄 Rebalanced [USELESS-USDC](https://app.meteora.ag/dlmm/9Ux...Kme)
 
Tx: 🔗 [view on solscan](https://solscan.io/tx/ftA...hmZ)
Range: -504 → -484 (w=20)
Placement: BID ASK
Inventory: $49.986
 ▷ 0.975801 USELESS
 ▷ 49.6428 USDC
Fees claimed: 
 ▷ 0.485385 USELESS
 ▷ 0.085867 USDC
AIGEXbot, [10/10/25 7:26]
📊 [USELESS-USDC](https://app.meteora.ag/dlmm/9Ux...Kme) [ET6m...ARQY](https://solscan.io/account/ET6...RQY)
      Liquidity: $49.963
        ▷ 0 USELESS
        ▷ 49.9768 USDC
      Current: $0.358705 ╏ bin -513
 
〚$0.352313 ⟺ $0.366676〛
│━━━━━━━━━━━━╏━━━━━━━━━━━━━━│
〚-522 ⟺ -502〛
 
Fees (unclaimed): $0.02196
 ▷ 0 USELESS
 ▷ 0.0219640 USDC
Claimed fees: $1.2336
PnL: -$0.7643  (-1.47%)
 
HOLD — in-range
AIGEXbot, [10/10/25 17:15]
📊 [USELESS-USDC](https://app.meteora.ag/dlmm/9Ux...Kme) [ET6m...ARQY](https://solscan.io/account/ET6...RQY)
      Liquidity: $49.232
        ▷ 142.192 USELESS
        ▷ 0 USDC
      Current: $0.346755 ╏ bin -530
 
〚$0.350937 ⟺ $0.365245〛
│╏━━━━━━━━━━━━━━━━━━━━━━━━━━│
〚-524 ⟺ -504〛
🔻 +6 bins (1.20%)
 
Fees (unclaimed): $0.01351
 ▷ 0.0390060 USELESS
 ▷ 0 USDC
Claimed fees: $3.1541
PnL: -$2.5056  (-4.56%)
 
→ 🚨 STOP LOSS — SL -4.56% ≤ -4.00%
AIGEXbot, [10/10/25 17:16]
❌ Closed USELESS-USDC (STOP LOSS)
tx: 🔗 [view on solscan](https://solscan.io/tx/3JD...dSZ)
pool: 9Ux4...cKme | pos: 6dph...vxtZ
Proceeds: 142.192 USELESS + 0 USDC = $49.232
Fees (claimed): 0.0390060 USELESS + 0 USDC = $0.01351
Realised PnL: -$2.5056 (-4.56%) | cost basis: $54.906
vs HODL: $799.84  | IL: -$750.61

Do logs and flows agree?

Yes. A few examples (tokenX = USELESS Dz9m...bonk, tokenY = USDC):

  • Fee claims match TG lines:
    • FEE_CLAIM: (274,452 USELESS, 94,984 USDC) at 14:45:26Z — Telegram at 16:45 reports “Fees claimed: ...” for that cycle.
    • FEE_CLAIM: (343,075 USELESS, 119,343 USDC) at 10:44:01Z — aligns with the 12:44 rebalance message claim block.
  • Closes are cleanly priced:
    • Final WITHDRAWAL at 15:16:03Z: 142.191727 USELESS @ ~$0.34624 & 0 USDC, matching the STOP LOSS summary.

Everything sits under a single lineage_id = 75e59b02-...-061f, so PnL can be reconstructed across opens/withdrawals/claims for the whole saga.

Did we actually make money?

  • Fees accrued ≈ $3.0 (from the claim logs and messages).
  • Price PnL ≈ −$2.5 (realized at close).
  • Net ≈ +$0.5. That’s the strategy thesis in miniature: if fee accrual outpaces spot drift, USDC-anchored one-sided bids can be net positive—even while the token trends down.

What still fails (and how I’m fixing it)

1) Too many skew rebalances in drifts

Symptom. On small, continuous slides, inventory flips to 100% USELESS quickly, triggering a rebalance every few bins even when in-range. That’s fee-positive but burns gas/spread and risks selling bottom-ish ticks.

Possible fixes:

  • Hysteresis (already added): only rebalance when share < anchorMin (say 65%), but don’t flip back until share > anchorExit (min(95%, anchorMin+10%)).
  • Min fee to act: require unclaimed_fees_usd >= expected_slippage + tx_fees + cushion.
  • Max cadence: maxRebalancesPerHour (e.g., 3). If reached, HOLD unless out-of-range.
  • Vol-aware width: widen bins on high drift/vol so inventory doesn’t flip instantly.

2) STOP LOSS uses price PnL only

Symptom. Our STOP LOSS at −4.56% ignored fees already earned (some realized, some unclaimed). That’s too conservative for fee-farms.

Fix. Evaluate net PnL, i.e. net_pnl_usd = price_pnl_usd + realized_fees_usd + unclaimed_fees_usd.

3) “HODL” and “IL” lines are wrong

Symptom. The TG close shows: vs HODL: $799.84 | IL: -$750.61—clearly bogus for a $50 test.

Why. The baseline assumed a weird starting portfolio and probably mixed decimals/orientation; also DLMM ≠ x*y AMM. For one-sided USDC opens, the proper “HODL” comparator is just USDC.

Fix:

  • HODL baseline: value if I had kept the budget in base mint (USDC).
  • “IL” (if you keep it at all) = LP value − HODL value. Call it “LP vs Base” to avoid Uniswap-style IL confusion.

4) “BUY 0 SOL” & “still short” edge cases (SOL pairs)

Symptom. Earlier runs showed “BUY 0 SOL” trade lines and repeated tiny quotes ending in “still short”.

Fixes to be implemented:

  • Count native lamports as supply when mint is SOL.
  • Avoid unwrap so balances remain in WSOL ATAs.
  • Tolerance (epsilon) stops micro-topping.

5) Range placement must reflect desired side

Symptom. A few early opens straddled the active bin, which forces two-sided liquidity even when I wanted one-sided.

Fix. The computeOneSidedRange guard above. Also, only set singleSidedX for true X-only.

Snippets to reuse

The monitor bot's strategy implementation looks a bit messy right now, we will abstract the foundations to implement different strategies and make it overall leaner.

We want to refractor the more complex rebalance decisions contained in a specific strategy. Right now, as we explained, we have:

const { shareA, shareB, tot } = usdShares(invA_ui, invB_ui, pxX, pxY);
const anchorMin = cfg.anchorMinSharePct ?? 0.65;
const anchorExit = Math.min(0.95, anchorMin + 0.10);
const minUsd = cfg.skewCheckMinUsd ?? 25;
 
let skewSuggests = false;
if (tot >= minUsd) {
  const wantB = desiredSide === LiquiditySide.TOKEN_B; // USDC anchor
  if (wantB && shareB < anchorMin) skewSuggests = true;
  if (!wantB && shareA < anchorMin) skewSuggests = true;
}
 
const feeGate = cfg.minFeeTvlPct != null && info.feeTvlPct24h != null
  ? info.feeTvlPct24h >= cfg.minFeeTvlPct
  : true;
 
const tvlGate = cfg.minTvlUsd != null && info.tvlUsd != null
  ? info.tvlUsd >= cfg.minTvlUsd
  : true;
 
let decision: Decision = inRange ? "HOLD" : "REBALANCE";
if (tvlGate && feeGate && skewSuggests) decision = "REBALANCE";

Did the DB prove the thesis?

A few highlights from our user_lp_position_flows for lineage 75e59b02-...-061f:

  • Opens are USDC-only deposits: amount_b = 50,000,000 raw (≈ 50 USDC), amount_a = 0.
  • Withdrawals after slides are mostly USELESS only: e.g., 142,191,727 raw (~142.19 USELESS) with amount_b = 0, which is exactly what a one-sided bid strategy expects (we bought the dip).
  • Fee claims come through frequently and priced (e.g., 274,452 USELESS + 94,984 USDC at priceA/B recorded). These match the TG “Fees claimed” lines around each rebalance.

Conclusion: data fidelity is good. The bugs are behavioral (decisioning, churn limits, baselines), not bookkeeping.

What I’m changing next

  • Fold fees (realized + current unclaimed) into STOP LOSS / TAKE PROFIT so we act on net PnL.
  • Add min-fees-to-act and max-rebalances/hour, plus vol-aware widths to slash churn.
  • Replace “IL/HODL” with LP vs Base (USDC) and make the chart honest.
  • Keep one-sided range logic and SOL-aware funding as the default templates.
  • Run another 24h “monitor saga” on 2–3 pools, side-by-side, to compare fee/TVL quality vs churn.

Closing

This was not “perfect code, ship it.” It was “monitor test saga”: iterate, poke real markets, and make the bot boring in the right places. The encouraging bit: even with a sliding token, fees nearly covered price drift—net +0.5on 0.5 on ~50 overnight while we learned a bunch. Next up: make the rebalance brain less eager and the PnL brain more honest, then scale to multiple pools.

Stay Updated

Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.

You might also like

DeFi Bots Series — Part 7: The Monitor Test Saga (Making the Monitor Debuggable)

I debugged the PnL, refactored the messy code we left implemented in our last devlog and made everything more compact and debuggable.

DeFi Bots Series — Part 6: Base-Funded Opens and Sweeps, Clean PnL, and a Quiet (Smarter) Monitor

I moved position funding and settlements to a USDC base, fixed a sneaky PnL bug (price/mint orientation), taught the monitor to chill (cooldown + “in-range = HOLD”), and battle-tested open/close scripts with ledgered flows. It’s finally… boring—in the good way.

DeFi Bots Series — Part 5: Live Rebalance on Meteora DLMM (RPC Profiles, Clean PnL & One-Sided Liquidity)

I rewired RPC handling with role-based profiles, unified LP strategy controls, fixed PnL accounting, and executed a live one-sided rebalance on a PUMP/USDC DLMM pool over gRPC—end to end with Supabase ledgering.