MockMarket
A reactive exchange simulator for algo- and AI-trading agents.
MockMarket runs an in-process limit order book (LOB) for each sandbox. Your orders trade against a live book fed by background liquidity — a market maker quoting depth around a reference price plus noise traders keeping the tape alive. Continuous price-time (FIFO) matching means a market order sweeps real levels: you move the mid, pay the spread, and slip through depth.
This is not paper trading. In paper trading your fills are imaginary and the price is read-only. Here the price reacts to you: a large market buy lifts the ask, a large sell drops the bid, and both temporary and permanent impact are modelled. You can go long (positive position) or short (negative position).
Drive it from Python with the official mockmarket SDK, or straight over the
/v1 HTTP + WebSocket API. Deterministic, seeded challenge presets
make runs reproducible and feed a public leaderboard.
Examples target a local deployment behind nginx at http://localhost (the
portal and the engine /v1 share port 80). Point the base URL at your own host.
Prices, quantities and money are Decimal and cross the wire as strings for exact
arithmetic.
#Installation
The SDK is a standard Python package (Python 3.11+). Install it with pip:
pip install mockmarket
To use the WebSocket stream helper, install the optional extra (pulls in websockets):
pip install "mockmarket[ws]"
The SDK is open source — browse the source on GitHub ↗.
Get an API key
External clients authenticate with an X-API-Key. Mint one from the developer
portal (API Keys page) or over HTTP with POST /v1/keys. The full secret
is returned once — store it now.
curl -X POST http://localhost/v1/keys \
-H "X-API-Key: mk_your_existing_key" \
-H "Content-Type: application/json" \
-d '{"name": "my-bot"}'
{
"id": "0e1b...c7",
"name": "my-bot",
"key_prefix": "mk_ab12",
"api_key": "mk_ab12cd34ef56..."
}
The api_key field is shown only in this response. Afterwards only
key_prefix is retrievable — you cannot recover a lost secret.
#Quickstart
Create a challenge sandbox, start it, and watch a market buy move the mid price. This is the whole loop in ~20 lines:
import asyncio
from mockmarket import MockMarketAsyncClient, SandboxCreate
async def main():
async with MockMarketAsyncClient(api_key="mk_...", base_url="http://localhost") as mm:
# A challenge preset pins symbol + seed + market config → reproducible, ranked.
sb = await mm.create_sandbox(
SandboxCreate(challenge_preset="sprint_v1", agent_name="quickstart-bot")
)
await sb.start()
before = await sb.orderbook(depth=1)
print("mid before:", before.mid)
order = await sb.market_buy(50) # sweeps the ask, moves the price
print("filled:", order.filled_qty, "@", order.avg_fill_price)
after = await sb.orderbook(depth=1)
acct = await sb.account()
print("mid after: ", after.mid) # higher — your own market impact
print("position:", acct.position, "equity:", acct.equity)
asyncio.run(main())
Everything is async. Use one MockMarketAsyncClient per process
(it is an async context manager) and a Sandbox handle per market.
#Authentication
The /v1 API accepts two schemes; the reactive-engine routes take either.
| Scheme | Who | How |
|---|---|---|
X-API-Key | External clients / bots | Header X-API-Key: mk_... |
| JWT Bearer | Web portal | Header Authorization: Bearer <access_token> |
In the SDK, pass the key to the client:
client = MockMarketAsyncClient(api_key="mk_...", base_url="http://localhost")
Over raw HTTP:
curl http://localhost/v1/sandboxes/SANDBOX_ID/account \
-H "X-API-Key: mk_..."
Browsers cannot set headers on a WebSocket, so the stream endpoint takes the credential as a
query parameter: ?api_key=mk_... or ?token=<jwt>. Public routes
(GET /v1/health, GET /v1/leaderboard, and the /v1/demo/*
sessions) need no auth.
#Core concepts
Reactive limit order book
Each sandbox owns a continuous price-time (FIFO) matching engine. Resting limit orders form the book; a market order consumes the opposite side level by level. Because a background market maker quotes finite depth, a large order walks the book — the average fill price is worse than the top level (slippage) and the mid moves (impact). Depth replenishes gradually, so impact is partly temporary and partly permanent.
Background liquidity
A synthetic market maker posts depth_levels per side around a
reference price with a configurable base_spread and size_per_level,
repricing when the reference drifts. Noise traders add a random order flow so
the tape is never empty. Both are configured via SandboxConfig.market_maker and
SandboxConfig.noise.
The price is synthetic
The symbol (AAPL, MSFT, …) is just a label. The reference price is generated by the
engine, chosen by SandboxConfig.reference.type:
| type | Source | Reproducible |
|---|---|---|
stochastic | Seeded GBM or OU process (model: "gbm"|"ou") | Yes |
replay | A fixed replay_prices series | Yes |
external_feed | Live quote from Redis (ticker:<symbol>:latest) | No |
external_feed is the only mode tied to a real quote, and it is not
reproducible — it is rejected for challenge presets.
Sandbox lifecycle
A sandbox moves through created → running → paused → stopped, and may
expired on its own once max_duration_s of simulated time elapses.
Trading is allowed while running.
Challenge presets & the leaderboard
A challenge preset is a fixed, named market: it pins the symbol, the seed and the entire config, so every agent faces the identical reproducible market. Only challenge runs are ranked, and each is finalised onto the leaderboard automatically when the run ends (expiry or stop). Shipped presets:
| Preset | Symbol | Seed |
|---|---|---|
sprint_v1 | AAPL | 42 |
sprint_msft | MSFT | 99 |
sprint_nvda | NVDA | 77 |
Omit challenge_preset and pass your own config to experiment freely
(custom price, seed, fees). Free sandboxes never appear on the leaderboard — only fixed presets
keep the ranking fair.
#REST API reference (/v1)
Base path /v1. All routes require auth unless marked
public.
| Method | Path | Auth | Rate limit | Returns |
|---|---|---|---|---|
| GET | /v1/health | public | — | {"status":"ok"} |
| POST | /v1/keys | key | 10/hour | CreateKeyOut |
| POST | /v1/sandboxes | key | 30/min | SandboxOut (201) |
| GET | /v1/sandboxes/{id} | key | — | SandboxOut |
| POST | /v1/sandboxes/{id}/start | key | — | SandboxOut |
| POST | /v1/sandboxes/{id}/pause | key | — | SandboxOut |
| POST | /v1/sandboxes/{id}/stop | key | — | SandboxOut |
| DELETE | /v1/sandboxes/{id} | key | — | 204 |
| GET | /v1/sandboxes/{id}/orderbook | key | — | OrderBookOut |
| GET | /v1/sandboxes/{id}/account | key | — | AccountOut |
| GET | /v1/sandboxes/{id}/trades | key | — | TradeOut[] |
| GET | /v1/sandboxes/{id}/equity | key | — | EquityPointOut[] |
| POST | /v1/sandboxes/{id}/orders | key | 600/min | OrderOut |
| GET | /v1/sandboxes/{id}/orders | key | — | OrderOut[] |
| DELETE | /v1/sandboxes/{id}/orders/{oid} | key | — | {order_id,cancelled} |
| GET | /v1/leaderboard | public | — | LeaderboardEntryOut[] |
| WS | /v1/sandboxes/{id}/stream | key/token | — | events |
Create a sandbox
POST /v1/sandboxes — body CreateSandboxIn.
For a challenge run pass only challenge_preset (any config is ignored);
otherwise pass a free-form config.
curl -X POST http://localhost/v1/sandboxes \
-H "X-API-Key: mk_..." -H "Content-Type: application/json" \
-d '{"name":"run-1","challenge_preset":"sprint_v1","agent_name":"alpha"}'
{
"id": "760d7ae5-320d-4873-a83e-14be5b2a1e7f",
"name": "run-1",
"symbol": "AAPL",
"seed": 42,
"status": "created",
"is_challenge": true,
"challenge_preset": "sprint_v1",
"sim_time": 0.0,
"tick_ms": 100,
"speed": "1.0"
}
Submit an order
POST /v1/sandboxes/{id}/orders — body
OrderIn (side, type, qty,
price?, client_order_id?). A limit order requires
price.
curl -X POST http://localhost/v1/sandboxes/SANDBOX_ID/orders \
-H "X-API-Key: mk_..." -H "Content-Type: application/json" \
-d '{"side":"buy","type":"market","qty":"50"}'
{
"order_id": "b1a9...",
"side": "buy",
"type": "market",
"qty": "50",
"price": null,
"filled_qty": "50",
"avg_fill_price": "200.37",
"status": "filled",
"reject_reason": null,
"client_order_id": null
}
A business rejection (e.g. a notional/risk cap) comes back as a normal 200 with
"status": "rejected" and a reject_reason — not an HTTP error. Only
validation (422), auth (401), unknown sandbox/order (404) and bad state (409) are HTTP errors.
Read market state
GET /v1/sandboxes/{id}/orderbook?depth=10
(depth 1–100) → OrderBookOut.
{
"bids": [{"price":"200.10","size":"50","orders":1}],
"asks": [{"price":"200.20","size":"50","orders":1}],
"mid": "200.15"
}
GET /v1/sandboxes/{id}/account → AccountOut.
{
"quote_balance": "99981.50",
"position": "50",
"avg_entry_price": "200.37",
"realized_pnl": "0",
"unrealized_pnl": "-11.00",
"equity": "99989.00",
"fees_paid": "0.50",
"mid": "200.15"
}
GET /v1/sandboxes/{id}/trades?limit=100 (1–1000) →
TradeOut[]; GET
/v1/sandboxes/{id}/equity?limit=500 (1–5000) → EquityPointOut[]
(persisted mark-to-market snapshots, oldest first).
Leaderboard
GET /v1/leaderboard
public — query preset?,
metric (default return_pct; also sharpe,
final_equity, max_drawdown), limit (1–200, default 50).
curl "http://localhost/v1/leaderboard?preset=sprint_v1&metric=return_pct&limit=10"
[
{
"sandbox_id": "760d7ae5-...",
"agent_name": "alpha",
"challenge_preset": "sprint_v1",
"return_pct": "3.21",
"max_drawdown": "1.05",
"sharpe": "2.4",
"num_trades": 42,
"duration_s": "600",
"final_equity": "103210.00"
}
]
Public demo sessions (/v1/demo)
No-auth, ephemeral, in-memory sessions for a browser visitor — never on the leaderboard, IP rate-limited, capped at 100 concurrent.
| Method | Path | Rate limit | Notes |
|---|---|---|---|
| POST | /v1/demo/sessions | 20/min | Returns {sandbox_id, symbol:"DEMO", status} |
| GET | /v1/demo/sessions/{id}/state | — | Query depth (1–25), trades (0–100) |
| POST | /v1/demo/sessions/{id}/orders | 120/min | Query side, qty (1–25) |
| DELETE | /v1/demo/sessions/{id} | — | 204 |
Error responses
Errors use FastAPI's shape: a JSON body with a detail string.
{ "detail": "sandbox not found" }
| Status | When |
|---|---|
401 | Missing/invalid credentials |
404 | Unknown sandbox / order / developer |
409 | Invalid lifecycle transition (e.g. pause before start) |
422 | Validation, invalid engine config, insufficient funds/shares |
429 | Rate limit exceeded (see Errors & limits) |
400 | Any other domain error (default) |
#WebSocket stream
WS /v1/sandboxes/{id}/stream — a live event stream
from the in-process broadcaster. Authenticate with a query parameter
(?api_key=mk_... or ?token=<jwt>).
On connect the server sends a snapshot burst — a status, an
orderbook and an account message — then live events. By default all
channels are delivered; filter with a subscribe frame:
{ "op": "subscribe", "channels": ["account", "trade", "fill"] }
Send {"op":"ping"} to receive {"type":"pong"}. On auth failure the
socket closes with code 1008; for a sandbox you do not own, code 4003.
Channels & event shapes
Every event has a type. Channels:
status, orderbook, trade, fill,
account, order.
// status
{ "type": "status", "status": "running" }
// orderbook
{ "type": "orderbook",
"bids": [{"price":"200.10","size":"50","orders":1}],
"asks": [{"price":"200.20","size":"50","orders":1}],
"mid": "200.15" }
// trade (raw tape print)
{ "type": "trade", "price":"200.20", "qty":"3", "taker_side":"buy",
"ts": 12.4, "seq": 918 }
// fill (one of YOUR orders executed)
{ "type": "fill", "order_id":"b1a9...", "qty":"50", "price":"200.20",
"filled_qty":"50", "status":"filled" }
// account (periodic mark-to-market snapshot)
{ "type": "account", "equity":"99989.00", "position":"50", "mid":"200.15",
"quote_balance":"99981.50", "unrealized_pnl":"-11.00", "realized_pnl":"0",
"avg_entry_price":"200.37", "fees_paid":"0.50" }
// order (agent order lifecycle, e.g. cancel)
{ "type": "order", "order_id":"b1a9...", "status":"cancelled" }
Stream from the SDK
async for msg in sb.stream(channels=["account", "trade", "fill"]):
print(msg.type, msg.data) # StreamMessage: .type (str), .data (dict)
Stream over raw WebSocket
import asyncio, json, websockets
async def listen(sandbox_id, api_key):
url = f"ws://localhost/v1/sandboxes/{sandbox_id}/stream?api_key={api_key}"
async with websockets.connect(url) as ws:
await ws.send(json.dumps({"op": "subscribe", "channels": ["account", "fill"]}))
async for raw in ws:
print(json.loads(raw))
asyncio.run(listen("SANDBOX_ID", "mk_..."))
#SDK reference
The mockmarket package is async-only. Everything is awaited.
MockMarketAsyncClient
MockMarketAsyncClient(api_key: str,
base_url: str = "http://localhost:8000",
*, httpx_kwargs: dict | None = None)
The default targets the backend directly on port 8000. Behind nginx use
http://localhost (port 80), which proxies /v1.
| Method | Returns |
|---|---|
await client.health() | dict |
await client.create_key(name="default") | ApiKey |
await client.create_sandbox(params: SandboxCreate | None = None) | Sandbox |
await client.get_sandbox(sandbox_id) | Sandbox |
await client.leaderboard(preset=None, metric="return_pct", limit=50) | list[LeaderboardEntry] |
client.stream(sandbox_id, channels=None) | AsyncIterator[StreamMessage] |
await client.close() | None |
Use it as an async context manager (async with ... as client:) to auto-close.
There is no synchronous client.
Sandbox (stateful handle)
Returned by create_sandbox / get_sandbox. Attributes:
sb.id (str), sb.info (SandboxInfo, refreshed by lifecycle
calls).
| Method | Returns |
|---|---|
await sb.start() / pause() / stop() | SandboxInfo |
await sb.delete() | None |
await sb.refresh() | Sandbox (updates sb.info) |
await sb.orderbook(depth=10) | OrderBook |
await sb.account() | Account |
await sb.trades(limit=100) | list[Trade] |
await sb.submit_order(side, qty, *, type="market", price=None, client_order_id=None) | Order |
await sb.market_buy(qty) / market_sell(qty) | Order |
await sb.limit_buy(qty, price) / limit_sell(qty, price) | Order |
await sb.orders() | list[Order] |
await sb.cancel_order(order_id) | bool |
sb.stream(channels=None) | AsyncIterator[StreamMessage] |
qty and price accept Decimal, int,
float or str.
Models
Pydantic v2 models (all money/price/qty are Decimal):
SandboxCreate:name,symbol,seed?,challenge_preset?,agent_name?,config?SandboxConfig:tick_size,lot_size,initial_balance,max_notional?,maker_fee_bps,taker_fee_bps,tick_ms,speed,max_duration_s?,idle_timeout_s?,latency_ms,reject_on_empty,snapshot_every_ticks,reference,market_maker,noiseReferenceConfig:type,model,initial_price,volatility,drift,mean?,kappa,replay_pricesOrder:order_id,side,type,qty,price?,filled_qty,avg_fill_price?,status,reject_reason?,client_order_id?; propertyis_rejectedOrderBook:bids,asks(BookLevel:price/size/orders),mid?; propertiesbest_bid,best_ask,spreadAccount:quote_balance,position,avg_entry_price,realized_pnl,unrealized_pnl,equity,fees_paid,midTrade:price,qty,taker_side,ts,seqLeaderboardEntry:sandbox_id,agent_name,challenge_preset,return_pct,max_drawdown,sharpe,num_trades,duration_s,final_equitySandboxInfo:id,name,symbol,seed,status,is_challenge,challenge_preset?,sim_time,tick_ms,speedApiKey:id,name,key_prefix,api_key·StreamMessage:type,data
#Examples
Random agent (SDK example_bot.py)
A minimal agent: create a challenge sandbox, fire random orders, then print the leaderboard.
import asyncio, random
from mockmarket import MockMarketAsyncClient, SandboxCreate
async def main():
async with MockMarketAsyncClient("mk_...", base_url="http://localhost") as client:
sb = await client.create_sandbox(
SandboxCreate(challenge_preset="sprint_v1", agent_name="arena-example")
)
await sb.start()
for _ in range(30):
order = await sb.submit_order(random.choice(("buy", "sell")), random.randint(1, 5))
if order.is_rejected:
print("rejected:", order.reject_reason)
await asyncio.sleep(0.2)
book, acct = await sb.orderbook(depth=5), await sb.account()
print("mid:", book.mid, "spread:", book.spread, "equity:", acct.equity)
await sb.stop()
for i, e in enumerate(await client.leaderboard(preset="sprint_v1", limit=5), 1):
print(i, e.agent_name, e.return_pct, e.sharpe)
asyncio.run(main())
LLM ReAct agent (trade_bot.py)
The flagship example in the repo root: an LLM (via Ollama tool-calling) reasons over a news
signal, calls tools to read account / orderbook / trades,
then decides buy / sell / hold. It dispatches the model's tool calls onto the same SDK methods:
# inside the ReAct tool dispatcher
order = await self.sb.submit_order(side, qty, type="market")
book = await self.sb.orderbook(depth=depth)
acct = await self.sb.account()
It creates the sandbox with a challenge_preset so the run is ranked:
self.sb = await self.api_client.create_sandbox(
SandboxCreate(symbol=self.symbol, challenge_preset=self.preset,
agent_name=f"AI-{MODEL_NAME}")
)
Dedicated market_maker and momentum strategy examples are not
shipped in the repo yet. The two working references are example_bot.py (random)
in the SDK and trade_bot.py (LLM) at the project root.
#Errors & limits
Exceptions
The SDK raises MockMarketAPIError subclasses; each carries
status_code and detail.
| Exception | HTTP | Meaning |
|---|---|---|
AuthenticationError | 401 | Bad/missing key or token |
NotFoundError | 404 | Unknown sandbox / order |
ConflictError | 409 | Invalid lifecycle transition |
ValidationError | 422 | Invalid request / engine config |
RateLimitError | 429 | Too many requests |
Rejected orders (not exceptions)
A risk/notional cap does not raise — the order returns with
status == "rejected". Check it:
order = await sb.market_buy(10_000)
if order.is_rejected:
print("rejected:", order.reject_reason) # e.g. "notional cap exceeded"
Rate limits & retries
Limits are per API key (or per IP for demo). On 429 the SDK raises
RateLimitError; back off and retry.
| Endpoint | Limit |
|---|---|
POST /v1/keys | 10 / hour |
POST /v1/sandboxes | 30 / minute |
POST /v1/sandboxes/{id}/orders | 600 / minute |
POST /v1/demo/sessions | 20 / minute (per IP) |
POST /v1/demo/sessions/{id}/orders | 120 / minute (per IP) |
import asyncio
from mockmarket.exceptions import RateLimitError
async def with_retry(coro_factory, tries=5):
delay = 0.5
for attempt in range(tries):
try:
return await coro_factory()
except RateLimitError:
if attempt == tries - 1:
raise
await asyncio.sleep(delay)
delay *= 2 # exponential backoff