This is the “fills” chapter. You already have a live signal that’s grounded in real-time on-chain data (Part 1) and a backtest loop you can defend (Part 2). Now we wire live execution on Sonic so the bot can actually buy on one venue and sell on another—safely.
Why this matters: execution is where edge leaks. Public mempools, fast competition, fee math, and shallow liquidity can turn a paper win into a live loss. The fix isn’t magic; it’s discipline: quote before you fire, set hard slippage bounds, use a cooldown, and add circuit breakers for latency and price drift. We’ll keep things explainable and EVM-plain so you can run it anywhere Sonic exposes a Uniswap-v2–style router.
What we’ll build
A minimal two-leg arbitrage executor (Router A → Router B) with approvals, quotes, and slippage protection.
A trade planner that turns your post-fee signal into concrete sizes and minOut values.
Guardrails: dry-run toggle, cooldown, latency and drift checks, revert handling, and basic telemetry.
A simple “live” runner that listens to your Part-1 signal and fires only when quotes confirm the edge.
Assumptions
You’re arbitraging the same token pair across two v2-style routers on Sonic (A then B, or B then A).
Tokens are ERC-20; you have addresses/decimals for tokenIn (quote) and tokenOut (base).
You have an RPC endpoint and a funded private key for Sonic.
You accept that two sequential swaps aren’t atomic. (For full atomicity, you’d use a flash-swap contract—out of scope here. Guardrails mitigate most day-one risks.)
We’re moving from signals to real swaps on Sonic DEXs. In practice, that’s two legs of a simple cross-venue arbitrage: buy on venue A, sell on venue B, same token pair, tight time window. Your bot must (1) quote both legs in real time, (2) cap slippage, (3) submit transactions with sensible gas/timeout settings, and (4) only fire when quotes still show an edge after fees.
The Sonic chain is active; there’s a handful of turnover and a mix of router types to target. Based on 24-hour trading volume, you will frequently see platforms like Shadow Exchange, SwapX (Algebra), Beets V3 (Beethoven X), Metropolis DLMM, Equalizer, and SpookySwap appear often. These platforms collectively cover various pool types:
v2-style pools with simpler paths and fast quotes
CLAMMs like SwapX and Uniswap v3, which include fee tiers and price ranges
Balancer-style weighted pools, utilizing basket math and multi-asset paths
DLMM/Liquidity Book designs with bin steps that can create sharp micro-moves

The mix matters because it changes how you quote, how you set minOut, and where tiny edges are most likely to appear. Knowing the router type matters for quoting and slippage math (our tutorial uses v2-style for clarity; CLAMM/DLMM can be added later with their quoter functions).
Sonic’s speed and fee structure make it a suitable venue for learning with small notional sizes. The DEX mix gives you both familiar v2 routers and modern CL/DLMM designs so that you can start with v2 today and layer in more sophisticated quoting later.
Public metrics show the chain has steady DEX activity, enough to make data-driven arb worth attempting, but still competitive enough that process beats hero trades. The goal isn’t to catch everything; it’s only to fire when numbers and quotes line up.

How to aim your bot: Picking venues and pairs
Start with high-touch pairs (e.g., USDC-major, wrapped majors) on two venues that consistently post depth. Use your own capture to pick spots where small gaps actually turn into fills.
Cross-check which routers dominate Sonic in your timezone; Shadow Exchange and SwapX (Algebra) often carry CL-style pools, Beets V3 handles weighted baskets and core pairs, while Metropolis DLMM brings Liquidity Book mechanics that can produce sharp micro-moves between bins.
This mix creates short-lived mismatches your signal can catch, provided your quotes and fees are accurate. The specific steps for aiming your bot are outlined below.
Pick two v2 routers that list the same pair (e.g., USDC ↔ major). Skip CL/DLMM/weighted pools for now.
Capture 3–7 days with Goldrush. Keep pools with a steady 1% depth and predictable slippage.
Measure post-fee spreads between the two venues—trade in the hours where your threshold is crossed most often.
Budget every trade: poolA fee + poolB fee + slippage guard + gas. If round-trip costs ≤ 0, don’t fire.
Start narrow: one pair × two venues. Size up only after a few days of hit rate and PnL

