
DeFi Bots Series — Part 7: The Monitor Test Saga (One-Sided Bids, Skew Rebalances, and Real PnL)
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
TVLdrops below a floor orfee/TVLunderperforms, 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 +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). 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.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.19) and follow-ups.
- 17:00 — REBALANCE (skew). Claimed small amounts, widened a bit.
- 17:16 — STOP LOSS (−4.56%). Closed the lineage at 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-rangeAIGEXbot, [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 USDCAIGEXbot, [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-rangeAIGEXbot, [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.61Do 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,000raw (≈ 50 USDC),amount_a = 0. - Withdrawals after slides are mostly USELESS only: e.g.,
142,191,727raw (~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,452USELESS +94,984USDC 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 +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.