Host handbook
What do you want to do?
Running an exchange, from the first session to backups. Sections name the console page they talk about. Search with /, or start from one of these.
Overview
Voltrade runs as a FastAPI async server with a single Python event loop, hosting any number of independent exchanges at once — each with its own matching engine, bot fleet, and persisted state. Every exchange you create gets its own scoped admin console at /exchanges/<exchange_id>/admin, reachable via the Admin link on your dashboard. There, you control that exchange's market state, participants, and instrument availability. Bots launch automatically when the exchange is created.
Console Layout
The console is built around one question: is the session running the way I meant it to? What you need at every moment sits in a fixed header. Everything else is in a side menu, grouped by job.
The header
| Part | What it does |
|---|---|
| State switch | Pre-open, Open, Halted or Closed, with a coloured dot for the current state. Closing asks you to confirm, because the standings freeze as the final result. |
| Timer | Counts down the session timer. It turns amber under 3 minutes and red under 1. It reads paused while the session is paused, and auto-close on when the market will close at zero. Click it to open the session plan. |
| Online count | “N of M online”: traders connected now, out of everyone who has joined. |
| Open terminal ↗ | Opens your own trading terminal in a new tab. The console no longer embeds it. |
| Theme | Switches between dark and light. The terminal and the ranks page use the same choice. |
Where each section lives
| Group | Page | Contents |
|---|---|---|
| Run | Overview | KPI strip (online, orders per minute, volume, leader), the top 8 traders with Adjust and Kick, quick scenarios and shocks, the invite link, and Up next from the plan. |
| Session plan | Timeline, queue of scheduled events, add form, timer, templates and builder. See Session Plan. | |
| News | Publish a headline now, browse the news catalogue, and see rumours that have not resolved yet. | |
| Scenarios & shocks | Scenario catalogue and one-off shocks. | |
| People | Traders | Every human trader with cash, equity, P&L, open positions and orders, a cash/equity editor and Kick. The menu entry shows the trader count. |
| Standings | Replaces the old Leaderboard and Summary tabs. See Standings & Present. | |
| Rules & limits | Default starting equity and position limits. | |
| Market | Instruments | Turn single instruments, or whole types, on and off. |
| Market health | Book depth and spreads. A dot appears on the menu entry when some books are empty or have wide spreads. It is checked every 15 seconds. | |
| Analytics | Live charts of order flow and P&L. | |
| Tools | Settings | Exchange settings, shock randomness, and the resets (all human P&L, fair values). |
Each page has its own address (#plan, #standings and so on), so you can bookmark a page and the browser's back button works. While the console's browser tab is hidden, it stops polling the server.
Session Plan
The plan page shows the whole session on one timeline. A now line splits it in two. Events that already happened (taken from the news feed) are on the left. Scheduled events are on the right, drawn as diamonds coloured by kind:
- Shock: the headline starts with ⚡.
- Rumour: credibility is under 100%.
- News: anything else.
While a timer runs, the line spans the whole session. With no timer, it starts at now and stretches to fit the last event, and it always covers at least 30 minutes.
Changing the queue
| Action | Effect |
|---|---|
| Release now | Fires the event within a second. Its shocks, drift and credibility stay as they were. |
| Delay | Pushes the event back 5 minutes. |
| Skip | Removes the event from the queue. |
Adding an event
The add form asks for:
- a kind: news, rumour or shock;
- the headline;
- the affected tickers and the size of the move;
- credibility, for rumours only;
- when it should fire, in minutes from now.
A shock is saved as a ⚡ headline with its moves attached, so traders see it in their news feed like any other event. The templates and the session builder sit in folded sections under the form, and each one fills the queue in a single step.
Authentication
The admin console uses the same account system as trading: log in at /login, and your session's JWT (stored in localStorage under voltrade-auth-token) is sent as Authorization: Bearer <jwt> on every admin request. There is no separate admin token or password for the scoped console.
Whether you're allowed to act on a given exchange is a server-side check, not just a hidden button: every admin route looks up your membership row for that exchange and requires role = "host", rejecting with 403 otherwise. You become the host of an exchange automatically by creating it.
PLATFORM_ADMIN_EMAILS) are exempt, and the limit itself is set by MAX_HOSTED_EXCHANGES (0 disables it). Note that an exchange only consumes that capacity while somebody is actually connected to it — see Hibernation.
Market State
The exchange can be in one of four states:
| State | Order submission | Matching | Bots |
|---|---|---|---|
| PRE_OPEN | Rejected | Off | Running (no orders) |
| OPEN | Accepted | Active | Active |
| HALTED | Rejected | Off (book frozen) | Running (no orders) |
| CLOSED | Rejected | Off | Running (no orders) |
The typical session flow: start at PRE_OPEN → let participants join → OPEN → trade → optionally HALT → CLOSE. Return to PRE_OPEN to start a new round without restarting the server (participant PnLs persist unless you use Reset).
Participant Management
Viewing Traders
The Traders page lists every human trader. Bots are hidden to reduce noise. Each row has a connection dot, display name, participant ID, cash, equity, P&L, open positions and orders, plus controls to set cash or equity and to kick the trader. Overview shows the top 8, each with Kick and an Adjust link to the Traders page.
Kicking a Participant
Click Kick on any trader row and confirm. This:
- Cancels all their open resting orders across every instrument first, so nothing keeps sitting on the book after they're gone
- Deletes them from the participant registry
- Force-closes their live WebSocket connection
- Broadcasts an updated snapshot to all remaining clients
Resetting PnLs
Reset all human P&L (Settings → Reset and delete) clears cash, positions, and all PnL counters for every non-bot participant. Bots are unaffected. The market does not need to be closed first.
Standings & Present
The Standings page shows the ranking traders see on the public ranks page, plus host tools: a Present button, a link to the public page, and CSV downloads (trades, positions, summary). Bots never appear.
You can rank by P&L, Return % or Equity. Return % divides P&L by starting cash, which keeps the ranking fair after you have topped someone up. This browser remembers your choice. Each row has:
- a sparkline of the trader's P&L over the last 10 minutes, sampled every 15 seconds while the market is open;
- an arrow showing places gained or lost in the last 5 minutes, worked out under the ranking you chose.
When the market closes, the standings freeze as the final result, without sparklines or arrows.
Present
Present opens the ranks page full screen in projector mode, with type large enough to read from the back of a room. It shows:
- the top of the table;
- the session clock;
- the biggest climber;
- the last headline;
- a “Join at” address, if you have made an invite link.
Put it on the classroom screen and leave it; it updates by itself. Press Esc to leave projector mode.
Equity Management
Default Starting Equity
The Default starting equity field on the Rules & limits page sets the total equity assigned to every new human participant on first connect (all as cash, with no open positions). The default is $1,000,000.
| Control | Effect |
|---|---|
| SET (checkbox off) | Updates the server-side default. New participants from this point forward start with the new amount. Existing participants are not changed. |
| SET with "also rebase every current player's baseline" checked | Rebases every currently registered human's starting cash to the new amount by shifting their cash by the delta — positions aren't touched, so each player's P&L-to-date and standing relative to others carry over unchanged. This is a shift, not a reset: nobody's gains or losses are erased. |
Adjusting an Individual Trader's Cash or Equity
Each human trader row has a mode selector (Cash / Equity), an amount field, and a Set button:
| Mode | Effect |
|---|---|
| Cash | Sets the participant's cash balance directly to the entered amount. Open positions are untouched, so total equity moves by exactly the cash change. |
| Equity | Sets the participant's total equity to the entered amount by solving for the cash needed, given their current mark-to-market position value: new_cash = target_equity − position_value. Positions themselves are left completely unchanged. |
Use either to top up a participant who's lost significant capital mid-session, level the playing field after an unusual run, or apply a penalty for rule violations. The adjustment is reflected immediately and in the next snapshot broadcast to all clients.
Position Limits
The Position Limits card in the Admin Console lets you cap the maximum net exposure human participants can hold per instrument. Bots are never subject to position limits.
Enabling / Disabling
The ON/OFF toggle next to the card header enables or disables all position limit enforcement globally. When limits are OFF, humans can accumulate unlimited positions (the default). Turning them ON immediately enforces the configured limits — any existing positions larger than the limit are not forced-closed, but no new orders that would widen them will be accepted.
Global Default Limit
Enter a lot count in the Global limit field and click APPLY GLOBAL. This sets the default ± cap applied to any instrument that does not have a per-instrument override. Set to 0 to remove the global cap (instruments without an override become uncapped).
Per-Instrument Overrides
The instrument grid shows every instrument with an editable limit field. Enter a value and click out of the field (or press Enter) to override the global default for that instrument specifically. Set to 0 to remove the override and fall back to the global limit.
| Scenario | Result for DRFT |
|---|---|
| Limits OFF | Unlimited positions for all humans |
| Limits ON, global = 50, no DRFT override | Humans capped at ±50 lots of DRFT |
| Limits ON, global = 50, DRFT override = 20 | Humans capped at ±20 lots of DRFT only |
| Limits ON, global = 50, DRFT override = 0 | Global cap applies: ±50 lots |
| Limits ON, global = 0, no DRFT override | DRFT is uncapped; global limit is disabled |
When a human submits an order that would breach their limit, the order is rejected with a message such as position limit exceeded: max +50 (currently +48). The rejection appears in their Python terminal as a red error.
Instrument Management
The Instruments tab in the Admin Console shows every instrument grouped by type (STOCK, INDEX, FUTURE, ETF, OPTION) with a checkbox for each.
Disabling an Instrument
Unchecking an instrument:
- Freezes fair value — the FairValueEngine skips it every tick
- Halts bot activity — all bots skip disabled instruments via
_active_instruments() - Rejects new orders — submit returns
"instrument disabled" - Preserves existing book orders — resting bids/asks remain and can be cancelled by their owners
For derivatives (futures, options, ETFs), the underlying's fair value continues to update. If you later re-enable the instrument its fair value will re-derive from the current underlying price on the next tick.
Type-Level Toggle
Each instrument group — STOCK, INDEX, ETF, COMMODITY, FUTURE, and options split into OPTIONS (30D) / OPTIONS (0DTE) buckets by expiry — has ENABLE ALL / DISABLE ALL buttons affecting every instrument in that group at once. Useful for disabling all 0DTE options during a session without clicking each one.
Configuration
Environment Variables (set at server start)
| Variable | Default | Effect |
|---|---|---|
EXCHANGE_TICK | 0.5 | Tick interval in seconds. Smaller = faster fair-value updates and more bot activity. Do not go below 0.05 (CPU bound). |
A unique SESSION_ID (12-char UUID prefix) is generated each server restart and sent to every connecting client in the welcome message. Clients compare this against their cached session and clear stale order state if it differs.
Runtime Settings (configurable via Admin Console)
| Setting | Default | Where |
|---|---|---|
| Default starting equity | $1,000,000 | Participant Management card → SET |
| Position limits on/off | OFF | Position Limits card → ENABLE/DISABLE LIMITS |
| Global position limit | 0 (uncapped) | Position Limits card → Default field → SET ALL INSTRUMENTS |
| Per-instrument limits | None (use default) | Position Limits card → per-instrument grid |
Hibernation
An exchange that nobody is connected to costs nothing to keep open. After 5 minutes with no connected client and no admin activity, the exchange takes a final snapshot to the database and is torn down completely — bots, tick loop, order books, the lot. It stays OPEN as far as everyone is concerned: it keeps its slot in Browse Public, its members, its positions and its session timer. The moment anyone opens it again it is rebuilt from the database, and the time it spent asleep is replayed in a single jump.
This is what lets an exchange stay open for days or weeks. Cost tracks how much an exchange is used, not how long it has existed.
What happens on wake
Rather than replaying every skipped tick — a week asleep is over a million ticks — the price engine jumps straight to the distribution it would have reached. Both drivers have exact closed-form solutions over an arbitrary horizon (geometric Brownian motion for the instruments, an Ornstein–Uhlenbeck process for the shared market factor), so bridging a month-long gap costs the same as bridging one second: about 2 ms for all 182 instruments. Validation against actually iterating the ticks puts the resulting spread within 0.92–1.09× of the real thing in standard mode.
Prices therefore will have moved when you come back, exactly as if the market had been running. Positions, cash, memberships, resting human orders and candle history are all restored untouched; bot orders are not (the fleet is rebuilt fresh and re-quotes immediately).
Session timers still fire
A competition that ends at 3am closes at 3am even if everyone went home at 10pm. A single platform-wide sweeper checks for sleeping exchanges whose timer has expired and closes them in the database, freezing the final leaderboard from persisted state. It never wakes anything to do it.
Leaderboards while asleep
Requesting the leaderboard of a sleeping exchange does not wake it. If the competition already finished, the frozen results are returned; otherwise standings are computed directly from the database. One difference worth knowing: a sleeping exchange has no order books, so positions are marked to fair value rather than to the mid of a live book. Expect small discrepancies against the live figure on wide-spread instruments.
| Variable | Default | Effect |
|---|---|---|
IDLE_SLEEP_SECONDS | 300 | Seconds with no connected client and no admin activity before an exchange hibernates. 0 disables hibernation entirely — every exchange stays resident forever. |
MAX_FASTFORWARD_SECONDS | 14400 | Ceiling on how much market time a single wake replays (4 hours). A month-long sleep still only jumps 4 hours of drift, so prices don't run away while nobody is trading. |
SLEEP_SWEEP_SECONDS | 30 | How often the platform checks for sleeping exchanges whose session timer has expired. |
Hibernation composes with the idle throttle above: the throttle handles the first few minutes after the last person leaves, hibernation takes over once they clearly aren't coming straight back.
Backups & Data Safety
All state — accounts, exchanges, memberships, positions, saved code, feedback — lives in one SQLite database on the server's volume. Two mechanisms protect it: a scheduled off-site copy that runs by itself, and a manual download you can take at any moment.
Scheduled off-site backups
When object storage is configured, the server uploads a fresh snapshot every BACKUP_INTERVAL_HOURS (24 by default) to an S3-compatible bucket — Cloudflare R2, Backblaze B2, AWS S3 — and prunes to the newest BACKUP_RETENTION copies. It runs in a background loop alongside the tick and persist loops, and skips an upload when a snapshot younger than the interval already exists, so a restart does not cost you a redundant copy.
| Variable | Default | Purpose |
|---|---|---|
BACKUP_S3_BUCKET | — | Bucket name. Backups are off entirely until this, the access key and the secret key are all set. |
BACKUP_S3_ACCESS_KEY | — | Access key ID. |
BACKUP_S3_SECRET_KEY | — | Secret access key. |
BACKUP_S3_ENDPOINT | AWS S3 | Endpoint URL for a non-AWS provider (R2, B2). Leave unset for AWS. |
BACKUP_S3_REGION | auto | Region. auto is correct for R2. |
BACKUP_PREFIX | voltrade-backups/ | Key prefix inside the bucket. |
BACKUP_INTERVAL_HOURS | 24 | Hours between uploads. |
BACKUP_RETENTION | 14 | How many snapshots to keep; older ones are deleted after each successful upload. |
POST /admin/backup/upload # run the scheduled backup now, instead of waiting GET /admin/backup/status # is it configured, and how old is the newest snapshot?
The startup banner prints the schedule, or says OFF with the variables you still need to set — check it after any deploy that touches configuration.
Manual download
GET /admin/backup # platform admin only; downloads a .db file
This is available to accounts listed in PLATFORM_ADMIN_EMAILS and returns a complete, consistent snapshot. It uses SQLite's VACUUM INTO rather than copying the file, which matters: copying exchange.db off the volume while the server is running can capture a half-finished write and misses anything still in the write-ahead log, producing a backup that looks fine until you try to restore it.
Watching volume space
GET /admin/storage # size breakdown: live data, reclaimable pages, rows per exchange POST /admin/reclaim # VACUUM — returns free pages to the filesystem
reclaimable_bytes is the figure to watch: pages the purge loop has freed inside the file but not yet returned to the volume. Run /admin/reclaim to hand them back.
VACUUM needs roughly twice the database size in free space, because it rebuilds the file before replacing the original. On a volume that is already tight this can fail precisely when you most need it — which is the argument for reclaiming early rather than waiting for a warning.
Restoring
Stop the service, put the downloaded file where EXCHANGE_DB points, delete any stale -wal / -shm files sitting next to it, then start the service again.
trades to the most recent 5,000 rows per exchange and events to 10,000 (this is what keeps the volume flat). If you need a full record of a session, download the CSVs from the Standings page before the next restart.
User Feedback
Signed-in users can send feedback from the Feedback button in the dashboard top bar, which opens a modal — up to 2,000 characters, tagged General, Bug report, Feature idea or Question. Each submission is written to the feedback table first and emailed afterwards, so a mail outage costs you a notification but never the message itself.
GET /admin/feedback # newest first; ?unread_only=true&limit=200
POST /admin/feedback/{feedback_id}/read # {"read": true|false}
Both are platform-admin only. The same data is in the Feedback inbox tab on the dashboard, which only appears for accounts listed in PLATFORM_ADMIN_EMAILS and carries an unread count on the tab itself. Any item that could not be emailed is marked Not emailed there — that flag is the reason to keep the tab as your source of truth rather than your inbox.
What is captured
Alongside the message: the sender's account ID, display name and email (denormalised onto the row, so feedback stays replyable even if the account is later deleted), the page they submitted from, the exchange they had open and its live state, and their browser's user agent.
Configuration
| Variable | Default | Purpose |
|---|---|---|
FEEDBACK_EMAIL | first entry in PLATFORM_ADMIN_EMAILS | Where notifications are sent. Nothing is emailed if this and PLATFORM_ADMIN_EMAILS are both unset — submissions are still stored. |
MAX_FEEDBACK_CHARS | 2000 | Server-side length cap, enforced independently of the textarea. |
FEEDBACK_RATE_MAX | 5 | Submissions allowed per account per window. |
FEEDBACK_RATE_WINDOW | 3600 | Throttle window in seconds. |
From: stays SMTP_FROM and the submitter's address rides on Reply-To, so replying in your mail client reaches them while the message still passes SPF and DKIM. Sending as the user would fail both and land in spam.
The startup banner prints where notifications are going, or warns that they are going nowhere, so a misconfiguration is visible in the deploy log rather than discovered when feedback goes missing.
Guest Play
Visitors can trade the featured exchange straight from the homepage without an account. The button appears whenever an exchange is featured, whatever its market state: if it is closed, guests can still join and look around, and trade once you reopen it. Unfeature it and guest play switches off; feature another and new guests go there instead.
You feature or unfeature an exchange from its tile under Browse public on the dashboard; only platform admins see that button. The featured exchange also fills the dashboard's top panel and the scrolling ticker tape. Tiles and the tape are priced from in-memory state only, so listing exchanges never wakes a hibernating one: an asleep exchange simply shows no price.
GET /api/guest-exchange # public: {"available": bool, "exchange_id", "name", "state"}
GET /api/public-market # public: live board for the homepage and login page
POST /auth/guest # new guest, or resume the caller's (bearer guest token)
POST /auth/claim # guest → full account: {email, display_name, password}
The homepage's markets section and the login page's side panel read /api/public-market: the featured exchange's state, people online, bot count, last price and change for every index, ETF, stock and commodity, and the latest headline. Like the dashboard tiles, it reads the exchange only if it is already awake; asleep, it returns live: false and both pages fall back to a static description. Polling it never wakes the bot fleet.
A guest is an ordinary users row with is_guest = 1, an undeliverable guest+<id>@guest.invalid address and no usable password. Its participant id is u_<user_id> like any account, which is why claiming keeps every position and trade: the row is converted in place and nothing moves. Guests join the featured exchange as participants regardless of its join mode, since featuring it is the invitation.
What guests cannot do
Use Python (the panel is locked and /api/code returns 403), rename themselves, host or join other exchanges, redeem invites, send feedback, or connect to the legacy default exchange. Hosting is blocked because a deleted guest would orphan its exchange; feedback because there is no address to reply to.
Configuration
| Variable | Default | Purpose |
|---|---|---|
GUEST_TTL_DAYS | 7 | Guests inactive this long are deleted (hourly sweep). Their trades stay in counterparties' history. |
GUEST_ORDER_RATE_MAX | 20 | Orders a guest may submit per window; extras are rejected with RATE_LIMITED. |
GUEST_ORDER_RATE_WINDOW | 10 | That window, in seconds. |
GUEST_RATE_MAX | 20 | New guests per IP per window. |
GUEST_RATE_WINDOW | 3600 | That window, in seconds. |
Guest names are Guest-XXXX, and signup and rename refuse any name starting with “Guest”, so the prefix reliably marks a guest on leaderboards, including frozen final ones.
Python Editor Files
The /pyterm editor stores each account's scripts (.py) and notebooks (.vtnb) in the user_files table, keyed by user id and file name. A notebook is JSON holding cell sources only ({"format": "voltrade-notebook", "version": 1, "cells": [{"type", "source"}]}); outputs are never stored. Code runs in the user's browser, so nothing here executes on the server. Guests cannot use files, and a guest's rows are removed with the guest.
| Endpoint | Purpose |
|---|---|
GET /api/files | The caller's files (name, size, save time), newest first, plus max_files. |
GET /api/files/{name} | One file's content. |
PUT /api/files/{name} | Create or replace, body {"content"}. Shares the per-account save throttle with /api/code. 413 over the size limit, 409 at the file cap. |
POST /api/files/{name}/rename | Body {"name"}. Cannot change .py to .vtnb or back (400); 409 if the target exists. |
DELETE /api/files/{name} | Remove one file. |
Names are letters, numbers, spaces, - _ ., up to 60 characters plus the extension. The old /api/code/{slot} endpoints stay: the inline panel in the trading terminal still uses the dev slot, and the editor copies a non-empty terminal slot into main.py on a user's first visit.
Configuration
| Variable | Default | Purpose |
|---|---|---|
MAX_USER_FILES | 50 | Files per account. Re-saving an existing file is always allowed. |
MAX_NOTEBOOK_BYTES | 262144 | Largest notebook (256 KB). Scripts use the existing 64 KB code limit. |
Console on a Phone
The console stays dense below 820 pixels rather than being cut down — an admin reaching for a phone usually wants to halt a market or fix one participant, not browse. What changes is the shape, not the feature set:
- Form rows stack. The 150-pixel label column would leave nothing for the field, so labels sit above their inputs instead.
- Wide tables scroll sideways rather than wrapping. A ten-column participants table folded onto itself is unreadable; swiping it is not.
- The side menu becomes a drawer behind the menu button in the header. Picking a page closes it.
- The session plan reflows. Queue rows stack, and the timeline keeps its diamonds but hides their labels.
- The metric grid drops to two columns, and the scenario, news and template grids to one.
Both wikis collapse their sidebar into a CONTENTS drawer in the top bar. Tap an entry and the drawer closes behind you; so does tapping outside it, or pressing Esc on a keyboard.
The trader terminal is a genuine phone layout rather than a squeezed desktop one — see On a Phone in the user guide for what your traders see.
All Exchanges (Platform Owner)
Accounts in PLATFORM_ADMIN_EMAILS get an All exchanges tab on the dashboard listing every exchange on the instance — including other people's private ones, which no other endpoint will show you. It is the view for answering “what is actually running on my server, and does anyone use it?”
GET /admin/exchanges
| Field | Meaning |
|---|---|
online | The exchange is live in the registry — holding a bot fleet and its loops in memory, consuming CPU. A row that exists in the database but is not online is hibernating and costs nothing but disk; the dashboard shows it as asleep. It is still open, and it wakes on the next connection. |
clients | How many browsers are connected right now. online with clients: 0 is capacity being spent on nobody — expected for the few minutes before hibernation kicks in, but a row sitting like that indefinitely means hibernation is disabled or something is holding the exchange awake. |
state | Live state from the registry when online, otherwise the last state persisted. |
has_bots | Whether a bot fleet is loaded for it. |
Each row has a delete action, which asks for confirmation before it runs. Deleting removes the exchange and its data for every member, not just for you, so the confirmation is the only thing standing between a misclick and someone else's session.
/admin/* endpoint checks PLATFORM_ADMIN_EMAILS server-side and returns 403 regardless of what the client renders.
Every endpoint below is scoped to one exchange and requires Authorization: Bearer <jwt> for a host of that exchange — there is no token body field on any of these.
REST — State Endpoint
POST /exchanges/{exchange_id}/admin/state
{ "state": "OPEN" | "CLOSED" | "HALTED" | "PRE_OPEN" }
→ { "state": "OPEN" }
REST — Reset Endpoint
POST /exchanges/{exchange_id}/admin/reset
(no body)
→ { "ok": true, "note": "human participant PnLs cleared" }
REST — Instruments Endpoints
# Toggle a single instrument
POST /exchanges/{exchange_id}/admin/instruments
{ "symbol": "DRFT", "enabled": false }
→ { "ok": true, "symbol": "DRFT", "enabled": false }
# Toggle all instruments of a kind
POST /exchanges/{exchange_id}/admin/instruments/type
{ "kind": "OPTION", "enabled": false }
→ { "ok": true, "kind": "OPTION", "enabled": false, "count": 40 }
Valid kind values: STOCK, INDEX, ETF, COMMODITY, FUTURE, OPTION.
REST — Participants Endpoint
POST /exchanges/{exchange_id}/admin/kick
{ "participant_id": "u_abc123" }
→ { "ok": true, "orders_cancelled": 2 }
Cancels every resting order the participant has across all instruments, force-closes their connection, then removes them. Bots cannot be kicked (400).
REST — Equity Endpoints
# Set the default starting equity for new human participants
POST /exchanges/{exchange_id}/admin/set-starting-cash
{ "amount": 500000, "apply_to_existing": false }
→ { "ok": true, "default_human_cash": 500000, "applied_to": 0 }
# apply_to_existing: true -> rebases every current human's starting cash by the
# delta (positions untouched, relative P&L carries over unchanged)
POST /exchanges/{exchange_id}/admin/set-starting-cash
{ "amount": 500000, "apply_to_existing": true }
→ { "ok": true, "default_human_cash": 500000, "applied_to": 4 }
# Set total equity for a specific participant
# Cash is solved so that equity = target; open positions are unchanged
POST /exchanges/{exchange_id}/admin/adjust-equity
{ "participant_id": "alice", "equity": 750000 }
→ { "ok": true, "participant_id": "alice", "equity": 750000, "cash": 712500 }
# Set cash directly for a specific participant — does not account for positions
POST /exchanges/{exchange_id}/admin/adjust-cash
{ "participant_id": "alice", "cash": 750000 }
→ { "ok": true }
REST — Position Limits Endpoint
Scoped to your exchange. Requires Authorization: Bearer <jwt> for a host of this exchange (same auth as every other /exchanges/{exchange_id}/admin/* route).
GET /exchanges/{exchange_id}/admin/position-limits
→ { "enabled": true, "default_limit": 50, "overrides": { "DRFT": 20, "QNTM": 15 } }
POST /exchanges/{exchange_id}/admin/position-limits
{
"enabled": true,
"default_limit": 50, # required each call; 0 = no global cap
"overrides": { # optional; omit or send {} to leave existing overrides untouched
"DRFT": 20,
"QNTM": 15,
"EXMINI-4500-C": 0 # <=0 removes that symbol's override
}
}
→ {
"ok": true,
"enabled": true,
"default_limit": 50,
"overrides": { "DRFT": 20, "QNTM": 15 }
}
overrides values of 0 or below remove that symbol's override (it falls back to default_limit). Each call only touches the symbols present in overrides — existing overrides for other symbols are left alone. The response always echoes back the full current overrides dict.
Pricing — GBM (Stocks & Indexes)
Every tick, each stock and index fair value evolves via log-normal GBM with a shared market factor:
// Z_mkt = shared market factor (OU process); Z_idio = instrument-specific white noise
// dt is measured in trading-year fractions: tick_seconds / (252 × 6.5h × 3600s)
The market factor Z_mkt is an Ornstein–Uhlenbeck process that reverts toward zero, creating realistic sector co-movement without a permanent drift. The 0.4 loading is deliberately small — see the callout below, effective correlation from it is only ~1.4%, not a real "market beta."
| Parameter | Symbol | Typical range |
|---|---|---|
| Annual drift | μ | −0.02 to +0.15 (varies by stock) |
| Annual volatility | σ | 0.40 to 1.00 for stocks (seed vol × a 2.0 multiplier), 0.85 for VOLAX |
| Tick seconds | dt | 0.1s default → ~1.7 × 10⁻⁸ yr/tick |
Realistic Market Dynamics
An optional, more faithful price process, chosen with the Enable Realistic Market Dynamics checkbox when you create an exchange. It is fixed for that exchange's lifetime — it changes the instrument universe's structure (indexes become baskets), not just its dynamics, so it can't be flipped mid-session. Existing and classic exchanges are completely unaffected. Under the hood it replaces the near-independent GBM above with:
| Feature | What it does |
|---|---|
| Factor correlation | Each name's return decomposes into a market factor, a sector factor, and an idiosyncratic part: z = β·Fmkt + γ·Fsec + √(1−β²−γ²)·ε. The whole market breathes together (cross-sector correlation ≈ 0.42, from β·β at the default β≈0.65) and sectors rotate (same-sector ≈ 0.58, adding the sector loading γ). The √(1−β²−γ²) term keeps each instrument's realized vol on its configured level. |
| Safe havens | Gold/silver/platinum carry negative market betas, so they genuinely rally when equities sell off. |
| Basket indexes | EXMINI, TECHX and ENRGX are priced as the live NAV of their constituents (like ETFs), so an index tracks the stocks it contains instead of drifting on its own. |
| Live VOLAX | The volatility index becomes ≈ 100 × the broad market's realized volatility, so it rises with turbulence and spikes on selloffs — a real VIX analogue rather than an unrelated random walk. |
| Volatility regimes | A shared, mean-reverting log-volatility process gives calm and turbulent stretches (vol clustering). A leverage term makes down moves raise volatility more than up moves. |
| Fat tails | Student-t innovations produce the occasional outsized move that Gaussian GBM never does (return kurtosis well above 3). |
| Memory | Some names trend (momentum) and some chop (mean-reversion) via a per-instrument AR(1) on the idiosyncratic shock — a learnable edge without changing average volatility. |
| Lead–lag | A few smaller names follow the market a tick or two late, another discoverable signal. |
Pricing — Futures (Cost of Carry)
Futures fair values are updated every tick from the current spot (underlying fair value):
// S = underlying fair value | r = 0.04 (4% risk-free rate) | T = expiry in years
| Contract | T (years) | Carry premium over spot |
|---|---|---|
Front month -F | 30/365 = 0.0822 | ≈ 0.33% |
Second month -F2 | 60/365 = 0.1644 | ≈ 0.66% |
Third month -F3 | 90/365 = 0.2466 | ≈ 0.99% |
Calendar spread fair value: F3 − F2 ≈ S × (er×T3 − er×T2). This is deterministic given S and r, so any market deviation is arbitrageable. Bots do not specifically trade calendar spreads — participants can exploit them.
Pricing — ETF NAV
ETF fair values are recalculated in pass 2 of the FairValueEngine tick, after stocks are updated in pass 1:
// w_i = basket weight (sums to 1.0) | S_i = component fair value after GBM step
| ETF | Components (equal weight 25% each) | Approx. initial NAV |
|---|---|---|
TECH-ETF | DRFT, QNTM, FNTM, NXUS | ~176 |
ENRG-ETF | CDLR, UMBR, GLDR, YNDR | ~26 |
INDU-ETF | ACME, JNTR, MRVL, SLPH | ~206 |
HLTH-ETF, DFNS-ETF, CONS-ETF, CMDT-ETF | Same equal-weight NAV pattern, over the healthcare, defense, consumer, and commodity baskets respectively | varies |
ETF initial volatility estimate = (weighted avg component vol, including the 2.0× stock vol multiplier) × 0.75 — a diversification discount. This is only used for the tick-size calculation; actual price moves derive from NAV directly.
The ETFArbBot quotes inside mm_spread_bps around live NAV and submits directional orders when market price diverges by more than threshold_bps from NAV.
SNP10 / SNP15 are the two broad-market (non-sector) ETFs, built in build_universe() instead of hand-specified: constituents are the 10 / 15 highest-fair_value stocks (a stand-in for cap), and each component's weight is its own fair_value ÷ Σfair_value of the basket — i.e. cap-weighted, not equal-weighted. SNP10's 10 names are always the top-10-weighted subset of SNP15's 15, so a name's weight rank never flips between the two baskets.
Pricing — Black-Scholes
Options fair values are recalculated every tick in pass 3:
C = S·N(d1) − K·e−rT·N(d2)
// Put price (put-call parity form)
P = K·e−rT·N(−d2) − S·N(−d1)
d1 = [ ln(S/K) + (r + ½σ²)T ] / (σ√T)
d2 = d1 − σ√T
// S = underlying fair_value | K = strike | T = expiry_days/365
// r = 0.04 | σ = skew-adjusted vol (see below) | N = standard normal CDF
Fair values are floored at 0.05 (one tick). No dividends are modelled. Greeks are not computed server-side — participants can compute them from the snapshot via the Python terminal.
Pricing — Volatility Skew
Options on individual stocks use an equity skew to make the vol surface realistic. The vol used in Black-Scholes is adjusted per strike:
// skew = slope term (as before); curvature = smile-curvature term, both per-instrument
// m > 0 when S > K (ITM call / OTM put) → skew term increases vol
// m < 0 when S < K (OTM call / ITM put) → skew term decreases vol
// the curvature × m² term adds convexity — vol rises again further from ATM either direction
// vol is floored at 5% to prevent negative values
This means OTM puts (K < S) get higher implied vol than ATM, and OTM calls (K > S) get lower implied vol from the skew term — matching the negative skew observed in equity markets — while the curvature term pulls both wings back up relative to a pure straight-line skew.
| Scenario (DRFT, S=220, σ=0.80 — note the stock vol multiplier applies here too, curvature=0 for this illustration) | σ_adj |
|---|---|
| K=200 (OTM put, ln(220/200)=0.095) | 0.80 × (1 + 1.0 × 0.095) = 0.876 |
| K=220 (ATM, ln(1)=0) | 0.80 × 1.0 = 0.800 |
| K=240 (OTM call, ln(220/240)=−0.087) | 0.80 × (1 − 0.087) = 0.730 |
skew_adjusted_vol(inst, under) helper in instruments.py is shared between the FairValueEngine and OptionsArbBot — both see the same theoretical vol surface.
Instrument Universe — Stocks
26 equities, GBM process, 0.01 tick size, mm_spread_bps=8. σ below already includes the 2.0× stock volatility multiplier applied at build time (seed vol × 2.0) — this is the real, realized annualized vol, not the raw seed value.
| Symbol | Name | Init. Price | σ (ann., actual) | μ (ann.) |
|---|---|---|---|---|
| ACME | Acme Industrial Corp | 152.40 | 0.56 | 0.05 |
| BRYN | Bryn Biotech | 78.10 | 0.70 | 0.08 |
| CDLR | Candler Energy | 44.55 | 0.44 | 0.02 |
| DRFT | Drift AI Systems | 220.00 | 0.80 | 0.12 |
| ELYS | Elysian Foods | 31.20 | 0.50 | 0.03 |
| FNTM | Fantom Semiconductors | 98.75 | 0.60 | 0.06 |
| GLDR | Glider Mining | 12.85 | 0.90 | 0.00 |
| HRZN | Horizon Pharma | 185.30 | 0.40 | 0.04 |
| IGNT | Ignition Robotics | 67.90 | 0.64 | 0.07 |
| JNTR | Juniter Aerospace | 410.50 | 0.50 | 0.05 |
| KLPS | Kelpsea Marine | 29.40 | 0.76 | 0.04 |
| LMNR | Luminar Optics | 88.25 | 0.54 | 0.06 |
| MRVL | Marvell Networks | 142.00 | 0.60 | 0.05 |
| NXUS | Nexus Cloud | 56.70 | 0.66 | 0.08 |
| ORBT | Orbit Satellite | 19.95 | 0.84 | 0.02 |
| PYRA | Pyramid Logistics | 73.10 | 0.56 | 0.05 |
| QNTM | Quantum Compute | 330.80 | 0.90 | 0.15 |
| RVNG | Ravenge Media | 24.50 | 0.72 | 0.03 |
| SLPH | Silph Chemicals | 117.65 | 0.44 | 0.04 |
| TLRX | Talarix Devices | 61.30 | 0.62 | 0.06 |
| UMBR | Umbra Coal | 9.85 | 1.00 | −0.02 |
| VRTX | Vertex Software | 255.20 | 0.48 | 0.05 |
| WLLW | Willow Retail | 47.80 | 0.58 | 0.04 |
| XNTH | Xanthe Defense | 201.45 | 0.66 | 0.09 |
| YNDR | Yonder Travel | 35.60 | 0.68 | 0.05 |
| ZPHR | Zephyr Auto | 82.95 | 0.52 | 0.04 |
Instrument Universe — Indexes
| Symbol | Name | Init. Price | σ | Tick | Notes |
|---|---|---|---|---|---|
| EXMINI | EX-Mini Index | 4,520 | 0.16 | 0.25 | Broad market index |
| TECHX | Tech-X Index | 18,750 | 0.22 | 0.25 | Tech sector index |
| FINX | Financials Index | 1,380.50 | 0.18 | 0.25 | tick is 0.25 above a fair value of 1,000 (0.05 below) |
| ENRGX | Energy Sector Index | 780.25 | 0.28 | 0.05 | |
| VOLAX | Volatility Index | 16.50 | 0.85 | 0.01 | vol-of-vol is extreme |
Instrument Universe — Futures
3 expiries × 15 underlyings = 45 futures contracts. Priced via cost-of-carry every tick. mm_spread_bps: 6 (front), 7 (F2/F3).
| Underlying | Front (-F, 30d) | Second (-F2, 60d) | Third (-F3, 90d) |
|---|---|---|---|
| EXMINI | EXMINI-F | EXMINI-F2 | EXMINI-F3 |
| TECHX | TECHX-F | TECHX-F2 | TECHX-F3 |
| ENRGX | ENRGX-F | ENRGX-F2 | ENRGX-F3 |
| ACME | ACME-F | ACME-F2 | ACME-F3 |
| DRFT | DRFT-F | DRFT-F2 | DRFT-F3 |
| QNTM | QNTM-F | QNTM-F2 | QNTM-F3 |
| HRZN | HRZN-F | HRZN-F2 | HRZN-F3 |
| JNTR | JNTR-F | JNTR-F2 | JNTR-F3 |
| GLDX (Gold) | GLDX-F | GLDX-F2 | GLDX-F3 |
| SLVX (Silver) | SLVX-F | SLVX-F2 | SLVX-F3 |
| OILX (Crude Oil) | OILX-F | OILX-F2 | OILX-F3 |
| NATG (Natural Gas) | NATG-F | NATG-F2 | NATG-F3 |
| COPX (Copper) | COPX-F | COPX-F2 | COPX-F3 |
| WHTX (Wheat) | WHTX-F | WHTX-F2 | WHTX-F3 |
| PLTX (Platinum) | PLTX-F | PLTX-F2 | PLTX-F3 |
Instrument Universe — ETFs
| Symbol | Components & Weights | mm_spread_bps | Vol est. (×0.75 diversification, includes the 2.0× stock multiplier) |
|---|---|---|---|
| TECH-ETF | DRFT 25%, QNTM 25%, FNTM 25%, NXUS 25% | 10 | ≈ 0.55 |
| ENRG-ETF | CDLR 25%, UMBR 25%, GLDR 25%, YNDR 25% | 10 | ≈ 0.57 |
| INDU-ETF | ACME 25%, JNTR 25%, MRVL 25%, SLPH 25% | 10 | ≈ 0.39 |
| HLTH-ETF | HRZN 40%, BRYN 25%, SLPH 25%, KLPS 10% | 10 | ≈ 0.39 |
| DFNS-ETF | XNTH 40%, JNTR 30%, IGNT 30% | 10 | ≈ 0.45 |
| CONS-ETF | WLLW 30%, ELYS 25%, RVNG 25%, YNDR 20% | 10 | ≈ 0.46 |
| CMDT-ETF | OILX 40%, NATG 25%, COPX 20%, WHTX 15% | 10 | ≈ 0.90 |
| SNP10 | Top 10 by fair_value, cap-weighted (JNTR ≈19%, QNTM ≈16%, ... FNTM ≈5%) | 10 | ≈ 0.46 |
| SNP15 | Top 15 by fair_value, cap-weighted — SNP10's 10 plus LMNR, ZPHR, BRYN, PYRA, IGNT | 10 | ≈ 0.46 |
Instrument Universe — Options
5 strikes × 2 types × 4 underlyings = 40 "30-day" option contracts. mm_spread_bps=25, tick=0.05, T=30d.
| Underlying | ATM (approx.) | Step | vol_skew | Strikes |
|---|---|---|---|---|
| DRFT (~220) | 220 | 10 | 1.0 | 200, 210, 220, 230, 240 × C/P |
| QNTM (~330) | 330 | 10 | 1.0 | 310, 320, 330, 340, 350 × C/P |
| EXMINI (~4520) | 4500 | 50 | 0.8 | 4400, 4450, 4500, 4550, 4600 × C/P |
| TECHX (~18750) | 18750 | 50 | 0.8 | 18650, 18700, 18750, 18800, 18850 × C/P |
{UNDERLYING}-{STRIKE}-C / -P. Example: DRFT-220-C is the DRFT 220-strike call. EXMINI-4500-P is the EXMINI 4500 put.
0DTE Options (separate family)
A second family of same-day-expiry options exists alongside the 30-day family above: 5 strikes × 2 types × 5 underlyings = 50 more contracts, symbol pattern {UNDERLYING}W-{STRIKE}-C / -P (note the trailing W on the underlying). Higher vol_mult, vol_skew, and smile_curvature than the 30-day family, and real intraday time decay as expiry approaches (see the Black-Scholes section).
| Underlying | ATM (approx.) | Step | vol_skew | smile_curvature | Strikes |
|---|---|---|---|---|---|
| DRFTW (~220) | 220 | 5 | 2.5 | 1.5 | 210, 215, 220, 225, 230 × C/P |
| QNTMW (~330) | 330 | 10 | 2.5 | 1.5 | 310, 320, 330, 340, 350 × C/P |
| EXMINIW (~4520) | 4525 | 25 | 1.5 | 1.0 | 4475, 4500, 4525, 4550, 4575 × C/P |
| TECHXW (~18750) | 18750 | 50 | 1.5 | 1.0 | 18650, 18700, 18750, 18800, 18850 × C/P |
| HRZNW (~185) | 185 | 5 | 2.5 | 1.5 | 175, 180, 185, 190, 195 × C/P |
Bot Types
| ID prefix | Class | Strategy | Instruments | Count |
|---|---|---|---|---|
| MM_xx | MarketMakerBot | Symmetric quotes ±spread around fair value, inventory skew | Stocks, indexes, futures, ETFs, commodities | 14 |
| BMM_xx | MarketMakerBot | Broad coverage variant — always quotes every non-option instrument so no book sits empty | Stocks, indexes, futures, ETFs, commodities | 2 |
| MM2_xx | MarketMakerBot | Additional narrow market makers, tighter spreads | Stocks, indexes, futures, ETFs, commodities | 6 |
| OMM_xx | MarketMakerBot | Options-only, wider spread_mult | Options (batched) | 5 |
| OMM2_xx | MarketMakerBot | Additional options makers with random overlap, so every option is quoted | Options (batched) | 4 |
| MOM_xx | MomentumBot | Buys on upticks (>3bps), sells on downticks | Stocks, front futures, commodities | 5 |
| MR_xx | MeanReversionBot | Fades when mark deviates >15-45bps from fair | Stocks, indexes, front futures, commodities | 5 |
| MR2_xx | MeanReversionBot | Additional mean-reversion, extra directional pressure | Stocks, indexes, front futures, commodities | 2 |
| NZ_xx | NoiseBot | Random market + limit orders (75% market) | All instruments | 5 |
| NZ2_xx | NoiseBot | Additional noise, broad random coverage | All instruments | 3 |
| NZ3_xx | NoiseBot | Sharded across all instruments so none go quiet, even by chance | All instruments | 3 |
| OPT_xx | OptionsArbBot | Buys cheap / sells rich vs BS fair value | All options | 3 |
| ETF_xx | ETFArbBot | Quotes around NAV, arbs premium/discount | ETFs | 2 |
| VORTEX, KESTREL, OBSIDIAN, MERIDIAN | InsiderBot | Reads scheduled events directly; accumulates positions 15–75s before each event fires | Affected instruments per event | 4 |
Total bot population: 63 bots per exchange. All bots skip disabled instruments and are exempt from position limits. Bots cannot be deleted via the admin UI (attempting returns HTTP 400). To remove a bot, restart the server.
Idle throttle
An exchange with nobody connected does not need its full fleet thinking every tick. When the last browser disconnects, all but a standing crew of 10 bots stop acting; they resume the moment someone connects again. Measured effect: roughly 125 to 22 think() calls per second, an 83% reduction, which is what makes running many exchanges on one box affordable.
The crew is picked to keep the market alive rather than at random — broad market makers first (so no book goes empty), then noise, momentum, mean reversion and narrow makers, covering about 125 of the 182 instruments. Prices still move on an unwatched exchange; they simply move less. Options are not in the crew, but their fair values continue to track the underlying through Black-Scholes in the tick loop regardless.
| Variable | Default | Purpose |
|---|---|---|
IDLE_BOT_COUNT | 10 | Bots that keep trading while nobody is watching. 0 suspends them entirely; a value at or above the fleet size disables the throttle. |
IDLE_POLL_SECONDS | 2.0 | How often a paused bot checks whether someone has connected. |
Bot Starting Capital
| Bot type | Starting cash | Reason |
|---|---|---|
| BMM_xx (broad market maker) | $8,000,000 | Always quotes every non-option instrument at once — the largest inventory exposure of any bot |
| MM_xx (market maker) | $5,000,000 | Must absorb large inventory swings across many instruments |
| MM2_xx (narrow market maker) | $4,000,000 | Same role as MM_xx on a smaller, tighter-spread subset |
| OMM_xx (options market maker) | $3,000,000 | Options positions can be large in notional terms |
| OMM2_xx, InsiderBot, OptionsArbBot | $2,000,000 | Moderate capital for holding options or informed positions for several ticks |
| MomentumBot, MeanReversionBot, ETFArbBot | $1,000,000 | Directional or arb bots, smaller position needs (the base default — none of these three override it) |
| NoiseBot | $500,000 | Random small orders, low capital requirement |
News REST API
All news endpoints require host-level authentication (JWT bearer token). Base path: /exchanges/{exchange_id}/admin.
List Active News
GET /exchanges/{eid}/admin/news
Returns all current news items: pending, resolved-confirmed, and resolved-denied. Each item includes admin-only fields (reality_prob, shocks) not exposed to traders.
{
"news_01": {
"id": "news_01",
"headline": "Fed signals surprise rate cut",
"body": "...",
"source": "Reuters",
"reality_prob": 0.90,
"status": "UNCONFIRMED",
"affected": ["DRFT", "TECHX"],
"shocks": {"DRFT": 0.06, "TECHX": 0.04},
"resolved": false,
"created_at": 1719400000.0
}
}
Publish News
POST /exchanges/{eid}/admin/news
| Field | Type | Description |
|---|---|---|
token | string | Admin token (required) |
news_id | string | Key from the news catalog (e.g. "fed_rate_cut"), or null for custom |
headline | string | Required if news_id is null |
body | string | Optional longer description |
source | string | Source label shown to traders (e.g. "Reuters") |
reality_prob | float 0–1 | Probability the news is true. Controls upfront shock magnitude. |
affected | string[] | List of symbols affected by this news item |
shocks | dict | Symbol → fractional shock (e.g. {"ACME": 0.12}). Applied immediately at reality_prob × shock upfront. |
resolve_seconds | float | Seconds until auto-resolution. Default: 45. |
drift_seconds | float | Seconds over which remaining shock drifts in after confirmation. Default: 30. |
Force Resolve News
POST /exchanges/{eid}/admin/news/resolve
| Field | Type | Description |
|---|---|---|
token | string | Admin token (required) |
news_id | string | ID of the news item to resolve (e.g. "news_03") |
outcome | string | "confirm" or "deny" |
Immediately resolves the news. CONFIRMED: remaining shock drifts in over drift_seconds. DENIED: upfront move reverses over the same window.
Fetch News Catalog
GET /exchanges/{eid}/admin/news-catalog
Returns all 39 built-in news items with their category, headline, source, reality_prob, and shocks fields. The admin UI uses this to populate the news picker. You can also call it from automation scripts to select items programmatically.
Scenarios REST API
Trigger a Scenario
POST /exchanges/{eid}/admin/scenario
| Field | Type | Description |
|---|---|---|
token | string | Admin token (required) |
scenario_id | string | Key from the catalog (e.g. "tech_crash"), or null for custom |
shocks | dict | Override or provide shocks when using a custom scenario: {"ACME": -0.10, "ALL_STOCKS": -0.03} |
Special shock keys: ALL_STOCKS, ALL_INDEXES, ALL_FUTURES, ALL_ETFS apply the shock to every instrument of that type simultaneously.
Fetch Scenarios Catalog
GET /exchanges/{eid}/admin/scenarios-catalog
Returns all 55 built-in scenarios with their category, label, description, and shocks fields. Used by the admin UI to populate the scenario picker.
Shock Randomness Toggle
POST /exchanges/{eid}/admin/shock-randomness
| Field | Type | Description |
|---|---|---|
token | string | Admin token (required) |
enabled | bool | true = random jitter applied to all shocks. false = shocks applied exactly as specified. |
The current state is returned in GET /exchanges/{eid}/admin/metrics as "shock_randomness": true/false. In the console, the toggle is on the Settings page.
Session Design Guide
A well-structured session has a clear arc: participants need time to learn the UI, then a period of active price discovery, then a climax event, then a wind-down. Sessions that are too short feel rushed; too long and energy fades.
Recommended Three-Phase Structure
| Phase | Duration | What to do |
|---|---|---|
| 1. Warm-up | 5–8 min | Keep market in PRE_OPEN until everyone is connected. Switch to OPEN and let participants explore — place a few orders, find the Python terminal, read the wiki. No news yet. |
| 2. Active Trading | 15–30 min | Release news and trigger scenarios every 3–5 minutes. Mix high-credibility confirmed news with a few low-credibility rumours to create uncertainty. Use Scenarios & shocks to inject macro shocks between news events. |
| 3. Wind-Down | 5 min | Set the session timer to 5 minutes. Announce the final state on your voice channel. Participants should close positions. Use HALT to freeze the market at the exact close. |
Timing Templates
30-Minute Sprint
0:00 PRE_OPEN — participants connect 3:00 → OPEN (timer: 25 min) 5:00 Trigger: sector_rotation (tech -8%, energy +8%) 10:00 News: Reuters — analyst note on top mover 14:00 News: Analyst Note — earnings whisper (reality_prob=0.65) 16:00 Force resolve earnings whisper — DENIED (reversal!) 20:00 Trigger: risk_off_flight (broad sell-off) 24:00 News: Government Statement — policy reversal (reality_prob=1.0) 28:00 Timer warning (2 min) visible to all 30:00 Auto-CLOSE
1-Hour Competition
0:00 PRE_OPEN — full briefing to participants 8:00 → OPEN (timer: 50 min) 12:00 News: Anonymous Tip — M&A rumour (reality_prob=0.30) 18:00 Scenario: sector_rally (choose the lagging sector) 22:00 Force resolve M&A rumour — CONFIRMED (+30%) 28:00 News: Corporate Filing — profit warning (reality_prob=1.0) 35:00 Scenario: macro_shock (central bank surprise) 42:00 News: Reuters — reversal / policy pivot 48:00 Timer warning (10 min) — "wind down positions" 55:00 HALT — final P&L freeze 58:00 Standings → Present, download CSVs → debrief begins
Insider Day (60 min)
The Insider Day template is designed specifically for teaching informed-flow detection. Select it from the Session Templates picker in the Admin Console. It schedules 9 events across 60 minutes, ensuring the 4 InsiderBots are repeatedly active so participants have multiple chances to spot the pattern.
0:00 PRE_OPEN — participants connect; brief them on tape attribution 5:00 → OPEN (timer: 60 min) 4 min Corporate action rumour (news) — ACME / BRYN affected 9 min Corporate event (scenario: mega_merger) 15 min Pharma catalyst rumour (news) — HRZN / BRYN affected 21 min Biotech binary outcome (scenario: biotech_binary) 27 min Technology partnership (news) — DRFT / NXUS affected 32 min Technology sector event (scenario: quantum_breakthrough) 38 min Market intelligence report (news) — broad signal 43 min Corporate disclosure (scenario: short_seller_attack) 50 min Broad market event (scenario: market_crash) 60 min Auto-CLOSE
exchange.suspect_flow() in the Python terminal to surface the signal quantitatively.
Choosing Confirmed vs Rumour News
The most educational sessions mix news credibilities deliberately. Here are the patterns that generate the most learning:
| Technique | How | Lesson taught |
|---|---|---|
| Fake Reuters | Publish Reuters news with reality_prob=0.90, then force DENY | Even high-credibility sources can be wrong. Black swans exist. |
| True Rumour | Publish Anonymous Tip, force CONFIRM | Rumours sometimes come true — the informed trader knew early. |
| Competing Headlines | Publish two conflicting news items about the same stock | How to reconcile conflicting information; price anchoring. |
| News + Scenario Combo | Trigger a positive news event, then immediately fire a broad macro sell-off scenario | Single-stock alpha can be wiped out by macro. Hedging matters. |
| InsiderBot Signal | Watch the tape 15–75s before a big event — VORTEX, KESTREL, OBSIDIAN, or MERIDIAN will be accumulating in the affected instrument | Informed flow appears in the tape before events. Teach participants to use exchange.suspect_flow() to detect it quantitatively. |
Classroom Tips — Teaching with Scenarios
Each scenario category maps to a specific financial concept. Pair the scenario with a short verbal explanation before you trigger it, then watch the price action together.
| Concept | Scenario to use | Discussion point |
|---|---|---|
| Market crash / systemic risk | risk_off_flight, market_crash | Why do stocks, futures, and commodities all fall together? Correlations spike to 1 in crises — diversification fails exactly when you need it. |
| Sector rotation | sector_rotation, tech_rally | When money leaves tech it has to go somewhere — see how energy names rally as tech falls. Capital allocation is zero-sum across sectors. |
| Central bank effects | rate_cut_surprise, fed_hike_50bps | Why does a rate cut help growth stocks but hurt financials? Discount rate mechanics and duration sensitivity. |
| Commodity price shocks | oil_spike, gold_safe_haven | Why does oil spiking hurt stock markets overall? Input cost pass-through, consumer spending, and sectoral winners/losers. |
| Volatility & options | volatility_explosion | VOLAX spikes. Watch option prices surge even though the underlying barely moved yet — this is pure vega. Options are a direct volatility bet. |
| Credit events | sovereign_debt_crisis, corporate_scandal | Contagion: one issuer's distress spreads to the whole sector. Why does FNTM fraud hurt FINX and even unrelated financials? |
| Options expiry | iv_crush | After an earnings event resolves, implied volatility collapses. Option buyers lose even if the direction was right — theta and vega matter as much as delta. |
Suggested Discussion Questions (during session)
- Why is the spread wider on that option than on the stock?
- Who was buying ACME before the rumour — can you see it in the tape?
- If you knew the announcement was coming, how would you position differently?
- The rumour said +15% and the stock moved +4% — why only 4%?
- Why did TECH-ETF fall less than DRFT even though DRFT is in it?
- What happens to the futures price relative to spot when the market crashes?
Post-Session Debrief
After the session closes, the Standings page shows the final ranking. Put it on the projector with Present and use it to structure the debrief:
| What to look at | Discussion angle |
|---|---|
| Top P&L vs bottom P&L spread | What differentiated the winners? Was it instrument choice, speed, or information processing? |
| Who traded the most volume | High volume doesn't always mean high P&L — over-trading and crossing the spread is costly. |
| Position at session end | Did anyone hold large positions into the close? What risk did they take? |
| News resolution history | Review which news items were confirmed vs denied — correlate with who made money on each event. |
Instrument Guide — Beginners vs Advanced
Not all instruments are appropriate for every audience. Use the Instruments tab to disable instrument categories before the session starts.
| Audience | Enable | Disable | Rationale |
|---|---|---|---|
| Beginners | Stocks only (5–8 names) | Indexes, Futures, ETFs, Options | Single instrument type reduces cognitive load. Focus on order types, spread, and basic P&L. |
| Intermediate | Stocks + Indexes + ETFs | Futures, Options | ETFs introduce diversification; Indexes let students see macro effects. Still no leverage. |
| Advanced | All instruments | — | Full universe. Best for finance students or professionals who already understand Greeks and futures carry. |
| Options focus | 4 option underlyings + their stocks | All others | Pure options teaching session. Keep universe small so participants focus on Greeks and strategies. |
Voltrade host handbook