Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,8 @@ This folder contains an implementation of El Farol restaurant model. Agents (res
### [Schelling Model with Caching and Replay](https://github.com/mesa/mesa-examples/tree/main/examples/caching_and_replay)

This example applies caching on the Mesa [Schelling](https://github.com/mesa/mesa-examples/tree/main/examples/schelling) example. It enables a simulation run to be "cached" or in other words recorded. The recorded simulation run is persisted on the local file system and can be replayed at any later point.

### [Continuous Double Auction (ZI-C) Market Model](https://github.com/mesa/mesa-examples/tree/main/examples/zi_double_auction)

A continuous double-auction market with zero-intelligence traders. Buyers and sellers each have a private reservation price or cost, and submit randomized, budget-constrained bids and asks as they arrive asynchronously through Mesa's event-driven scheduler. The order book matches on price-time priority and clears at the midpoint price, converging naturally toward the market's theoretical equilibrium.

148 changes: 148 additions & 0 deletions examples/zi_double_auction/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# Continuous Double Auction (ZI-C) Market Model

## Overview

This model implements a Continuous Double Auction (CDA) — the kind of matching
engine that underlies most real financial exchanges — populated by simple,
randomized "Zero-Intelligence" traders (following Gode & Sunder's ZI-C
formulation). It reproduces their classic 1993 result: even traders with no
strategy or market knowledge, constrained only by a private valuation, will
drive a market toward its theoretical equilibrium price purely through the
mechanics of the auction itself.

Beyond the economics, this example showcases Mesa's continuous-time,
event-driven scheduling. Traders don't act in lockstep on a fixed tick —
each schedules its own next arrival at a randomized, staggered `model.time`,
rather than all acting on every discrete step.

## Gode & Sunder ZI-C Traders

Each trader (`Buyer` or `Seller`) is assigned a private value on creation —
a buyer's maximum willingness to pay, or a seller's minimum acceptable cost.
On arrival, a trader cancels any stale resting order of its own, then submits
a new random order constrained by that private value:

- **Buyers** submit a bid drawn uniformly from `[0, private_value]`
- **Sellers** submit an ask drawn uniformly from `[private_value, max_valuation]`

No trader ever sees the order book, other traders, or market history — all
convergence toward equilibrium emerges purely from the auction mechanism,
not from trader intelligence.

## How It Works

1. **Arrival**: Each trader schedules its own first arrival on creation, then
repeated arrivals via `model.rng.exponential(mean_interarrival)` — a
randomized, staggered delay rather than a fixed tick.
2. **Order submission**: On arrival, the trader cancels any stale resting
order of its own, then submits a new random bid/ask as described above.
3. **Matching**: The order book matches on price-time priority — best bid is
the highest price (earliest timestamp breaks ties), best ask is the
lowest price (earliest timestamp breaks ties).
4. **Clearing**: Whenever the best bid crosses the best ask
(`best_bid.price >= best_ask.price`), the trade clears at their exact
midpoint: `(best_bid.price + best_ask.price) / 2`.
5. **Full clearing per arrival**: After each new order, `handle_arrival`
matches in a loop until no crossing orders remain — this was fixed after
review caught that a single match per arrival could leave the book still
crossed.
6. **Data collection**: Every tick, `DataCollector` records `ClearingPrice`,
`Volume`, `CumulativeVolume`, `Spread`, `BestBid`, and `BestAsk` at the
model level, and `Wealth`, `Cash`, `Inventory`, `PrivateValue`, `Type`,
and `DoneTrading` at the agent level.

## Installation

Install dependencies from this example's `requirements.txt`:

```bash
pip install -r requirements.txt
```

which pins:

```text
mesa[viz]>=3.5
pandas
matplotlib
pytest
```

(Tested against both Mesa 3.5.1 and the Mesa 4.0 development branch.)

## Running the Model

**Interactively**, from the repository root:

```bash
solara run examples/zi_double_auction/notebook/app.py
```

or, from inside `examples/zi_double_auction/`:

```bash
solara run notebook/app.py
```

Then open your browser to the local Solara URL, select model parameters,
press Reset, then Start, to view the live dashboard tracking clearing price,
spread, and volume in real time.

**As a scripted run**, from inside `examples/zi_double_auction/`:

```bash
python notebook/run_simulation.py
```

This prints recent model data, compares transaction prices against a
uniform-price equilibrium reference computed from the sampled private
values, and saves a summary figure to `notebook/simulation_results.png`.

## Project Structure

```plaintext
examples/zi_double_auction/
├── README.md
├── requirements.txt
├── model/
│ ├── __init__.py # exports Buyer, Seller, Trader, DoubleAuctionModel, Order, OrderBook, Trade
│ ├── agents.py # Trader base class, Buyer and Seller subclasses
│ ├── model.py # DoubleAuctionModel: agent creation, DataCollector, handle_arrival
│ └── order_book.py # Order, Trade dataclasses; OrderBook matching engine
├── notebook/
│ ├── app.py # interactive SolaraViz dashboard
│ ├── run_simulation.py # scripted run + summary plots
│ └── simulation_results.png
└── tests/
├── test_model.py # integration tests: agent counts, inventories, timing, no-loss trades
└── test_orderbook.py # unit tests: matching, price-time priority, cancellation, spread
```

## Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `n_buyers` | `int` | `25` | Number of `Buyer` agents created |
| `n_sellers` | `int` | `25` | Number of `Seller` agents created |
| `max_valuation` | `float` | `100.0` | Upper bound for sampling buyer reservation prices, seller costs, and seller ask prices |
| `mean_interarrival` | `float` | `1.0` | Mean of the exponential distribution controlling how staggered trader arrival times are |
| `rng` | `int \| None` | `None` | Seed for Mesa's random number generator |

## Verification

The order book's matching engine is covered independently of Mesa in
`tests/test_orderbook.py` (price-time priority, tie-breaking, midpoint
clearing, cancellation, multi-trade clearing). `tests/test_model.py` adds
integration-level invariant checks — including that no agent ever transacts
at a loss relative to its private value, and that a trader with a completed
trade never reappears in the order book. The scripted run
(`run_simulation.py`) additionally verifies empirically that simulated
transaction prices converge toward the theoretical equilibrium price implied
by the sampled supply/demand curves, consistent with the original Gode &
Sunder result.

## Further Reading

Gode, D. K., & Sunder, S. (1993). Allocative Efficiency of Markets with
Zero-Intelligence Traders: Market as a Partial Substitute for Individual
Rationality. *Journal of Political Economy*, 101(1), 119–137.
13 changes: 13 additions & 0 deletions examples/zi_double_auction/model/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from .agents import Buyer, Seller, Trader
from .model import DoubleAuctionModel
from .order_book import Order, OrderBook, Trade

__all__ = [
"Buyer",
"DoubleAuctionModel",
"Order",
"OrderBook",
"Seller",
"Trade",
"Trader",
]
98 changes: 98 additions & 0 deletions examples/zi_double_auction/model/agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import mesa


class Trader(mesa.Agent):
"""Base class for single-unit zero-intelligence traders."""

def __init__(self, model, private_value: float):
"""Initialize a trader and schedule its first market arrival."""

super().__init__(model)
self.private_value = private_value # reservation price (buyer) or cost (seller)
self.cash = 0.0
self.inventory = 0
self.done_trading = False # True once this agent's single unit has traded
self.side: str = ""
first_delay = self.model.rng.exponential(self.model.mean_interarrival)
self.model.schedule_event(self.act, after=first_delay)

def wealth(self) -> float:
"""Return marked wealth using the trader's private value for inventory."""

return self.cash + self.inventory * self.private_value

def _schedule_next_arrival(self):
"""Schedule another market arrival unless the trader has completed its trade."""

if self.done_trading:
return
delay = self.model.rng.exponential(self.model.mean_interarrival)
self.model.schedule_event(self.act, after=delay)

def act(self):
"""Submit or update an order when the trader arrives at the market."""

raise NotImplementedError


class Buyer(Trader):
"""Zero-intelligence buyer with one unit of demand."""

def __init__(self, model, reservation_price: float):
"""Create a buyer with a maximum willingness to pay."""

super().__init__(model, private_value=reservation_price)
self.side = "bid"

def act(self):
"""Cancel any stale bid, submit a new random bid, and try to trade."""

if self.done_trading:
return

self.model.order_book.cancel_agent_order(self.unique_id, "bid")
bid_price = self.model.rng.uniform(0, self.private_value)
self.model.order_book.submit_bid(self.unique_id, bid_price, self.model.time)
self.model.handle_arrival()

self._schedule_next_arrival()

def settle_purchase(self, price: float):
"""Record a completed purchase and stop future trading."""

self.cash -= price
self.inventory += 1
self.done_trading = True


class Seller(Trader):
"""Zero-intelligence seller endowed with one unit to sell."""

def __init__(self, model, cost: float):
"""Create a seller with a minimum acceptable sale price."""

super().__init__(model, private_value=cost)
self.side = "ask"
self.inventory = (
1 # sellers start endowed with the one unit they intend to sell
)

def act(self):
"""Cancel any stale ask, submit a new random ask, and try to trade."""

if self.done_trading:
return

self.model.order_book.cancel_agent_order(self.unique_id, "ask")
ask_price = self.model.rng.uniform(self.private_value, self.model.max_valuation)
self.model.order_book.submit_ask(self.unique_id, ask_price, self.model.time)
self.model.handle_arrival()

self._schedule_next_arrival()

def settle_sale(self, price: float):
"""Record a completed sale and stop future trading."""

self.cash += price
self.inventory -= 1
self.done_trading = True
85 changes: 85 additions & 0 deletions examples/zi_double_auction/model/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import mesa

from .agents import Buyer, Seller
from .order_book import OrderBook


class DoubleAuctionModel(mesa.Model):
"""Continuous double-auction model with zero-intelligence buyers and sellers."""

def __init__(
self,
n_buyers: int = 25,
n_sellers: int = 25,
max_valuation: float = 100.0,
mean_interarrival: float = 1.0,
rng: int | None = None,
):
"""Create traders, the order book, and the model data collector."""

super().__init__(rng=rng)

self.max_valuation = max_valuation
self.mean_interarrival = mean_interarrival
self.order_book = OrderBook()

self.clearing_price: float | None = None
self.cumulative_volume: int = 0
self.price_history: list[tuple[float, float]] = [] # (time, price)

self._volume_since_last_tick: int = 0
reservation_prices = self.rng.uniform(0, max_valuation, size=n_buyers).tolist()
Buyer.create_agents(
model=self, n=n_buyers, reservation_price=reservation_prices
)

costs = self.rng.uniform(0, max_valuation, size=n_sellers).tolist()
Seller.create_agents(model=self, n=n_sellers, cost=costs)

self.datacollector = mesa.DataCollector(
model_reporters={
"ClearingPrice": lambda m: m.clearing_price,
"Volume": lambda m: m._volume_since_last_tick,
"CumulativeVolume": lambda m: m.cumulative_volume,
"Spread": lambda m: m.order_book.spread(),
"BestBid": lambda m: (
m.order_book.best_bid().price if m.order_book.best_bid() else None
),
"BestAsk": lambda m: (
m.order_book.best_ask().price if m.order_book.best_ask() else None
),
},
agent_reporters={
"Wealth": lambda a: a.wealth(),
"Cash": lambda a: a.cash,
"Inventory": lambda a: a.inventory,
"PrivateValue": lambda a: a.private_value,
"Type": lambda a: type(a).__name__,
"DoneTrading": lambda a: a.done_trading,
},
)

def handle_arrival(self):
"""Settle trades while the book remains crossed after an order arrival."""

agents_by_id = {a.unique_id: a for a in self.agents}
while True:
trade = self.order_book.try_match(self.time)
if trade is None:
break

buyer = agents_by_id[trade.buyer_id]
seller = agents_by_id[trade.seller_id]
buyer.settle_purchase(trade.price)
seller.settle_sale(trade.price)

self.clearing_price = trade.price
self.price_history.append((trade.time, trade.price))
self.cumulative_volume += 1
self._volume_since_last_tick += 1

def step(self):
"""Collect one tick of data and reset the per-tick volume counter."""

self.datacollector.collect(self)
Comment thread
shipitdev marked this conversation as resolved.
self._volume_since_last_tick = 0
Loading
Loading