voltrade /Host handbook Your exchanges ↗

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

PartWhat it does
State switchPre-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.
TimerCounts 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.
ThemeSwitches between dark and light. The terminal and the ranks page use the same choice.

Where each section lives

GroupPageContents
RunOverviewKPI 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 planTimeline, queue of scheduled events, add form, timer, templates and builder. See Session Plan.
NewsPublish a headline now, browse the news catalogue, and see rumours that have not resolved yet.
Scenarios & shocksScenario catalogue and one-off shocks.
PeopleTradersEvery human trader with cash, equity, P&L, open positions and orders, a cash/equity editor and Kick. The menu entry shows the trader count.
StandingsReplaces the old Leaderboard and Summary tabs. See Standings & Present.
Rules & limitsDefault starting equity and position limits.
MarketInstrumentsTurn single instruments, or whole types, on and off.
Market healthBook 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.
AnalyticsLive charts of order flow and P&L.
ToolsSettingsExchange 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:

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

ActionEffect
Release nowFires the event within a second. Its shocks, drift and credibility stay as they were.
DelayPushes the event back 5 minutes.
SkipRemoves the event from the queue.
Scheduled events have no edit endpoint. To release or delay an event, the console deletes it and schedules a copy at the new time. The copy gets a new id; nothing else about the event changes.

Adding an event

The add form asks for:

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.

Hosting limit. A regular account can host one exchange at a time — hosting one runs a full bot fleet plus its tick, persist and snapshot loops, so the limit keeps a single account from consuming unbounded server capacity. Joining someone else's exchange as a participant doesn't count against it; only exchanges you host do. Deleting an exchange frees the slot immediately. Platform-admin accounts (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:

StateOrder submissionMatchingBots
PRE_OPENRejectedOffRunning (no orders)
OPENAcceptedActiveActive
HALTEDRejectedOff (book frozen)Running (no orders)
CLOSEDRejectedOffRunning (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).

The server always starts in PRE_OPEN on every restart, regardless of any previously saved state. This ensures participants cannot accidentally trade before you are ready when you reboot the server. Moving back to Pre-open with the header switch does not clear P&L or positions. Use Reset all human P&L on the Settings page for that.

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:

This is irreversible. If they reconnect, they're re-registered as a fresh participant with the default starting equity and zero positions.

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:

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:

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.

ControlEffect
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" checkedRebases 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:

ModeEffect
CashSets the participant's cash balance directly to the entered amount. Open positions are untouched, so total equity moves by exactly the cash change.
EquitySets 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.

ScenarioResult for DRFT
Limits OFFUnlimited positions for all humans
Limits ON, global = 50, no DRFT overrideHumans capped at ±50 lots of DRFT
Limits ON, global = 50, DRFT override = 20Humans capped at ±20 lots of DRFT only
Limits ON, global = 50, DRFT override = 0Global cap applies: ±50 lots
Limits ON, global = 0, no DRFT overrideDRFT 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.

Position limits apply to net position after the order fills. A participant at +48 with a global limit of 50 can still buy 2 more lots but not 3.

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:

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)

VariableDefaultEffect
EXCHANGE_TICK0.5Tick 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)

SettingDefaultWhere
Default starting equity$1,000,000Participant Management card → SET
Position limits on/offOFFPosition Limits card → ENABLE/DISABLE LIMITS
Global position limit0 (uncapped)Position Limits card → Default field → SET ALL INSTRUMENTS
Per-instrument limitsNone (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).

Realism mode caveat. With Realistic Market Dynamics on, the GJR-GARCH volatility regime, AR(1) memory and lead-lag structure are path-dependent and have no closed form. The gap is bridged with the plain jump and those states resume where they left off. Correlation structure across a sleep is approximate; behaviour within a live session is unaffected.

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.

VariableDefaultEffect
IDLE_SLEEP_SECONDS300Seconds with no connected client and no admin activity before an exchange hibernates. 0 disables hibernation entirely — every exchange stays resident forever.
MAX_FASTFORWARD_SECONDS14400Ceiling 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_SECONDS30How 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.

VariableDefaultPurpose
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_ENDPOINTAWS S3Endpoint URL for a non-AWS provider (R2, B2). Leave unset for AWS.
BACKUP_S3_REGIONautoRegion. auto is correct for R2.
BACKUP_PREFIXvoltrade-backups/Key prefix inside the bucket.
BACKUP_INTERVAL_HOURS24Hours between uploads.
BACKUP_RETENTION14How 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.

The bucket must be private. A backup is the whole database: every user's email address and password hash. Never make the bucket or its objects publicly readable, and delete local copies once you have finished with them.

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.

