meridian. DeFi market-making agent

Tutorial progress

1

Agent architecture

A market-making agent continuously posts bid and ask quotes on a trading venue, earning the spread between them. For RWA (real-world asset) markets -- tokenized treasuries, real estate, commodities -- market making is especially valuable because these assets are illiquid and need someone to provide quotes.

The meridian agent architecture has four components running in a loop:

Why meridian? General-purpose MM frameworks (like quoter) work for fungible tokens. Meridian is built specifically for RWA markets, where you need geography-aware compliance, oracle-driven pricing (not orderbook-derived), and longer holding periods. See the basic meridian tutorial for the fundamentals.

2

Prerequisites

Before you start

  • Python 3.10+
  • Completed the basic meridian tutorial (recommended)
  • Completed the switchboard getting started tutorial (recommended)
  • A Base Sepolia testnet wallet with test ETH and a mock RWA token
  • Basic understanding of market making (bid/ask, spread, inventory)
3

Project setup

terminal -- setup
mkdir rwa-mm-agent && cd rwa-mm-agent
python -m venv .venv && source .venv/bin/activate

pip install meridian-agent switchboard-agent web3 numpy

Project structure:

project structure
rwa-mm-agent/
  config.py            # wallet, budget, asset config
  oracle.py            # multi-source price oracle
  spread.py            # Avellaneda-Stoikov adaptive spreads
  inventory.py         # position tracking + rebalancing
  executor.py          # on-chain order submission
  agent.py             # main loop
  backtest.py          # historical simulation
config.py
import os
from dataclasses import dataclass
from switchboard.gas_budget import GasBudgetTracker

@dataclass
class AssetConfig:
    name: str             # e.g. "T-BILL-6M" (tokenized 6-month T-bill)
    token_address: str   # ERC-20 address of the RWA token
    quote_token: str      # USDC address
    oracle_feeds: list   # Chainlink/Pyth feed addresses
    tick_interval: int   # seconds between quote updates
    base_spread_bps: int # base spread in basis points (e.g. 20 = 0.20%)
    max_position: float  # max units of RWA token to hold
    geography: str       # "US", "EU", "LATAM" -- determines compliance rules

# Example: tokenized 6-month US T-bill on Base
ASSET = AssetConfig(
    name="T-BILL-6M",
    token_address="0x...tbill_token...",
    quote_token="0x036CbD53842c5426634e7929541eC2318f3dCF7e",  # USDC
    oracle_feeds=[
        "0x...chainlink_tbill_usd...",
        "0x...pyth_tbill_usd...",
    ],
    tick_interval=30,    # update quotes every 30 seconds
    base_spread_bps=20, # 0.20% base spread
    max_position=10000, # hold at most 10K tokens
    geography="US",
)

# Switchboard gas budget
budget = GasBudgetTracker(
    hourly_limit=0.1,    # 0.1 ETH gas per hour
    daily_limit=1.0,     # 1 ETH gas per day
)
4

Multi-source oracle pricing

The oracle module fetches prices from multiple sources and computes a robust mid-price. For RWA assets, you often combine on-chain oracle feeds with off-chain reference rates (e.g., the US Treasury yield curve).

oracle.py
import numpy as np
from web3 import Web3
from meridian.oracle import ChainlinkFeed, PythFeed, MedianAggregator

class RWAOracle:
    """Multi-source oracle with outlier rejection for RWA assets."""

    def __init__(self, w3: Web3, feed_addresses: list):
        self.feeds = []
        for addr in feed_addresses:
            # Auto-detect feed type by contract interface
            self.feeds.append(ChainlinkFeed(w3, addr))

        self.aggregator = MedianAggregator(
            max_staleness=300,      # reject prices older than 5 min
            max_deviation=0.02,     # reject if > 2% from median
        )
        self.price_history = []

    def get_mid_price(self) -> float:
        """Fetch prices from all feeds, aggregate, return mid-price."""
        prices = []
        for feed in self.feeds:
            try:
                p = feed.latest_price()
                prices.append(p)
            except Exception as e:
                print(f"Feed error: {e}")

        if not prices:
            raise RuntimeError("No oracle prices available")

        mid = self.aggregator.aggregate(prices)
        self.price_history.append(mid)
        return mid

    def volatility(self, window: int = 20) -> float:
        """Compute realized volatility from recent price history."""
        if len(self.price_history) < window + 1:
            return 0.01  # default low vol
        recent = self.price_history[-window:]
        returns = np.diff(np.log(recent))
        return float(np.std(returns))
5

Adaptive spread calculation

