← MockMarket
MockMarket

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.

Conventions

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..."
}
Warning

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())
Tip

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.

SchemeWhoHow
X-API-KeyExternal clients / bots Header X-API-Key: mk_...
JWT BearerWeb 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_..."
WebSocket auth

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:

typeSourceReproducible
stochasticSeeded GBM or OU process (model: "gbm"|"ou")Yes
replayA fixed replay_prices seriesYes
external_feedLive quote from Redis (ticker:<symbol>:latest)No
Note

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:

PresetSymbolSeed
sprint_v1AAPL42
sprint_msftMSFT99
sprint_nvdaNVDA77
Tip

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.

MethodPathAuthRate limitReturns
GET/v1/healthpublic{"status":"ok"}
POST/v1/keyskey10/hourCreateKeyOut
POST/v1/sandboxeskey30/minSandboxOut (201)
GET/v1/sandboxes/{id}keySandboxOut
POST/v1/sandboxes/{id}/startkeySandboxOut
POST/v1/sandboxes/{id}/pausekeySandboxOut
POST/v1/sandboxes/{id}/stopkeySandboxOut
DELETE/v1/sandboxes/{id}key204
GET/v1/sandboxes/{id}/orderbookkeyOrderBookOut
GET/v1/sandboxes/{id}/accountkeyAccountOut
GET/v1/sandboxes/{id}/tradeskeyTradeOut[]
GET/v1/sandboxes/{id}/equitykeyEquityPointOut[]
POST/v1/sandboxes/{id}/orderskey600/minOrderOut
GET/v1/sandboxes/{id}/orderskeyOrderOut[]
DELETE/v1/sandboxes/{id}/orders/{oid}key{order_id,cancelled}
GET/v1/leaderboardpublicLeaderboardEntryOut[]
WS/v1/sandboxes/{id}/streamkey/tokenevents

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
}
Rejected orders are 200

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}/accountAccountOut.

{
  "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.

MethodPathRate limitNotes
POST/v1/demo/sessions20/minReturns {sandbox_id, symbol:"DEMO", status}
GET/v1/demo/sessions/{id}/stateQuery depth (1–25), trades (0–100)
POST/v1/demo/sessions/{id}/orders120/minQuery 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" }
StatusWhen
401Missing/invalid credentials
404Unknown sandbox / order / developer
409Invalid lifecycle transition (e.g. pause before start)
422Validation, invalid engine config, insufficient funds/shares
429Rate limit exceeded (see Errors & limits)
400Any 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)
base_url

The default targets the backend directly on port 8000. Behind nginx use http://localhost (port 80), which proxies /v1.

MethodReturns
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).

MethodReturns
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, noise
  • ReferenceConfig: type, model, initial_price, volatility, drift, mean?, kappa, replay_prices
  • Order: order_id, side, type, qty, price?, filled_qty, avg_fill_price?, status, reject_reason?, client_order_id?; property is_rejected
  • OrderBook: bids, asks (BookLevel: price/size/orders), mid?; properties best_bid, best_ask, spread
  • Account: quote_balance, position, avg_entry_price, realized_pnl, unrealized_pnl, equity, fees_paid, mid
  • Trade: price, qty, taker_side, ts, seq
  • LeaderboardEntry: sandbox_id, agent_name, challenge_preset, return_pct, max_drawdown, sharpe, num_trades, duration_s, final_equity
  • SandboxInfo: id, name, symbol, seed, status, is_challenge, challenge_preset?, sim_time, tick_ms, speed
  • ApiKey: 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}")
)
TODO

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.

ExceptionHTTPMeaning
AuthenticationError401Bad/missing key or token
NotFoundError404Unknown sandbox / order
ConflictError409Invalid lifecycle transition
ValidationError422Invalid request / engine config
RateLimitError429Too 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.

EndpointLimit
POST /v1/keys10 / hour
POST /v1/sandboxes30 / minute
POST /v1/sandboxes/{id}/orders600 / minute
POST /v1/demo/sessions20 / minute (per IP)
POST /v1/demo/sessions/{id}/orders120 / 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