Guardrails are simple, explicit rules that prevent you from turning a statistical edge into a live loss. Your build should perform these checks before implementing a trade. Some of them include:
Pre-trade quotes (both legs): Always call the router’s quote (getAmountsOut for v2; quoter for CLAMM/DLMM) on current size. If the after-cost round-trip is below your minimum profit bps, skip.
Hard slippage caps: Set minOut from quotes with a bps haircut (per leg). No fill > no loss.
Latency + drift checks: Skip if your input latency spikes or if the computed spread just collapsed.
Cooldown: A short, enforced pause between attempts to avoid thrash when ranges are moving.
Gas/timeout policy: Cap gas price and set a short deadline; stale intent = no transaction.
Dry-run first: Log everything and only flip to live when replay/backtest parity looks clean.
Non-atomic risk awareness: Two swaps can diverge. If you need atomicity later, graduate to a flash-swap contract; for Part 3, we keep it simple and defensive.
We implement these as plain, readable checks in the runner before any transaction is sent (you’ll see them in code). If Sonic or your provider offers private rails later, you can plug that in for less leakage; if not, these guardrails still carry you a long way.
Goldrush Streams stays in the loop during execution: it feeds live tick data to your runner, which helps it decide whether a trade still makes sense (current reserves, inferred prices, latency). It also powers ongoing capture, so every live attempt has a matching record for replay and post-trade review.
What it doesn’t do
Goldrush doesn’t submit transactions, route orders, or aggregate DEX quotes. Fills happen via your Sonic RPC, wallet, and the DEX routers you configure.
Why it still matters for fills
Execution discipline depends on the fresh state: you quote both legs at the size you’ll trade, enforce minOut, and skip if latency or drift fail your guardrails. Goldrush keeps that state hot and records exactly what you saw when you pressed “go.”
Quick checklist
Keep your Goldrush filters narrow (only the two pools you trade).
Leave capture on during live runs for replay and audits.
Alert on stream stalls or latency spikes before sending any swap.
Two Sonic DEX routers you can name and reach (v2 to start).
A single pair to focus on (stablecoin ↔ primary).
Your Goldrush stream filters narrowed to those pools.
A funded Sonic wallet and RPC with stable latency.
A conservative policy file: slippage caps, min profit bps, cooldown, and dry-run = true.
Add dependencies:
npm install ethers dotenvExtend scripts (merge/append into package.json):
Please include the runnable scripts that we will create in this section, specifically the exec and dryrun scripts, in the packet.json file we made in the previous section.
{
"scripts": {
"dev": "ts-node src/index.ts",
"replay": "ts-node src/replay.ts",
"backtest": "ts-node src/backtest.ts",
"approve": "ts-node src/approve.ts",
"exec": "ts-node src/execute.ts",
"dryrun": "DRY_RUN=1 ts-node src/execute.ts"
}
}
ethers: lets us talk to Sonic—read balances/allowances, call router getAmountsOut, and send swaps.
dotenv: loads secrets from .env so RPC URLs and keys aren’t hard-coded.
.env (fill with your values):
SONIC_RPC_URL=https://<your-sonic-rpc>
PRIVATE_KEY=0x<your_private_key_for_small_hot_wallet>sonic_rpc_url: an HTTPS RPC endpoint for Sonic (from your node provider). Faster endpoints reduce quote→submit delay.
private_key: a small, dedicated wallet for testing/execution—never your primary key. Fund sparingly.
Tip: Add .env to .gitignore so you don’t commit secrets.
Note: Use a small, dedicated key; fund it only with what you can lose while testing.
This diagram shows the Part 3 runtime flow: it includes Goldrush feed prices in the signal loop; we fetch a fresh quote, apply guardrails, and execute a two-leg swap on Sonic: approvals, wallet wiring, and a tiny PnL print round out a production-ready path.