The Avellaneda-Stoikov model adjusts the bid-ask spread based on volatility and inventory. When volatility is high, the spread widens to protect against adverse selection. When the agent's inventory is skewed, the spread shifts to encourage rebalancing.

spread.py
import math
from meridian.strategy import AvellanedaStoikov

class AdaptiveSpread:
    """Compute bid/ask quotes using Avellaneda-Stoikov with RWA adjustments."""

    def __init__(
        self,
        base_spread_bps: int,
        risk_aversion: float = 0.5,
        time_horizon: float = 1.0,
    ):
        self.base_spread = base_spread_bps / 10000
        self.gamma = risk_aversion
        self.T = time_horizon
        self.model = AvellanedaStoikov(
            gamma=risk_aversion,
            sigma_default=0.01,
        )

    def compute_quotes(
        self,
        mid_price: float,
        volatility: float,
        inventory: float,
        max_inventory: float,
    ) -> tuple[float, float]:
        """Return (bid_price, ask_price) adjusted for volatility and inventory."""

        # Avellaneda-Stoikov reservation price
        # r(s, q) = s - q * gamma * sigma^2 * T
        inventory_fraction = inventory / max_inventory  # [-1, 1]
        reservation = mid_price - inventory_fraction * self.gamma * (volatility ** 2) * self.T

        # Optimal spread (increases with volatility)
        # delta = gamma * sigma^2 * T + (2/gamma) * ln(1 + gamma/k)
        k = 1.5  # order arrival intensity parameter
        optimal_half_spread = (
            self.gamma * (volatility ** 2) * self.T / 2
            + math.log(1 + self.gamma / k) / self.gamma
        )

        # Enforce minimum spread (base_spread_bps from config)
        half_spread = max(optimal_half_spread, self.base_spread / 2)

        bid_price = reservation - half_spread
        ask_price = reservation + half_spread

        return round(bid_price, 6), round(ask_price, 6)

RWA-specific adjustment: Meridian adds a geography-aware compliance layer on top of Avellaneda-Stoikov. For US-regulated RWA tokens, the agent verifies that counterparties are KYC'd before accepting trades. The compliance check happens in the executor, not the spread calculator.

6

Inventory management

The inventory manager tracks the agent's position and computes risk metrics. It communicates with the spread calculator to skew quotes when the position is too large or too small.

inventory.py
from dataclasses import dataclass, field
from meridian.risk import RiskMetrics

@dataclass
class InventoryState:
    position: float = 0.0       # units of RWA token held
    avg_entry: float = 0.0      # average entry price
    total_pnl: float = 0.0      # realized PnL
    trades: int = 0
    fills_bid: int = 0          # number of bid fills (we bought)
    fills_ask: int = 0          # number of ask fills (we sold)


class InventoryManager:
    """Track inventory, compute PnL, enforce position limits."""

    def __init__(self, max_position: float):
        self.max_position = max_position
        self.state = InventoryState()

    def on_fill(self, side: str, price: float, quantity: float):
        """Update state when an order is filled."""
        if side == "bid":
            # We bought (inventory increases)
            new_pos = self.state.position + quantity
            self.state.avg_entry = (
                (self.state.avg_entry * self.state.position + price * quantity)
                / new_pos if new_pos > 0 else 0
            )
            self.state.position = new_pos
            self.state.fills_bid += 1
        else:
            # We sold (inventory decreases)
            pnl = (price - self.state.avg_entry) * quantity
            self.state.total_pnl += pnl
            self.state.position -= quantity
            self.state.fills_ask += 1

        self.state.trades += 1

    def should_quote_bid(self) -> bool:
        """Only quote bids if we have room to buy more."""
        return self.state.position < self.max_position

    def should_quote_ask(self) -> bool:
        """Only quote asks if we have inventory to sell."""
        return self.state.position > 0

    def unrealized_pnl(self, current_price: float) -> float:
        """Mark-to-market PnL on current holdings."""
        return (current_price - self.state.avg_entry) * self.state.position

    def summary(self, current_price: float) -> str:
        upnl = self.unrealized_pnl(current_price)
        return (
            f"Position: {self.state.position:.2f} | "
            f"Avg entry: ${self.state.avg_entry:.4f} | "
            f"Realized PnL: ${self.state.total_pnl:.2f} | "
            f"Unrealized PnL: ${upnl:.2f} | "
            f"Trades: {self.state.trades}"
        )
7

On-chain execution

The executor submits limit orders to the on-chain trading venue. It uses switchboard's gas budget to ensure the agent does not overspend on gas fees.

