diff --git a/README.md b/README.md index a678d0da..1925a718 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/examples/zi_double_auction/README.md b/examples/zi_double_auction/README.md new file mode 100644 index 00000000..a7e27212 --- /dev/null +++ b/examples/zi_double_auction/README.md @@ -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. \ No newline at end of file diff --git a/examples/zi_double_auction/model/__init__.py b/examples/zi_double_auction/model/__init__.py new file mode 100644 index 00000000..473263f7 --- /dev/null +++ b/examples/zi_double_auction/model/__init__.py @@ -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", +] diff --git a/examples/zi_double_auction/model/agents.py b/examples/zi_double_auction/model/agents.py new file mode 100644 index 00000000..1942862e --- /dev/null +++ b/examples/zi_double_auction/model/agents.py @@ -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 diff --git a/examples/zi_double_auction/model/model.py b/examples/zi_double_auction/model/model.py new file mode 100644 index 00000000..bb1c444d --- /dev/null +++ b/examples/zi_double_auction/model/model.py @@ -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 diff --git a/examples/zi_double_auction/model/order_book.py b/examples/zi_double_auction/model/order_book.py new file mode 100644 index 00000000..b3606d4d --- /dev/null +++ b/examples/zi_double_auction/model/order_book.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass + + +@dataclass +class Order: + """A resting bid or ask submitted by one trader.""" + + agent_id: int + price: float + side: str # "bid" or "ask" + timestamp: float # model.time at submission; used for FIFO tie-breaks + + +@dataclass +class Trade: + """A matched buyer/seller pair and the surplus implied by the clearing price.""" + + time: float + price: float + buyer_id: int + seller_id: int + buyer_surplus: float + seller_surplus: float + + +class OrderBook: + """Minimal limit order book for a single-unit continuous double auction.""" + + def __init__(self): + """Create an empty bid and ask book.""" + + self.bids: list[Order] = [] + self.asks: list[Order] = [] + + def submit_bid(self, agent_id: int, price: float, time: float) -> None: + """Add a bid order for the given agent.""" + + self.bids.append(Order(agent_id, price, "bid", time)) + + def submit_ask(self, agent_id: int, price: float, time: float) -> None: + """Add an ask order for the given agent.""" + + self.asks.append(Order(agent_id, price, "ask", time)) + + def cancel_agent_order(self, agent_id: int, side: str) -> None: + """Remove all resting orders for one agent on the requested side.""" + + book = self.bids if side == "bid" else self.asks + book[:] = [o for o in book if o.agent_id != agent_id] + + def best_bid(self) -> Order | None: + """Return the highest bid, using earliest submission as the tie-breaker.""" + + if not self.bids: + return None + # Highest price wins; earliest timestamp breaks ties (price-time priority) + return max(self.bids, key=lambda o: (o.price, -o.timestamp)) + + def best_ask(self) -> Order | None: + """Return the lowest ask, using earliest submission as the tie-breaker.""" + + if not self.asks: + return None + return min(self.asks, key=lambda o: (o.price, o.timestamp)) + + def spread(self) -> float | None: + """Return best ask minus best bid, or None when either side is empty.""" + + bb, ba = self.best_bid(), self.best_ask() + if bb is None or ba is None: + return None + return ba.price - bb.price + + def try_match(self, time: float) -> Trade | None: + """Match the best bid and ask if they cross, clearing at their midpoint.""" + + bb, ba = self.best_bid(), self.best_ask() + if bb is None or ba is None: + return None + if bb.price < ba.price: + return None # no overlap, nothing to trade + + clearing_price = (bb.price + ba.price) / 2.0 + + self.bids.remove(bb) + self.asks.remove(ba) + + return Trade( + time=time, + price=clearing_price, + buyer_id=bb.agent_id, + seller_id=ba.agent_id, + buyer_surplus=bb.price - clearing_price, + seller_surplus=clearing_price - ba.price, + ) + + def clear_all_crosses(self, time: float) -> list[Trade]: + """Repeatedly match crossing orders until the book no longer crosses.""" + + trades = [] + while True: + t = self.try_match(time) + if t is None: + break + trades.append(t) + return trades diff --git a/examples/zi_double_auction/notebook/app.py b/examples/zi_double_auction/notebook/app.py new file mode 100644 index 00000000..bd127e7b --- /dev/null +++ b/examples/zi_double_auction/notebook/app.py @@ -0,0 +1,34 @@ +import os +import sys + +from mesa.visualization import Slider, SolaraViz, make_plot_component + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from model import DoubleAuctionModel + +model_params = { + "n_buyers": Slider(label="Number of buyers", value=25, min=2, max=100, step=1), + "n_sellers": Slider(label="Number of sellers", value=25, min=2, max=100, step=1), + "max_valuation": Slider( + label="Max valuation ($)", value=100.0, min=10.0, max=500.0, step=10.0 + ), + "mean_interarrival": Slider( + label="Mean time between an agent's arrivals", + value=1.0, + min=0.1, + max=5.0, + step=0.1, + ), +} + +page = SolaraViz( + DoubleAuctionModel(n_buyers=25, n_sellers=25), + [ + make_plot_component("ClearingPrice"), + make_plot_component("CumulativeVolume"), + make_plot_component("Spread"), + ], + model_params=model_params, + name="Continuous Double Auction — Zero-Intelligence Traders", +) diff --git a/examples/zi_double_auction/notebook/run_simulation.py b/examples/zi_double_auction/notebook/run_simulation.py new file mode 100644 index 00000000..526063fd --- /dev/null +++ b/examples/zi_double_auction/notebook/run_simulation.py @@ -0,0 +1,169 @@ +import os +import sys + +import matplotlib.pyplot as plt + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from model import DoubleAuctionModel + + +def theoretical_equilibrium(model: DoubleAuctionModel): + """Compute a uniform-price equilibrium reference from private values.""" + + buyers = [a for a in model.agents if type(a).__name__ == "Buyer"] + sellers = [a for a in model.agents if type(a).__name__ == "Seller"] + + demand = sorted((b.private_value for b in buyers), reverse=True) + supply = sorted(s.private_value for s in sellers) + + eq_qty = 0 + for q in range(1, min(len(demand), len(supply)) + 1): + if demand[q - 1] >= supply[q - 1]: + eq_qty = q + else: + break + + eq_price = (demand[eq_qty - 1] + supply[eq_qty - 1]) / 2 if eq_qty > 0 else None + return demand, supply, eq_qty, eq_price + + +def run( + n_buyers=25, + n_sellers=25, + max_valuation=100.0, + mean_interarrival=1.0, + duration=300, + rng=42, +): + """Run the model for a fixed duration and return model and data frames.""" + + model = DoubleAuctionModel( + n_buyers=n_buyers, + n_sellers=n_sellers, + max_valuation=max_valuation, + mean_interarrival=mean_interarrival, + rng=rng, + ) + model.run_for(duration) + + model_df = model.datacollector.get_model_vars_dataframe() + agent_df = model.datacollector.get_agent_vars_dataframe() + return model, model_df, agent_df + + +def plot_results(model, model_df, save_path="simulation_results.png"): + """Plot equilibrium, transaction prices, cumulative volume, and spread.""" + + demand, supply, eq_qty, eq_price = theoretical_equilibrium(model) + + fig, axes = plt.subplots(1, 3, figsize=(16, 5)) + + # --- Panel 1: Supply & demand with theoretical equilibrium --- + ax = axes[0] + ax.step( + range(1, len(demand) + 1), + demand, + where="post", + label="Demand", + color="tab:blue", + ) + ax.step( + range(1, len(supply) + 1), supply, where="post", label="Supply", color="tab:red" + ) + if eq_price is not None: + ax.axhline( + eq_price, + color="gray", + linestyle="--", + linewidth=1, + label=f"Theoretical eq. price = {eq_price:.1f}", + ) + ax.axvline(eq_qty, color="gray", linestyle=":", linewidth=1) + ax.set_xlabel("Quantity") + ax.set_ylabel("Price") + ax.set_title("Supply & Demand (known private values)") + ax.legend(fontsize=8) + + # --- Panel 2: Transaction price convergence, by model TIME not step --- + ax = axes[1] + if model.price_history: + times, prices = zip(*model.price_history) + ax.plot( + times, + prices, + marker="o", + markersize=3, + linewidth=1, + color="tab:green", + label="Transaction price", + ) + if eq_price is not None: + ax.axhline( + eq_price, + color="gray", + linestyle="--", + linewidth=1, + label="Theoretical equilibrium", + ) + ax.set_xlabel("Model time") + ax.set_ylabel("Price") + ax.set_title("Transaction Price Convergence (continuous time)") + ax.legend(fontsize=8) + + # --- Panel 3: Cumulative volume and spread, by tick --- + ax = axes[2] + ax.plot( + model_df.index, + model_df["CumulativeVolume"], + color="tab:purple", + label="Cumulative volume", + ) + ax2 = ax.twinx() + ax2.plot( + model_df.index, + model_df["Spread"], + color="tab:orange", + linewidth=1, + label="Spread", + ) + ax.set_xlabel("Tick (1 tick = 1 time unit)") + ax.set_ylabel("Cumulative volume", color="tab:purple") + ax2.set_ylabel("Bid-ask spread", color="tab:orange") + ax.set_title("Volume & Spread") + + fig.tight_layout() + fig.savefig(save_path, dpi=150) + print(f"Saved figure to {save_path}") + + if eq_price is not None and model.price_history: + last_5 = sum(p for _, p in model.price_history[-5:]) / min( + 5, len(model.price_history) + ) + print(f"Theoretical equilibrium price: {eq_price:.2f} at quantity {eq_qty}") + print(f"Mean of last 5 transaction prices: {last_5:.2f}") + print( + f"Absolute deviation: {abs(last_5 - eq_price):.2f} " + f"({100 * abs(last_5 - eq_price) / eq_price:.1f}% of eq. price)" + ) + print( + f"Actual trades executed: {model.cumulative_volume} " + f"(uniform-price equilibrium quantity: {eq_qty})" + ) + + +if __name__ == "__main__": + model, model_df, agent_df = run( + n_buyers=25, + n_sellers=25, + max_valuation=100.0, + mean_interarrival=1.0, + duration=300, + rng=42, + ) + print(model_df.tail(10)) + plot_results( + model, + model_df, + save_path=os.path.join(os.path.dirname(__file__), "simulation_results.png"), + ) diff --git a/examples/zi_double_auction/notebook/simulation_results.png b/examples/zi_double_auction/notebook/simulation_results.png new file mode 100644 index 00000000..b4672aeb Binary files /dev/null and b/examples/zi_double_auction/notebook/simulation_results.png differ diff --git a/examples/zi_double_auction/requirements.txt b/examples/zi_double_auction/requirements.txt new file mode 100644 index 00000000..86b71395 --- /dev/null +++ b/examples/zi_double_auction/requirements.txt @@ -0,0 +1,4 @@ +mesa[viz]>=3.5,<4.0 +pandas +matplotlib +pytest diff --git a/examples/zi_double_auction/tests/test_model.py b/examples/zi_double_auction/tests/test_model.py new file mode 100644 index 00000000..62aec6cd --- /dev/null +++ b/examples/zi_double_auction/tests/test_model.py @@ -0,0 +1,79 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from model import Buyer, DoubleAuctionModel, Seller + + +def test_model_creates_correct_agent_counts(): + model = DoubleAuctionModel(n_buyers=10, n_sellers=8, rng=1) + buyers = [a for a in model.agents if isinstance(a, Buyer)] + sellers = [a for a in model.agents if isinstance(a, Seller)] + assert len(buyers) == 10 + assert len(sellers) == 8 + assert len(model.agents) == 18 + + +def test_sellers_start_with_one_unit_inventory(): + model = DoubleAuctionModel(n_buyers=5, n_sellers=5, rng=1) + for s in [a for a in model.agents if isinstance(a, Seller)]: + assert s.inventory == 1 + + +def test_buyers_start_with_zero_inventory(): + model = DoubleAuctionModel(n_buyers=5, n_sellers=5, rng=1) + for b in [a for a in model.agents if isinstance(a, Buyer)]: + assert b.inventory == 0 + + +def test_each_agent_schedules_its_own_first_arrival(): + model = DoubleAuctionModel(n_buyers=5, n_sellers=5, rng=1) + model.run_for(1) + assert ( + model.cumulative_volume > 0 + or len(model.order_book.bids) > 0 + or len(model.order_book.asks) > 0 + ) + + +def test_run_for_advances_time_and_steps(): + model = DoubleAuctionModel(n_buyers=5, n_sellers=5, rng=1) + assert model.time == 0.0 + model.run_for(10) + assert model.time == 10.0 + + +def test_volume_never_exceeds_min_side_size(): + model = DoubleAuctionModel(n_buyers=5, n_sellers=5, rng=1) + model.run_for(50) + assert model.cumulative_volume <= 5 + + +def test_datacollector_produces_one_row_per_tick(): + model = DoubleAuctionModel(n_buyers=10, n_sellers=10, rng=1) + model.run_for(10) + model_df = model.datacollector.get_model_vars_dataframe() + agent_df = model.datacollector.get_agent_vars_dataframe() + assert len(model_df) == 10 + assert len(agent_df) == 10 * 20 + + +def test_no_agent_ever_transacts_at_a_loss(): + model = DoubleAuctionModel(n_buyers=20, n_sellers=20, rng=7) + model.run_for(100) + for a in model.agents: + if isinstance(a, Buyer) and a.done_trading: + assert a.wealth() >= -1e-9 # bought at or below reservation price + if isinstance(a, Seller) and a.done_trading: + assert a.cash >= a.private_value - 1e-9 # sold at or above cost + + +def test_a_done_trading_agent_never_appears_in_the_book_again(): + model = DoubleAuctionModel(n_buyers=15, n_sellers=15, rng=3) + model.run_for(50) + resting_ids = {o.agent_id for o in model.order_book.bids} | { + o.agent_id for o in model.order_book.asks + } + done_ids = {a.unique_id for a in model.agents if a.done_trading} + assert resting_ids.isdisjoint(done_ids) diff --git a/examples/zi_double_auction/tests/test_orderbook.py b/examples/zi_double_auction/tests/test_orderbook.py new file mode 100644 index 00000000..5f44574a --- /dev/null +++ b/examples/zi_double_auction/tests/test_orderbook.py @@ -0,0 +1,110 @@ +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from model.order_book import OrderBook + + +def test_no_match_when_bid_below_ask(): + book = OrderBook() + book.submit_bid(agent_id=1, price=10.0, time=0.0) + book.submit_ask(agent_id=2, price=20.0, time=0.0) + assert book.try_match(time=0.0) is None + assert len(book.bids) == 1 + assert len(book.asks) == 1 + + +def test_match_when_bid_crosses_ask(): + book = OrderBook() + book.submit_bid(agent_id=1, price=25.0, time=0.0) + book.submit_ask(agent_id=2, price=15.0, time=0.0) + trade = book.try_match(time=0.0) + assert trade is not None + assert trade.price == 20.0 # midpoint + assert trade.buyer_id == 1 + assert trade.seller_id == 2 + assert len(book.bids) == 0 + assert len(book.asks) == 0 + + +def test_best_bid_picks_highest_price(): + book = OrderBook() + book.submit_bid(agent_id=1, price=10.0, time=0.0) + book.submit_bid(agent_id=2, price=30.0, time=0.0) + book.submit_bid(agent_id=3, price=20.0, time=0.0) + assert book.best_bid().price == 30.0 + + +def test_best_bid_uses_earliest_order_as_tie_breaker(): + book = OrderBook() + book.submit_bid(agent_id=1, price=30.0, time=2.0) + book.submit_bid(agent_id=2, price=30.0, time=1.0) + assert book.best_bid().agent_id == 2 + + +def test_best_ask_picks_lowest_price(): + book = OrderBook() + book.submit_ask(agent_id=1, price=50.0, time=0.0) + book.submit_ask(agent_id=2, price=10.0, time=0.0) + book.submit_ask(agent_id=3, price=30.0, time=0.0) + assert book.best_ask().price == 10.0 + + +def test_best_ask_uses_earliest_order_as_tie_breaker(): + book = OrderBook() + book.submit_ask(agent_id=1, price=10.0, time=2.0) + book.submit_ask(agent_id=2, price=10.0, time=1.0) + assert book.best_ask().agent_id == 2 + + +def test_clear_all_crosses_matches_multiple_pairs(): + book = OrderBook() + book.submit_bid(agent_id=1, price=50.0, time=0.0) + book.submit_bid(agent_id=2, price=40.0, time=0.0) + book.submit_ask(agent_id=3, price=10.0, time=0.0) + book.submit_ask(agent_id=4, price=20.0, time=0.0) + trades = book.clear_all_crosses(time=0.0) + assert len(trades) == 2 + assert len(book.bids) == 0 + assert len(book.asks) == 0 + + +def test_cancel_agent_order_removes_only_that_agents_order(): + book = OrderBook() + book.submit_bid(agent_id=1, price=10.0, time=0.0) + book.submit_bid(agent_id=2, price=20.0, time=0.0) + book.cancel_agent_order(agent_id=1, side="bid") + assert len(book.bids) == 1 + assert book.bids[0].agent_id == 2 + + +def test_cancel_agent_order_is_a_noop_if_agent_has_no_order(): + book = OrderBook() + book.submit_bid(agent_id=1, price=10.0, time=0.0) + book.cancel_agent_order(agent_id=999, side="bid") # agent 999 never quoted + assert len(book.bids) == 1 + + +def test_agent_can_replace_its_own_resting_order(): + # This is the core operation continuous re-quoting relies on: + # cancel your stale order, then submit a fresh one. + book = OrderBook() + book.submit_bid(agent_id=1, price=10.0, time=0.0) + book.cancel_agent_order(agent_id=1, side="bid") + book.submit_bid(agent_id=1, price=15.0, time=1.0) + assert len(book.bids) == 1 + assert book.bids[0].price == 15.0 + + +def test_spread_is_ask_minus_bid(): + book = OrderBook() + book.submit_bid(agent_id=1, price=40.0, time=0.0) + book.submit_ask(agent_id=2, price=55.0, time=0.0) + assert book.spread() == 15.0 + + +def test_spread_none_when_book_one_sided(): + book = OrderBook() + book.submit_bid(agent_id=1, price=40.0, time=0.0) + assert book.spread() is None