Take a backup before any deploy that changes the database schema. Rolling back the code does not roll back a migration, so this file is the only way back. Two related habits worth keeping: test a migration against a copy of real data rather than an empty dev database (a change can pass on an empty table and fail on live rows), and keep schema changes additive — add a column, deploy code that writes it, backfill, and only drop the old one much later.
The trade tape is not an archive. On every restart the server trims 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

VariableDefaultPurpose
FEEDBACK_EMAILfirst entry in PLATFORM_ADMIN_EMAILSWhere notifications are sent. Nothing is emailed if this and PLATFORM_ADMIN_EMAILS are both unset — submissions are still stored.
MAX_FEEDBACK_CHARS2000Server-side length cap, enforced independently of the textarea.
FEEDBACK_RATE_MAX5Submissions allowed per account per window.
FEEDBACK_RATE_WINDOW3600Throttle window in seconds.
Notification mail is sent from your verified domain, not from the user. 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.

The Python lock is an incentive, not a security boundary. Python runs in the browser over the same socket as the order ticket, so a determined guest could script orders anyway. The server-side order rate limit is what actually bounds an anonymous visitor.

Configuration

VariableDefaultPurpose
GUEST_TTL_DAYS7Guests inactive this long are deleted (hourly sweep). Their trades stay in counterparties' history.
GUEST_ORDER_RATE_MAX20Orders a guest may submit per window; extras are rejected with RATE_LIMITED.
GUEST_ORDER_RATE_WINDOW10That window, in seconds.
GUEST_RATE_MAX20New guests per IP per window.
GUEST_RATE_WINDOW3600That 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.

EndpointPurpose
GET /api/filesThe 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}/renameBody {"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

VariableDefaultPurpose
MAX_USER_FILES50Files per account. Re-saving an existing file is always allowed.
MAX_NOTEBOOK_BYTES262144Largest 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:

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
FieldMeaning
onlineThe 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.
clientsHow 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.
stateLive state from the registry when online, otherwise the last state persisted.
has_botsWhether 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.

Hiding the tab is presentation only — every /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:

St+dt = St × exp( (μ − ½σ²)dt + σ√dt · (0.4Zmkt + Zidio) )
// 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."

ParameterSymbolTypical 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 secondsdt0.1s default → ~1.7 × 10⁻⁸ yr/tick
Why don't prices move much each tick? At 0.1s ticks with 20% annual vol, each tick moves the price by σ√dt ≈ 0.20 × √(1.7×10⁻⁸) ≈ 0.0026%. That's correct — the large intraday moves come from hundreds of ticks accumulating.
The formula above is the classic price process (the default). It intentionally keeps instruments almost independent — the shared factor's loading is tiny, so effective pairwise correlation is only ~1.4%. Turn on Realistic Market Dynamics at exchange creation for correlated sectors, volatility regimes, fat tails and memory.

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:

FeatureWhat it does
Factor correlationEach 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 havensGold/silver/platinum carry negative market betas, so they genuinely rally when equities sell off.
Basket indexesEXMINI, 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 VOLAXThe 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 regimesA 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 tailsStudent-t innovations produce the occasional outsized move that Gaussian GBM never does (return kurtosis well above 3).
MemorySome 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–lagA few smaller names follow the market a tick or two late, another discoverable signal.
Teaching tip. The realistic process is what makes classic strategies actually work: pairs/relative-value trades (correlated names), index arbitrage (index vs constituents), volatility trading (VOLAX vs realized), momentum and mean-reversion. Market makers automatically widen their spreads during volatility bursts, so quoting through a spike is no longer free money for the aggressors. The classic process is better for a clean, single-name demonstration where you don't want cross-instrument effects.

Pricing — Futures (Cost of Carry)

Futures fair values are updated every tick from the current spot (underlying fair value):

F = S × er × T
// S = underlying fair value | r = 0.04 (4% risk-free rate) | T = expiry in years
ContractT (years)Carry premium over spot
Front month -F30/365 = 0.0822≈ 0.33%
Second month -F260/365 = 0.1644≈ 0.66%
Third month -F390/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.

T does not count down. Expiry days are fixed for the life of the session. There are no roll events.

Pricing — ETF NAV

ETF fair values are recalculated in pass 2 of the FairValueEngine tick, after stocks are updated in pass 1:

NAV = Σ wi × Si
// w_i = basket weight (sums to 1.0) | S_i = component fair value after GBM step
ETFComponents (equal weight 25% each)Approx. initial NAV
TECH-ETFDRFT, QNTM, FNTM, NXUS~176
ENRG-ETFCDLR, UMBR, GLDR, YNDR~26
INDU-ETFACME, JNTR, MRVL, SLPH~206
HLTH-ETF, DFNS-ETF, CONS-ETF, CMDT-ETFSame equal-weight NAV pattern, over the healthcare, defense, consumer, and commodity baskets respectivelyvaries

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:

// Call price
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:

σadj = σunderlying × max( 1 + skew × m + curvature × m² , 0.05 ) where m = ln(S/K)
// 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
The 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.

SymbolNameInit. Priceσ (ann., actual)μ (ann.)
ACMEAcme Industrial Corp152.400.560.05
BRYNBryn Biotech78.100.700.08
CDLRCandler Energy44.550.440.02
DRFTDrift AI Systems220.000.800.12
ELYSElysian Foods31.200.500.03
FNTMFantom Semiconductors98.750.600.06
GLDRGlider Mining12.850.900.00
HRZNHorizon Pharma185.300.400.04
IGNTIgnition Robotics67.900.640.07
JNTRJuniter Aerospace410.500.500.05
KLPSKelpsea Marine29.400.760.04
LMNRLuminar Optics88.250.540.06
MRVLMarvell Networks142.000.600.05
NXUSNexus Cloud56.700.660.08
ORBTOrbit Satellite19.950.840.02
PYRAPyramid Logistics73.100.560.05
QNTMQuantum Compute330.800.900.15
RVNGRavenge Media24.500.720.03
SLPHSilph Chemicals117.650.440.04
TLRXTalarix Devices61.300.620.06
UMBRUmbra Coal9.851.00−0.02
VRTXVertex Software255.200.480.05
WLLWWillow Retail47.800.580.04
XNTHXanthe Defense201.450.660.09
YNDRYonder Travel35.600.680.05
ZPHRZephyr Auto82.950.520.04

Instrument Universe — Indexes

SymbolNameInit. PriceσTickNotes
EXMINIEX-Mini Index4,5200.160.25Broad market index
TECHXTech-X Index18,7500.220.25Tech sector index
FINXFinancials Index1,380.500.180.25tick is 0.25 above a fair value of 1,000 (0.05 below)
ENRGXEnergy Sector Index780.250.280.05
VOLAXVolatility Index16.500.850.01vol-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).