agent.py -- main loop
import asyncio, time
from web3 import Web3
from config import ASSET, budget
from oracle import RWAOracle
from spread import AdaptiveSpread
from inventory import InventoryManager
from meridian.executor import OnChainExecutor
from switchboard.gas_budget import BudgetExhausted

w3 = Web3(Web3.HTTPProvider("https://sepolia.base.org"))

oracle = RWAOracle(w3, ASSET.oracle_feeds)
spread_calc = AdaptiveSpread(ASSET.base_spread_bps)
inventory = InventoryManager(ASSET.max_position)
executor = OnChainExecutor(
    w3=w3,
    private_key="0x...",
    budget=budget,
    token=ASSET.token_address,
    quote_token=ASSET.quote_token,
)


async def run_agent():
    print(f"Starting MM agent for {ASSET.name}")
    print(f"Base spread: {ASSET.base_spread_bps}bps | Max position: {ASSET.max_position}")

    while True:
        try:
            # 1. Get mid price from oracle
            mid = oracle.get_mid_price()
            vol = oracle.volatility()

            # 2. Compute adaptive bid/ask
            bid, ask = spread_calc.compute_quotes(
                mid_price=mid,
                volatility=vol,
                inventory=inventory.state.position,
                max_inventory=inventory.max_position,
            )

            spread_bps = (ask - bid) / mid * 10000
            print(f"\n[{time.strftime('%H:%M:%S')}] Mid: ${mid:.4f} | Vol: {vol:.4f} | Spread: {spread_bps:.1f}bps")
            print(f"  Bid: ${bid:.4f} | Ask: ${ask:.4f}")
            print(f"  {inventory.summary(mid)}")

            # 3. Cancel old orders and submit new quotes
            await executor.cancel_all()

            if inventory.should_quote_bid():
                await executor.submit_limit_order(
                    side="buy",
                    price=bid,
                    quantity=min(100, inventory.max_position - inventory.state.position),
                )

            if inventory.should_quote_ask():
                await executor.submit_limit_order(
                    side="sell",
                    price=ask,
                    quantity=min(100, inventory.state.position),
                )

            # 4. Check for fills from previous tick
            fills = await executor.check_fills()
            for fill in fills:
                inventory.on_fill(fill.side, fill.price, fill.quantity)
                print(f"  FILL: {fill.side} {fill.quantity} @ ${fill.price:.4f}")

        except BudgetExhausted as e:
            print(f"  GAS BUDGET EXHAUSTED: {e} -- pausing until budget refills")

        except Exception as e:
            print(f"  ERROR: {e}")

        await asyncio.sleep(ASSET.tick_interval)


asyncio.run(run_agent())

Risk warning: Market making involves financial risk. This tutorial uses testnet tokens. Before deploying with real assets, you need proper risk management, circuit breakers, and significantly more sophisticated execution logic. The meridian library includes production-grade risk modules -- see the documentation.

8

Backtesting

Before going live, backtest your strategy against historical data. Meridian includes a backtesting harness that simulates order fills using a Brownian motion price model or historical tick data.

backtest.py
from meridian.backtest import Backtester, BrownianPriceModel
from spread import AdaptiveSpread
from inventory import InventoryManager

# Simulate 500 ticks with Brownian price motion
price_model = BrownianPriceModel(
    initial_price=99.50,     # T-bill face value ~$100
    drift=0.0001,             # slight upward drift
    volatility=0.005,         # low vol for treasuries
    num_ticks=500,
)

backtester = Backtester(
    spread_calc=AdaptiveSpread(base_spread_bps=20),
    inventory=InventoryManager(max_position=10000),
    price_model=price_model,
    fill_probability=0.3,    # 30% chance a quote gets filled per tick
)

results = backtester.run()

print(f"Total trades: {results.total_trades}")
print(f"Realized PnL: ${results.realized_pnl:.2f}")
print(f"Unrealized PnL: ${results.unrealized_pnl:.2f}")
print(f"Total PnL: ${results.total_pnl:.2f}")
print(f"Sharpe ratio: {results.sharpe:.2f}")
print(f"Max drawdown: ${results.max_drawdown:.2f}")
print(f"Fill rate: {results.fill_rate:.1%}")
print(f"Avg spread captured: {results.avg_spread_bps:.1f}bps")

# Compare strategies: try the interactive simulator at meridian.kcolbchain.com
# for side-by-side constant vs adaptive spread comparison.
9

Next steps

You have a working DeFi market-making agent with adaptive spreads, inventory management, and switchboard gas budget safety. Here is where to go from here: