Tutorial progress
- Agent architecture
- Prerequisites
- Project setup
- Oracle pricing
- Adaptive spreads
- Inventory management
- On-chain execution
- Backtesting
- Next steps
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:
- Oracle pricing engine -- fetches the fair value of the RWA asset from multiple price sources (Chainlink, Pyth, off-chain APIs) and computes a weighted mid-price.
- Adaptive spread calculator -- widens the bid-ask spread when volatility is high or inventory is skewed, narrows it in calm markets. Based on Avellaneda-Stoikov theory.
- Inventory manager -- tracks the agent's position and adjusts quotes to rebalance. If the agent holds too much of the asset, it lowers the bid to discourage more buying and raises the ask to encourage selling.
- On-chain executor -- submits limit orders to the DEX (or RFQ system) using switchboard for wallet management and gas budgets.
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.
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)
Project 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:
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
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 )
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).
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))
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.
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.
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.
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}" )
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.
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.
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.
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.
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:
- Multi-venue execution -- extend the executor to quote on multiple DEXes (Uniswap, RFQ platforms) simultaneously. See quoter for multi-venue patterns.
- ML-enhanced spreads -- replace the Avellaneda-Stoikov model with an ML model trained on historical fill data. Use ZKML to prove the model is the one you claim.
- Agent payments -- use x402 to pay for premium data feeds (e.g., real-time treasury yield curves) that improve your oracle pricing.
- Geography compliance -- enable meridian's geography-aware compliance module for US-regulated RWA tokens. See the documentation.