UnderlyingFront (-F, 30d)Second (-F2, 60d)Third (-F3, 90d)
EXMINIEXMINI-FEXMINI-F2EXMINI-F3
TECHXTECHX-FTECHX-F2TECHX-F3
ENRGXENRGX-FENRGX-F2ENRGX-F3
ACMEACME-FACME-F2ACME-F3
DRFTDRFT-FDRFT-F2DRFT-F3
QNTMQNTM-FQNTM-F2QNTM-F3
HRZNHRZN-FHRZN-F2HRZN-F3
JNTRJNTR-FJNTR-F2JNTR-F3
GLDX (Gold)GLDX-FGLDX-F2GLDX-F3
SLVX (Silver)SLVX-FSLVX-F2SLVX-F3
OILX (Crude Oil)OILX-FOILX-F2OILX-F3
NATG (Natural Gas)NATG-FNATG-F2NATG-F3
COPX (Copper)COPX-FCOPX-F2COPX-F3
WHTX (Wheat)WHTX-FWHTX-F2WHTX-F3
PLTX (Platinum)PLTX-FPLTX-F2PLTX-F3

Instrument Universe — ETFs

SymbolComponents & Weightsmm_spread_bpsVol est. (×0.75 diversification, includes the 2.0× stock multiplier)
TECH-ETFDRFT 25%, QNTM 25%, FNTM 25%, NXUS 25%10≈ 0.55
ENRG-ETFCDLR 25%, UMBR 25%, GLDR 25%, YNDR 25%10≈ 0.57
INDU-ETFACME 25%, JNTR 25%, MRVL 25%, SLPH 25%10≈ 0.39
HLTH-ETFHRZN 40%, BRYN 25%, SLPH 25%, KLPS 10%10≈ 0.39
DFNS-ETFXNTH 40%, JNTR 30%, IGNT 30%10≈ 0.45
CONS-ETFWLLW 30%, ELYS 25%, RVNG 25%, YNDR 20%10≈ 0.46
CMDT-ETFOILX 40%, NATG 25%, COPX 20%, WHTX 15%10≈ 0.90
SNP10Top 10 by fair_value, cap-weighted (JNTR ≈19%, QNTM ≈16%, ... FNTM ≈5%)10≈ 0.46
SNP15Top 15 by fair_value, cap-weighted — SNP10's 10 plus LMNR, ZPHR, BRYN, PYRA, IGNT10≈ 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.

UnderlyingATM (approx.)Stepvol_skewStrikes
DRFT (~220)220101.0200, 210, 220, 230, 240 × C/P
QNTM (~330)330101.0310, 320, 330, 340, 350 × C/P
EXMINI (~4520)4500500.84400, 4450, 4500, 4550, 4600 × C/P
TECHX (~18750)18750500.818650, 18700, 18750, 18800, 18850 × C/P
Option symbols follow the pattern {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).

UnderlyingATM (approx.)Stepvol_skewsmile_curvatureStrikes
DRFTW (~220)22052.51.5210, 215, 220, 225, 230 × C/P
QNTMW (~330)330102.51.5310, 320, 330, 340, 350 × C/P
EXMINIW (~4520)4525251.51.04475, 4500, 4525, 4550, 4575 × C/P
TECHXW (~18750)18750501.51.018650, 18700, 18750, 18800, 18850 × C/P
HRZNW (~185)18552.51.5175, 180, 185, 190, 195 × C/P

Bot Types

ID prefixClassStrategyInstrumentsCount
MM_xxMarketMakerBotSymmetric quotes ±spread around fair value, inventory skewStocks, indexes, futures, ETFs, commodities14
BMM_xxMarketMakerBotBroad coverage variant — always quotes every non-option instrument so no book sits emptyStocks, indexes, futures, ETFs, commodities2
MM2_xxMarketMakerBotAdditional narrow market makers, tighter spreadsStocks, indexes, futures, ETFs, commodities6
OMM_xxMarketMakerBotOptions-only, wider spread_multOptions (batched)5
OMM2_xxMarketMakerBotAdditional options makers with random overlap, so every option is quotedOptions (batched)4
MOM_xxMomentumBotBuys on upticks (>3bps), sells on downticksStocks, front futures, commodities5
MR_xxMeanReversionBotFades when mark deviates >15-45bps from fairStocks, indexes, front futures, commodities5
MR2_xxMeanReversionBotAdditional mean-reversion, extra directional pressureStocks, indexes, front futures, commodities2
NZ_xxNoiseBotRandom market + limit orders (75% market)All instruments5
NZ2_xxNoiseBotAdditional noise, broad random coverageAll instruments3
NZ3_xxNoiseBotSharded across all instruments so none go quiet, even by chanceAll instruments3
OPT_xxOptionsArbBotBuys cheap / sells rich vs BS fair valueAll options3
ETF_xxETFArbBotQuotes around NAV, arbs premium/discountETFs2
VORTEX, KESTREL, OBSIDIAN, MERIDIANInsiderBotReads scheduled events directly; accumulates positions 15–75s before each event firesAffected instruments per event4

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.

VariableDefaultPurpose
IDLE_BOT_COUNT10Bots that keep trading while nobody is watching. 0 suspends them entirely; a value at or above the fleet size disables the throttle.
IDLE_POLL_SECONDS2.0How often a paused bot checks whether someone has connected.
InsiderBots are intentionally disguised. Their IDs (VORTEX, KESTREL, OBSIDIAN, MERIDIAN) and display names (Vortex Capital, Kestrel Fund, Obsidian Partners, Meridian Alpha) look like regular participants. Participants must detect them through tape analysis — there are no other visual giveaways. The 15–75 second lead window and order size of 15–30 lots leave a footprint large enough to detect but subtle enough to require attention.

Bot Starting Capital

Bot typeStarting cashReason
BMM_xx (broad market maker)$8,000,000Always quotes every non-option instrument at once — the largest inventory exposure of any bot
MM_xx (market maker)$5,000,000Must absorb large inventory swings across many instruments
MM2_xx (narrow market maker)$4,000,000Same role as MM_xx on a smaller, tighter-spread subset
OMM_xx (options market maker)$3,000,000Options positions can be large in notional terms
OMM2_xx, InsiderBot, OptionsArbBot$2,000,000Moderate capital for holding options or informed positions for several ticks
MomentumBot, MeanReversionBot, ETFArbBot$1,000,000Directional or arb bots, smaller position needs (the base default — none of these three override it)
NoiseBot$500,000Random 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
FieldTypeDescription
tokenstringAdmin token (required)
news_idstringKey from the news catalog (e.g. "fed_rate_cut"), or null for custom
headlinestringRequired if news_id is null
bodystringOptional longer description
sourcestringSource label shown to traders (e.g. "Reuters")
reality_probfloat 0–1Probability the news is true. Controls upfront shock magnitude.
affectedstring[]List of symbols affected by this news item
shocksdictSymbol → fractional shock (e.g. {"ACME": 0.12}). Applied immediately at reality_prob × shock upfront.
resolve_secondsfloatSeconds until auto-resolution. Default: 45.
drift_secondsfloatSeconds over which remaining shock drifts in after confirmation. Default: 30.

