-
-
Notifications
You must be signed in to change notification settings - Fork 278
Feat: Add Continuous Double Auction (ZI-C) Market Model #471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
b2f74d4
order-book
shipitdev aa25fec
simulation finalized with a result
shipitdev 3f44d0b
Add README for ZI double auction example
shipitdev 87dd3f4
added the missing order_book and also added tests and documentation t…
shipitdev 0b24111
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 654a781
Apply suggestions from code review
shipitdev 6d115c4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] af1b1a4
fix: resolve linting errors and double-counting step bug
shipitdev 79b0a76
fix: resolve crossed book bug and update tests for Mesa 4.0 continuou…
shipitdev e843098
refactored readme
shipitdev c0f8d44
Add Continuous Double Auction example to Other Examples
shipitdev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| self._volume_since_last_tick = 0 | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.