1aexecution block to centralize addresses and sizing (routers, tokens, slippage caps, cooldown, gas guard). Keeping these in config means you can tune without touching code.config.yaml (update)src/configFile.ts (minor additions)config.yaml — add execution:execution block that holds everything the live executor needs: two v2 router addresses (A/B), tokenIn/tokenOut addresses + decimals, trade size, and guardrails (gas cap, staleness cap, cooldown, default dry-run).config.yaml, keep existing keys, fill addresses from Sonic’s DEX UI/explorer, and start with a tiny trade_notional_quote.execution:
routerA: "0xRouterAddressA" # Sonic v2-style router
routerB: "0xRouterAddressB" # Sonic v2-style router
tokenIn: "0xTokenIn" # e.g., USDC
tokenOut: "0xTokenOut" # e.g., WETH
decimalsIn: 6
decimalsOut: 18
trade_notional_quote: 100 # quote units (e.g., 100 USDC)
max_gas_gwei: 20 # cap gas price
max_staleness_ms: 800 # quote→submit window
cooldown_ms: 2000 # after any attempt
dry_run_default: true # overridden by DRY_RUN env1bAppConfig with an execution object matching the YAML fields, so the rest of the code can use cfg.execution.* with type safety.execution type to AppConfig and recompile/run; if TypeScript complains, your YAML keys/indentation don’t match.// src/configFile.ts
import fs from 'fs';
import path from 'path';
import YAML from 'yaml';
export type AppConfig = {
data: { out_dir: string; tick_file_prefix: string; replay_glob: string };
fees: { poolA_bps: number; poolB_bps: number; gas_bps?: number };
features: { ema_window: number; rv_window: number; depth_floor: number; penalty_bps: number };
policy: { spread_threshold_bps: number; slippage_bps: number; min_notional_quote: number; cooldown_ms: number };
backtest: { train_days: number; test_days: number };
execution: {
routerA: string; routerB: string;
tokenIn: string; tokenOut: string;
decimalsIn: number; decimalsOut: number;
trade_notional_quote: number;
max_gas_gwei: number; max_staleness_ms: number; cooldown_ms: number;
dry_run_default: boolean;
};
};
export function loadConfig(): AppConfig {
const p = path.resolve(process.cwd(), 'config.yaml');
const raw = fs.readFileSync(p, 'utf8');
const cfg = YAML.parse(raw) as AppConfig;
return cfg;
}
execution from loadConfig(); you don’t run this directly.2decimals, symbol, balanceOf, allowance, approve to check wallet balances/allowances and grant the router permission to spend tokens.getAmountsOut, swapExactTokensForTokens used to quote the actual trade size you’ll send, then execute with a hard minOut.execution.routerA/routerB, execution.tokenIn/tokenOut, and their decimals are set correctly in config.yaml. The ABI is static; the addresses and sizes are supplied at runtime.config.yaml’s execution block, not from this file.// src/abis.ts
export const ERC20_ABI = [
"function decimals() view returns (uint8)",
"function symbol() view returns (string)",
"function balanceOf(address) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
"function approve(address spender, uint256 value) returns (bool)"
];
export const V2_ROUTER_ABI = [
"function getAmountsOut(uint amountIn, address[] calldata path) view returns (uint[] memory amounts)",
"function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)"
];3env, plus small utilities (chainId(), deadlineSeconds(), gwei()). This keeps network access and tx signing in one place and makes gas/staleness guards easy to enforce.env (already added in Step 0):SONIC_RPC_URL=…PRIVATE_KEY=0x…src/wallet.ts// src/wallet.ts
import 'dotenv/config';
import { ethers } from 'ethers';
export function getProvider() {
const url = process.env.SONIC_RPC_URL!;
if (!url) throw new Error("SONIC_RPC_URL missing");
return new ethers.JsonRpcProvider(url);
}
export function getSigner() {
const pk = process.env.PRIVATE_KEY!;
if (!pk) throw new Error("PRIVATE_KEY missing");
return new ethers.Wallet(pk, getProvider());
}
export async function chainId(): Promise<number> {
const net = await getProvider().getNetwork();
return Number(net.chainId);
}
export function deadlineSeconds(secs: number = 60): number {
return Math.floor(Date.now() / 1000) + secs;
}
export function gwei(n: number) {
return ethers.parseUnits(n.toString(), "gwei");
}
4tokenOut. It’s a one-time setup per wallet/token/router combo; needed before any swap.config.yaml › execution: routerA, routerB, tokenIn, tokenOut.env: SONIC_RPC_URL, PRIVATE_KEY.src/approve.ts.// src/approve.ts
import { ethers } from 'ethers';
import { loadConfig } from './configFile';
import { getSigner } from './wallet';
import { ERC20_ABI } from './abis';
async function approve(token: string, spender: string, amount: bigint) {
const signer = getSigner();
const erc = new ethers.Contract(token, ERC20_ABI, signer);
const sym = await erc.symbol().catch(() => "TOKEN");
const tx = await erc.approve(spender, amount);
console.log(`Approve ${sym} for ${spender}… tx=${tx.hash}`);
await tx.wait();
console.log(`Approve ${sym}: confirmed.`);
}
async function main() {
const cfg = loadConfig();
const max = ethers.MaxUint256;
await approve(cfg.execution.tokenIn, cfg.execution.routerA, max);
await approve(cfg.execution.tokenOut, cfg.execution.routerA, max);
await approve(cfg.execution.tokenIn, cfg.execution.routerB, max);
await approve(cfg.execution.tokenOut, cfg.execution.routerB, max);
}
main().catch(e => { console.error(e); process.exit(1); });npm run approve four approval tx hashes, then confirmations.
5getAmountsOut for the exact size you plan to trade. It returns minOut values with your fee and slippage guard baked in, plus a timestamp so the executor can reject stale quotes.execution.routerA, execution.routerBexecution.tokenIn, execution.tokenOutexecution.decimalsIn, execution.decimalsOutexecution.trade_notional_quote (size used for quoting)policy.slippage_bps, fees.poolA_bps, fees.poolB_bps, fees.gas_bps (for minOut)src/quoter.ts.// src/quoter.ts
import { ethers } from 'ethers';
import { loadConfig } from './configFile';
import { getSigner } from './wallet';
import { V2_ROUTER_ABI } from './abis';
export type TwoLegQuote = {
ts: number;
amountInA: bigint; outA: bigint;
amountInB: bigint; outB: bigint;
minOutA: bigint; minOutB: bigint;
};
export async function quoteTwoLeg(): Promise<TwoLegQuote> {
const cfg = loadConfig();
const signer = getSigner();
const routerA = new ethers.Contract(cfg.execution.routerA, V2_ROUTER_ABI, signer);
const routerB = new ethers.Contract(cfg.execution.routerB, V2_ROUTER_ABI, signer);
const decIn = BigInt(10 ** cfg.execution.decimalsIn);
const decOut = BigInt(10 ** cfg.execution.decimalsOut);
// size in base units (trade_notional_quote assumed == tokenIn quote units)
const amountIn = BigInt(Math.floor(cfg.execution.trade_notional_quote)) * decIn;
// Path: tokenIn -> tokenOut, tokenOut -> tokenIn
const pathAB = [cfg.execution.tokenIn, cfg.execution.tokenOut];
const pathBA = [cfg.execution.tokenOut, cfg.execution.tokenIn];
const amountsA = await routerA.getAmountsOut(amountIn, pathAB);
const outA = BigInt(amountsA[1].toString());
const amountsB = await routerB.getAmountsOut(outA, pathBA);
const outB = BigInt(amountsB[1].toString());
// Compute minOuts with policy slippage + fees (bps)
const feesBps = cfg.fees.poolA_bps + cfg.fees.poolB_bps + (cfg.fees.gas_bps ?? 0);
const slipBps = cfg.policy.slippage_bps;
const guardBps = BigInt(feesBps + slipBps);
const minOutA = outA - (outA * guardBps) / 10_000n;
const minOutB = outB - (outB * guardBps) / 10_000n;
return {
ts: Date.now(),
amountInA: amountIn, outA,
amountInB: outA, outB,
minOutA, minOutB
};
}
quoteTwoLeg() returns { ts, amountInA, outA, amountInB, outB, minOutA, minOutB }. You don’t run this file directly.6DRY_RUN (env) — set to 1/true to force dry-run at the executor level.src/executor.ts. It exports tryExecute(); your runner (src/execute.ts) calls it on signal.// src/executor.ts
import { ethers } from 'ethers';
import { loadConfig } from './configFile';
import { getSigner, deadlineSeconds, gwei } from './wallet';
import { ERC20_ABI, V2_ROUTER_ABI } from './abis';
import { quoteTwoLeg } from './quoter';
export type ExecResult = { sent: boolean; reason?: string; tx1?: string; tx2?: string };
function envDryRun(defaultOn: boolean) {
const v = process.env.DRY_RUN;
if (v === '0' || v === 'false') return false;
if (v === '1' || v === 'true') return true;
return defaultOn;
}
export async function tryExecute(): Promise<ExecResult> {
const cfg = loadConfig();
const signer = getSigner();
const dryRun = envDryRun(cfg.execution.dry_run_default);
// 1) Fresh quote
const q = await quoteTwoLeg();
const age = Date.now() - q.ts;
if (age > cfg.execution.max_staleness_ms) {
return { sent: false, reason: `stale quote (${age}ms)` };
}
// 2) Check balances and allowances
const ercIn = new ethers.Contract(cfg.execution.tokenIn, ERC20_ABI, signer);
const ercOut = new ethers.Contract(cfg.execution.tokenOut, ERC20_ABI, signer);
const balIn = await ercIn.balanceOf(await signer.getAddress());
if (balIn < q.amountInA) return { sent: false, reason: "insufficient tokenIn balance" };
// 3) Gas guard
const gp = await signer.provider!.getGasPrice();
const cap = gwei(cfg.execution.max_gas_gwei);
if (gp > cap) return { sent: false, reason: `gas ${gp} > cap ${cap}` };
// 4) Prepare routers
const routerA = new ethers.Contract(cfg.execution.routerA, V2_ROUTER_ABI, signer);
const routerB = new ethers.Contract(cfg.execution.routerB, V2_ROUTER_ABI, signer);
const to = await signer.getAddress();
const dl = deadlineSeconds(60);
// Dry-run?
if (dryRun) {
console.log(`[DRY_RUN] LegA tokenIn->tokenOut amountIn=${q.amountInA.toString()} minOut=${q.minOutA.toString()}`);
console.log(`[DRY_RUN] LegB tokenOut->tokenIn amountIn=${q.outA.toString()} minOut=${q.minOutB.toString()}`);
return { sent: false, reason: "dry-run" };
}
// 5) Execute leg A (buy tokenOut on routerA)
const tx1 = await routerA.swapExactTokensForTokens(
q.amountInA,
q.minOutA,
[cfg.execution.tokenIn, cfg.execution.tokenOut],
to,
dl,
{ gasPrice: gp }
);
console.log(`Leg A sent: ${tx1.hash}`);
const rc1 = await tx1.wait();
// 6) Check received amountOut (simple sanity via balance delta)
const balOutAfterA = await ercOut.balanceOf(to);
// 7) Execute leg B (sell tokenOut on routerB) using amount from quote (conservative)
const amountForB = q.outA; // conservative; could use actual delta
const tx2 = await routerB.swapExactTokensForTokens(
amountForB,
q.minOutB,
[cfg.execution.tokenOut, cfg.execution.tokenIn],
to,
dl,
{ gasPrice: gp }
);
console.log(`Leg B sent: ${tx2.hash}`);
const rc2 = await tx2.wait();
// 8) Cooldown
await new Promise(r => setTimeout(r, cfg.execution.cooldown_ms));
return { sent: true, tx1: tx1.hash, tx2: tx2.hash };
}
7DRY_RUN (env): set DRY_RUN=1 to force dry-run from the shell.TICK_MS (in-file constant): optional—change the loop cadence if you want a slower/faster heartbeat.src/execute.ts. Use npm run dryrun first, then npm run exec when ready.// src/execute.ts
import 'dotenv/config';
import { startStream } from './stream';
import { snapshot } from './state';
import { evalSignal } from './signal';
import { tryExecute } from './executor';
import { loadConfig } from './configFile';
const cfg = loadConfig();
const TICK_MS = 1000;
let busy = false;
function fmt(n?: number | null, p: number = 6) {
return n == null ? '…' : n.toFixed(p);
}
async function loop() {
if (busy) return;
const snap = snapshot();
const sig = evalSignal();
const ts = new Date().toISOString();
const lineBase =
`[${ts}] A=${fmt(snap.A.price)} (lat ${snap.A.latencyMs ?? '…'}ms) | ` +
`B=${fmt(snap.B.price)} (lat ${snap.B.latencyMs ?? '…'}ms) | `;
if (!sig.ok) {
console.log(lineBase + `no edge${sig.note ? ` (${sig.note})` : ''}`);
return;
}
busy = true;
console.log(lineBase + `SIGNAL: ${sig.side} | edge ${sig.spreadBps?.toFixed(1)} bps → checking guards…`);
try {
const res = await tryExecute();
if (res.sent) {
console.log(`EXECUTED: tx1=${res.tx1} tx2=${res.tx2}`);
} else {
console.log(`SKIP: ${res.reason}`);
}
} catch (e: any) {
console.error(`ERROR: ${e?.message || e}`);
} finally {
busy = false;
}
}
function main() {
startStream(() => console.log('Stream connected. Waiting for live prices…'));
setInterval(loop, TICK_MS);
}
main();npm run dryrunnpm run execSKIP: dry-run.SKIP: <reason> (stale quote, gas cap, balance, etc.).
8src/pnl.ts: It checks the edge BPs, slippage, fees, and compares them with the edge BPs to determine the PnL of the trade.src/pnl.ts.// src/pnl.ts
export function estPnlQuote(
edgeBps: number, // post-fee edge your signal reported (bps)
notionalQuote: number, // trade size in quote units
slipBps: number, // slippage budget (bps)
feesBps: number // total fees: poolA + poolB (+ gas proxy) in bps
) {
const pnlBps = edgeBps - slipBps - feesBps;
return (pnlBps / 10_000) * notionalQuote;// ADD near other imports:
import { estPnlQuote } from './pnl';
// … inside your success path:
if (res.sent) {
const feesBps =
cfg.fees.poolA_bps + cfg.fees.poolB_bps + (cfg.fees.gas_bps ?? 0);
const est = estPnlQuote(
sig.spreadBps ?? 0,
cfg.execution.trade_notional_quote,
cfg.policy.slippage_bps,
feesBps
);
console.log(
`EXECUTED: tx1=${res.tx1} tx2=${res.tx2} | estPnL≈ ${est.toFixed(2)} quote`
);
}
EXECUTED: tx1=… tx2=… | estPnL≈ 0.07 quote
No swaps ever fire → Signal OK but executor idle. Check DRY_RUN isn’t set, cooldown is not blocking (wait a few seconds), and your spread_threshold_bps isn’t higher than typical edges.
SKIP: stale quote → Quote timestamp too old. Reduce network hops, raise the staleness cap slightly, or call the quoter right before execution.
INSUFFICIENT_ALLOWANCE / transferFrom failed → Approvals missing or to the wrong router. Re-run npm run approve after confirming routerA/routerB and token addresses in config.yaml.
EXECUTION_REVERTED on leg A/B → MinOut too tight or wrong decimals. Recheck token decimals, fee math, and slippage budget; verify minOut uses the same units as the router.
Insufficient funds for gas → Wallet underfunded. Top up the native token for gas on Sonic; keep a buffer for spikes.
Quotes look huge/tiny → Decimals / amountIn mismatch. Confirm decimalsIn/decimalsOut and that trade_notional_quote is in quote units, not wei/base.
SKIP: gas over cap → Gas price spike. Raise the gas cap modestly or wait; don’t disable the guard.
Invalid address / chainId mismatch → Wrong network/router. Ensure SONIC_RPC_URL is Sonic, and router/token addresses are Sonic deployments (not Ethereum).
Nonce too low/stuck tx → Competing sends or slow provider. Use a single runner, retry with a slightly higher gas price, or clear pending txs.
File writes OK, but console shows … for prices → Stream isn’t live. Restart the process; verify pool filters and API key; check your WebSocket connectivity.
If one keeps repeating, log the reason and values (quote age, gas, minOut, balances) next to the skip—most fixes become obvious with those numbers printed.
You now have a working HFT arbitrage loop on Sonic, end-to-end: Goldrush Streams → clean prices → features/policy → capture/replay → walk-forward backtest → approvals → quoter → guarded executor → tiny PnL print. It runs live, gates every attempt with minOut/staleness/gas/cooldown, and logs enough context to debug quickly. The code is modular, so you can swap pools/routers and tune without touching core logic.
Not blockers, but worth adding as you harden:
Persistence & restart safety (write fills/decisions to disk/DB; resume cleanly).
Telemetry & alerts (latency spikes, stale-quote rate, revert reasons, PnL drift).
Key & allowance hygiene (low balances, scoped approvals, periodic rotation/revocation).
Tests for edge cases (decimals/minOut math, quote age, one-in-flight control).
A real PnL ledger (beyond the quick estimate) and simple dashboards.
Run small, live: keep size conservative; watch logs and trades.csv for hit rate, slippage, and skips.
Tune, then widen: adjust thresholds/windows; only then add more pairs/venues.
Optional upgrades: CLAMM/DLMM quoter support, private submission (if/when available on Sonic), containerize and supervise the process (PM2/systemd), and ship basic dashboards.
You can use the bot today in a cautious configuration. Treat it like a real system: measure, adjust, and scale only when the numbers stay boring and green.