Force Resolve News

POST /exchanges/{eid}/admin/news/resolve
FieldTypeDescription
tokenstringAdmin token (required)
news_idstringID of the news item to resolve (e.g. "news_03")
outcomestring"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
FieldTypeDescription
tokenstringAdmin token (required)
scenario_idstringKey from the catalog (e.g. "tech_crash"), or null for custom
shocksdictOverride 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.

When Shock Randomness is enabled, each shock is multiplied by a Gaussian factor drawn from N(1.0, 0.25) — clamped to [0.6, 1.4]. This means a +10% shock will land somewhere between +6% and +14%, with the sign preserved. Disable randomness for perfectly deterministic sessions.

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
FieldTypeDescription
tokenstringAdmin token (required)
enabledbooltrue = 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

PhaseDurationWhat to do
1. Warm-up5–8 minKeep 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 Trading15–30 minRelease 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-Down5 minSet 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.
Set the timer before you OPEN. In PRE_OPEN, fill in the Session Timer card's duration field and click ▶ START — this starts the countdown from when you switch to OPEN. Participants see remaining time in the top bar.

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
Facilitator tip: Around 30 minutes in, pause to ask "Did anyone notice unusual buying in DRFT before the technology event?" and walk through the tape together. Have participants use 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:

TechniqueHowLesson taught
Fake ReutersPublish Reuters news with reality_prob=0.90, then force DENYEven high-credibility sources can be wrong. Black swans exist.
True RumourPublish Anonymous Tip, force CONFIRMRumours sometimes come true — the informed trader knew early.
Competing HeadlinesPublish two conflicting news items about the same stockHow to reconcile conflicting information; price anchoring.
News + Scenario ComboTrigger a positive news event, then immediately fire a broad macro sell-off scenarioSingle-stock alpha can be wiped out by macro. Hedging matters.
InsiderBot SignalWatch the tape 15–75s before a big event — VORTEX, KESTREL, OBSIDIAN, or MERIDIAN will be accumulating in the affected instrumentInformed 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.

ConceptScenario to useDiscussion point
Market crash / systemic riskrisk_off_flight, market_crashWhy do stocks, futures, and commodities all fall together? Correlations spike to 1 in crises — diversification fails exactly when you need it.
Sector rotationsector_rotation, tech_rallyWhen 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 effectsrate_cut_surprise, fed_hike_50bpsWhy does a rate cut help growth stocks but hurt financials? Discount rate mechanics and duration sensitivity.
Commodity price shocksoil_spike, gold_safe_havenWhy does oil spiking hurt stock markets overall? Input cost pass-through, consumer spending, and sectoral winners/losers.
Volatility & optionsvolatility_explosionVOLAX spikes. Watch option prices surge even though the underlying barely moved yet — this is pure vega. Options are a direct volatility bet.
Credit eventssovereign_debt_crisis, corporate_scandalContagion: one issuer's distress spreads to the whole sector. Why does FNTM fraud hurt FINX and even unrelated financials?
Options expiryiv_crushAfter 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)

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 atDiscussion angle
Top P&L vs bottom P&L spreadWhat differentiated the winners? Was it instrument choice, speed, or information processing?
Who traded the most volumeHigh volume doesn't always mean high P&L — over-trading and crossing the spread is costly.
Position at session endDid anyone hold large positions into the close? What risk did they take?
News resolution historyReview which news items were confirmed vs denied — correlate with who made money on each event.
Use the Download CSV buttons on the Standings page (Trades, Positions, Summary) for deeper post-session analysis. Load into Excel or Python to show trade-by-trade P&L attribution.

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.

AudienceEnableDisableRationale
BeginnersStocks only (5–8 names)Indexes, Futures, ETFs, OptionsSingle instrument type reduces cognitive load. Focus on order types, spread, and basic P&L.
IntermediateStocks + Indexes + ETFsFutures, OptionsETFs introduce diversification; Indexes let students see macro effects. Still no leverage.
AdvancedAll instruments—Full universe. Best for finance students or professionals who already understand Greeks and futures carry.
Options focus4 option underlyings + their stocksAll othersPure options teaching session. Keep universe small so participants focus on Greeks and strategies.
Disable instruments before switching to OPEN. Instruments cannot be disabled mid-session without disrupting existing orders. Plan your universe before you start the timer.

Voltrade host handbook