API Gateway (REST/WebSocket)¶
Learning objectives
After reading this page you will understand:
- What
pm-api-gwydoes in the EduMatcher process model - How to configure API keys in the central
engine_config.yaml - How to call the REST API and inspect Swagger documentation
- How private and public WebSocket streams work
- Where to find reusable Python and C REST examples
What this process is¶
pm-api-gwy exposes EduMatcher order entry, order management, reference
data, history, and market data over REST/JSON and WebSocket. It is intended for
third-party software: browser UIs, dashboards, simple bots, and teaching
examples.
It is not a second matching engine. The process translates HTTP and WebSocket
requests into the same engine ZMQ/JSON messages used by the interactive
pm-alf-console process.
flowchart LR
UI[Trading UI] -->|REST /api/v1| API[pm-api-gwy]
BOT[Bot] -->|REST /api/v1| API
UI -->|WS /api/v1/events| API
DASH[Dashboard] -->|WS /api/v1/market-data| API
API -->|ZMQ PUSH :5555| ENG[pm-engine]
ENG -->|ZMQ PUB :5556| API
STATS[pm-stats\nstats.db] -->|read-only history| API
AUDIT[pm-audit\naudit_index.db] -->|read-only order lifecycle| API
API -->|ZMQ PUSH :5559| IDX[pm-index]
IDX -->|ZMQ PUB :5558| API
Configuration¶
API gateway configuration lives in the central engine_config.yaml, matching
the existing CALF and RALF gateway pattern.
Use the top-level key api_gateways (underscore). The dashed form
api-gateways is not valid.
api_gateways:
desk:
enabled: true
host: 0.0.0.0
port: 8080
swagger_enabled: true
log_level: info
stats_db: data/stats.db
# pm-audit's index, read-only, for GET /admin/orders/{order_id}.
# Optional: without it that one endpoint returns 503 and nothing else
# is affected.
audit_db: data/audit_index.db
# Seconds a terminal order stays in the in-memory cache. 0 disables
# eviction (unbounded growth).
order_retention_sec: 3600
# Seconds the market-data stream cache retains the per-symbol trades tail
# for WS snapshot/resume. Latest book/depth/auction snapshots are kept
# regardless of age; 0 disables the trade buffer but still serves snapshots.
market_data_cache_sec: 60
credentials:
- api_key: key-trader-demo
gateway_id: TRADER01
description: Demo trading client
- api_key: key-dashboard-demo
gateway_id: null
description: Read-only dashboard client
rate_limit:
writes_per_second: 10
burst: 20
timeouts:
engine_auth_sec: 3.0
engine_reply_sec: 3.0
wait_ack_sec: 3.0
| Field | Meaning |
|---|---|
api_gateways.<NAME> |
Named API gateway process configuration selected with --instance NAME when needed |
host / port |
HTTP server bind address and port |
swagger_enabled |
Enables /docs and /openapi.json when true |
credentials[].api_key |
Bearer token clients use for REST and WebSocket auth |
credentials[].gateway_id |
Engine gateway identity; null means read-only market-data access; non-null values must be unique across api_gateways entries |
stats_db |
pm-stats' SQLite file, read-only, for /history/* |
audit_db |
pm-audit's index, read-only, for /admin/orders/{order_id}. Optional |
order_retention_sec |
Seconds a terminal order stays cached (default 3600; 0 disables eviction) |
market_data_cache_sec |
Seconds the market-data cache retains the per-symbol trades tail for WS snapshot/resume (default 60; latest book/depth/auction snapshots are kept regardless of age; 0 disables the trade buffer) |
rate_limit |
Per-key write limiting for POST/PATCH/DELETE endpoints |
timeouts |
Engine auth, request/reply, and synchronous ACK wait timeouts |
host defaults to 0.0.0.0, matching the external TCP gateways: the API
gateway is intended to be reachable by browser clients, API clients, and
read-only dashboards on other machines. Set it to 127.0.0.1 only for a
loopback-only lab or when a reverse proxy on the same host is the only caller.
The engine's gateways.alf allowlist remains authoritative. If a credential
maps to TRADER01 but TRADER01 is not allowed by the engine config, the
engine rejects the API gateway handshake and every request using that
credential fails with 403 and error code ENGINE_AUTH.
Use multiple named entries when you want logical process separation, such as one
gateway for a human trading desk and another for automated clients. Each
non-null gateway_id is owned by one API gateway process so process-local
session and event state remain unambiguous. Read-only gateway_id: null
credentials can appear in more than one entry.
Start the process¶
Installed mode:
Developer mode:
Useful options:
| Option | Default | Description |
|---|---|---|
--host ADDR |
config value | Override HTTP bind address |
--port PORT |
config value | Override HTTP listen port |
--instance NAME |
auto-selected only when one entry exists | Select a named api_gateways entry |
--engine-host HOST |
config value | Override engine host for ZMQ ports 5555/5556 |
--stats-db PATH |
config value | SQLite database for /history/* |
--log-level LEVEL |
config value | debug, info, warning, or error |
Uvicorn writes access and application logs to stdout/stderr. Redirect them with your shell or service manager:
Swagger interface¶
When swagger_enabled: true, open:
Swagger shows all REST endpoints, request schemas, response schemas, and enum values. Use the Authorize button with:
Authentication principles¶
REST clients send an HTTP bearer token:
WebSocket clients send the API key as their first JSON message:
Auth and roles¶
The gateway checks auth in two steps:
- The presented API key must exist in
api_gateways.<NAME>.credentials[].api_key. - For trading/admin actions, the key must map to a non-null
gateway_idand that gateway must be accepted by the engine allowlist and role model.
Access classes used across this guide:
Authenticated key(orAuthentication key): any configured API key.Trading key: an authenticated key with a non-nullgateway_id.Admin key: a trading key whose mapped gateway role resolves toADMIN.
What each class can do:
- Authenticated key: reference data and public history/market-data endpoints.
- Trading key: everything above plus private order/position/trading endpoints.
- Admin key: everything above plus
/api/v1/admin/*.
Read-only credentials (gateway_id: null) can use public and reference
surfaces but cannot submit, cancel, or inspect private orders.
Key types and provisioning¶
Keys are configured centrally in engine_config.yaml under
api_gateways.<NAME>.credentials.
api_gateways:
desk:
credentials:
- api_key: key-trader-demo
gateway_id: TRADER01
description: Trading client
- api_key: key-dashboard-demo
gateway_id: null
description: Read-only dashboard
- api_key: key-admin-demo
gateway_id: ADMIN01
description: Admin operator client
Provisioning rules:
gateway_id: nullcreates a read-only authenticated key.gateway_id: <GW_ID>creates a trading-capable key bound to that gateway.- ADMIN access is not set on the API key itself; it comes from the mapped
gateway role in
gateways.alf. - Non-null
gateway_idvalues must be unique acrossapi_gatewaysentries.
How to get a key in practice:
- Keys are issued out-of-band by the venue/operator team.
- There is no self-service endpoint to create or rotate keys in
pm-api-gwy. - After key changes in config, restart/redeploy the API gateway instance.
Usage examples:
Trading REST call:
Read-only REST call:
Admin REST call:
Read-only WebSocket auth:
For strict endpoint-by-endpoint access rules, see Appendix: REST API Reference.
| Error code | Status | Cause |
|---|---|---|
AUTH |
401 |
Missing/malformed Authorization header, or an unrecognized API key |
ENGINE_AUTH |
403 |
The credential's gateway_id isn't allowed by the engine's gateways.alf list |
READ_ONLY |
403 |
A gateway_id: null credential called a trading-only endpoint |
ROLE_DENIED |
403 |
Credential's gateway lacks the ADMIN role on an /admin/* call |
RATE_LIMIT |
429 |
Per-key write rate limit exceeded |
DUPLICATE |
409 |
client_order_id already active for the session |
VALIDATION |
422/400 |
Malformed request body or query parameters |
STATS_DB |
503 |
pm-stats' SQLite file doesn't exist yet |
AUDIT_INDEX_UNAVAILABLE |
503 |
No pm-audit index — only affects GET /admin/orders/{order_id} |
UNKNOWN_ORDER |
404 |
No audited events for that order id |
TRANSITION_REJECTED |
409 |
The engine will not perform the requested session transition |
RELOAD_REJECTED |
409 |
POST /admin/reference/reload was rejected — usually because the file's symbol/index set changed |
REBALANCE_REJECTED |
409 |
POST /admin/indexes/{id}/rebalance was rejected by pm-index — see the ack reason |
ENGINE_TIMEOUT |
503 |
No engine reply within the configured timeout |
INDEX_TIMEOUT |
503 |
No pm-index reply within the configured timeout |
INDEX_ERROR |
502 |
pm-index rejected the request |
REST endpoints¶
Base path: /api/v1.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/orders |
trading | Submit one order |
DELETE |
/orders/{order_id} |
trading | Cancel one order |
PATCH |
/orders/{order_id} |
trading | Amend price and/or quantity |
POST |
/orders/{order_id}/replace |
trading | Cancel then submit replacement |
GET |
/orders |
trading | List live orders for the gateway |
GET |
/orders/{order_id} |
trading | Read cached order state |
POST |
/oco |
trading | Submit OCO pair |
DELETE |
/oco/{oco_id} |
trading | Cancel OCO pair |
POST |
/combos |
trading | Submit combo order |
DELETE |
/combos/{combo_id} |
trading | Cancel combo |
POST |
/quotes |
trading | Submit two-sided quote |
DELETE |
/quotes/{symbol} |
trading | Cancel quote for symbol |
POST |
/mass-cancel |
trading | Cancel all or symbol-scoped exposure |
POST |
/kill-switch |
trading | Alias of /mass-cancel |
GET |
/symbols |
trading | Instrument metadata |
GET |
/session |
trading | Current engine session state |
GET |
/reference |
any valid key | Full compiled reference-data bundle |
GET |
/reference/config-version |
any valid key | Content-hash version of reference data |
GET |
/reference/symbols |
any valid key | Tick sizes, risk level, collar/circuit-breaker config per symbol |
GET |
/reference/risk |
any valid key | Risk level definitions and the default level |
GET |
/reference/indexes |
any valid key | Configured exchange index definitions |
GET |
/reference/schedule |
any valid key | Session schedule, sessions_enabled, country |
GET |
/quotes/bootstrap |
trading | Active quote bootstrap state |
GET |
/quotes/legs |
trading | Quote leg state |
GET |
/positions |
trading | Net positions by symbol |
GET |
/status |
trading | Gateway cache summary |
GET |
/history/orders |
trading | Historical order lifecycle events |
GET |
/history/orders/{order_id} |
trading | Full lifecycle for one order |
GET |
/history/fills |
trading | Historical fills |
GET |
/history/trades |
any valid key | Public trade log |
GET |
/history/daily |
any valid key | Daily OHLCV rows |
GET |
/history/price-snapshots |
any valid key | Intraday instrument mid/bid/ask time series |
GET |
/history/index-daily |
any valid key | Daily index OHLC rows |
GET |
/history/index-snapshots |
any valid key | Intraday index level time series |
GET |
/history/index-ids |
any valid key | Index IDs with recorded statistics |
GET |
/history/index-events |
any valid key | Index structural/audit log (live pm-index round-trip) |
GET |
/bootstrap/trader |
any valid key | One-request TRADER/MM/ADMIN startup payload |
GET |
/bootstrap/mm |
MARKET_MAKER | One-request MARKET_MAKER startup payload (adds quote state) |
GET |
/bootstrap/admin |
admin | One-request ADMIN startup payload |
GET |
/healthz |
none | Liveness probe (not in Swagger) |
Admin endpoints are documented separately under Admin endpoints.
Submit order¶
{
"symbol": "AAPL",
"side": "BUY",
"order_type": "LIMIT",
"quantity": 100,
"tif": "DAY",
"price": 150.50,
"smp_action": "NONE",
"client_order_id": "ui-42"
}
| Field | Required | Notes |
|---|---|---|
symbol |
yes | Instrument symbol |
side |
yes | BUY or SELL |
order_type |
yes | MARKET, LIMIT, STOP, STOP_LIMIT, FOK, ICEBERG, IOC, TRAILING_STOP |
quantity |
yes | Positive integer |
tif |
no | DAY, GTC, ATO, ATC; default DAY |
price |
conditional | Required for LIMIT, FOK, IOC, ICEBERG, STOP_LIMIT |
stop_price |
conditional | Required for STOP, STOP_LIMIT |
visible_qty |
conditional | Required for ICEBERG, less than quantity |
trail_offset |
conditional | Required for TRAILING_STOP |
smp_action |
no | Self-match prevention action |
Default write calls return immediately with 202 Accepted. Add ?wait=ack to
wait for the matching engine ACK until the configured timeout. The wait filters
by order_id so concurrent requests on the same gateway receive their own ack.
Submitting an order with a client_order_id that already exists in the session
cache returns 409 Conflict.
Cancel, amend, and replace¶
| Operation | Payload |
|---|---|
DELETE /orders/{order_id} |
no body |
PATCH /orders/{order_id} |
{ "price": 151.00 }, { "quantity": 200 }, or both |
POST /orders/{order_id}/replace |
same shape as POST /orders |
?wait=ack is not limited to POST /orders — both DELETE /orders/{order_id}
and PATCH /orders/{order_id} also accept it, waiting on the matching
order.cancelled.*/order.amended.* event the same way. POST
/orders/{order_id}/replace has no wait parameter; it always waits
synchronously for the cancel to be acknowledged before submitting the
replacement (see Implementation notes).
OCO, combos, quotes, and mass cancel¶
| Endpoint | Minimal payload |
|---|---|
POST /oco |
{ "oco_id":"tp-sl-1", "symbol":"AAPL", "quantity":100, "leg1":{"side":"SELL","order_type":"LIMIT","price":152.0}, "leg2":{"side":"SELL","order_type":"STOP","stop_price":147.0} } |
POST /combos |
{ "combo_id":"spread-1", "legs":[{"symbol":"AAPL","side":"BUY","quantity":100,"price":150.0},{"symbol":"MSFT","side":"SELL","quantity":100,"price":410.0}] } |
POST /quotes |
{ "symbol":"AAPL", "bid_price":150.0, "bid_qty":500, "ask_price":150.1, "ask_qty":500 } |
POST /mass-cancel |
{ "symbol":"AAPL" } or {} for all symbols |
Orders, positions, and reference data¶
| Endpoint | Returns | Notes |
|---|---|---|
GET /orders |
{ "orders": [...] } |
Live orders for the caller's gateway, keyed off the gateway's order cache; requests a fresh snapshot from the engine and falls back to the cache on timeout |
GET /orders/{order_id} |
The cached order dict for order_id |
Read-only, served entirely from the gateway's local cache (no engine round-trip); returns 404 with a plain {"detail": "Unknown order"} body if not found — not the {"error": {...}} envelope used by every other error response in this gateway |
GET /symbols |
{ "symbols": [...] } |
Instrument metadata, round-tripped from the engine's system.symbols_request |
GET /session |
Current SessionState and schedule info |
Round-tripped from the engine's system.session_status reply |
GET /quotes/bootstrap |
Active MM quote bootstrap state | Round-tripped from the engine |
GET /quotes/legs |
{ "legs": [...], "recent": [...], "show_requested":..., "complete":... } |
Served from the gateway's local quote-leg cache when populated, otherwise round-tripped from the engine. legs and recent are always present, empty when the requested half does not include them |
GET /positions |
{ "positions": [{"symbol", "net_qty", "last_price"}, ...] } |
Computed entirely from the gateway's local fill cache — no engine round-trip |
All of the round-tripped endpoints above return 503 with error code
ENGINE_TIMEOUT if the engine doesn't reply within timeouts.engine_reply_sec.
Reference data¶
Base path: /api/v1/reference. These endpoints serve the engine's
compiled, static reference data — the resolved settings a client needs to
interpret prices and risk state correctly (tick sizes, resolved risk-band
collars, circuit-breaker ladders, session schedule, index definitions)
without parsing engine_config.yaml or depending on internal engine
structures. They are distinct from GET /symbols and GET /session, which
report live state (current halts, prev_close, current SessionState):
reference data changes only when an admin reloads it.
| Endpoint | Returns | Notes |
|---|---|---|
GET /reference |
The full bundle: symbols, risk, indexes, schedule, config_version |
One call for a client that wants everything |
GET /reference/config-version |
{ "config_version": "..." } |
A content hash — see below |
GET /reference/symbols |
{ "symbols": [{symbol, tick_decimals, level?, collar?, circuit_breaker?}], "config_version":... } |
A list, not a map: each entry carries its own symbol, so a client can iterate without knowing the keys. collar/circuit_breaker are omitted for a symbol with neither configured |
GET /reference/risk |
{ "default_level"?:..., "levels": [{name, collar?}], "config_version":... } |
Risk-band definitions referenced by each symbol's level. collar is omitted for a level that configures none |
GET /reference/indexes |
{ "indexes": [{id, description, base_value, constituents}], "config_version":... } |
Configured exchange indexes; empty list if none configured |
GET /reference/schedule |
{ "sessions_enabled":..., "country"?:..., "schedule": {pre_open, opening_auction_start, continuous_start, closing_auction_start, closing_auction_end} \| null, "config_version":... } |
The five clock times are nested under schedule, which is the same record system.session_schedule carries. schedule: null means no schedule: block is configured |
All six accept any valid API key, including read-only (gateway_id: null)
credentials — this is metadata, not account or order data. Every response
round-trips to the engine (no gateway-side cache, so a reload is reflected
immediately) and returns 503 ENGINE_TIMEOUT under the same conditions as
GET /symbols.
{
"symbols": {
"AAPL": {
"tick_size": 0.01,
"level": "STANDARD",
"collar": { "static_band_pct": 0.20, "dynamic_band_pct": 0.02 },
"circuit_breaker": {
"reference_window_ns": 300000000000,
"levels": [
{ "name": "L1", "price_shift_pct": 0.07, "halt_duration_ns": 300000000000 },
{ "name": "L2", "price_shift_pct": 0.13, "halt_duration_ns": 900000000000 },
{ "name": "L3", "price_shift_pct": 0.20, "halt_duration_ns": null }
]
}
}
},
"config_version": "3f2a9c1e7b0d4a5f"
}
config_version¶
A 16-character hex prefix of a SHA-256 hash over the compiled reference
bundle, computed once when the engine loads or reloads its config — not
recomputed per request. It changes if and only if the compiled reference
data changes, so a client can cache reference data and cheaply poll
GET /reference/config-version to know when to refetch, rather than diffing
the full bundle. There is no other versioning scheme (no counter, no
timestamp) — treat the string as opaque.
Reloading reference data¶
POST /api/v1/admin/reference/reload (ADMIN-only) re-reads the same
engine_config.yaml the engine started from and applies any change to tick
sizes, risk-band collars, circuit-breaker ladders, the schedule, or index
descriptions/constituents-within-an-unchanged-index-set.
It is intentionally narrower than a full engine restart:
- It never creates or removes an order book, never re-seeds market-maker quotes, and never touches session or halt state — those only happen once, at engine startup, and re-running them mid-session would double-seed quotes and republish trades.
- If the reloaded file's symbol set or index-id set differs from what is
currently live, the reload is rejected with
409 RELOAD_REJECTEDand nothing is applied — adding or removing an instrument still requires a restart. - If the engine was started from a compiled config artifact rather than a plain YAML file, reload is rejected — there is no single file to re-read.
- Like other admin writes, it is subject to the per-key rate limit and
returns
503 ENGINE_TIMEOUTif the engine doesn't reply in time.
This is meant for controlled reloads in development/classroom mode — tuning a risk band or circuit-breaker threshold between drills without restarting the whole exchange — not for live production config changes.
History endpoints¶
Base path: /api/v1/history. Every endpoint except /history/index-events
reads from pm-stats' SQLite database (--stats-db PATH, default
data/stats.db); the gateway returns 503 with error code STATS_DB if
that file does not exist yet (for example, before pm-stats has run at
least once). /history/index-events is the one exception — see its own
section below.
/history/orders, /history/orders/{order_id}, and /history/fills require
a trading credential and are scoped to that credential's gateway_id — they
only ever return that gateway's own orders. /history/trades,
/history/daily, /history/price-snapshots, /history/index-daily,
/history/index-snapshots, /history/index-ids, and /history/index-events
are public market data: any valid API key works, including read-only keys
with no gateway_id.
| Endpoint | Query parameters | Notes |
|---|---|---|
GET /history/orders |
symbol, event_type, date, from, to, limit (1–5000, default 500), after |
Trading credential only; scoped to the caller's gateway_id |
GET /history/orders/{order_id} |
none (path parameter only) | Trading credential only; full lifecycle for one order, scoped to the caller's gateway_id; unbounded and unpaginated — see the Pagination exceptions note below |
GET /history/fills |
symbol, date, from, to, limit, after |
Trading credential only; event_type=FILL events for the caller's gateway_id |
GET /history/trades |
symbol, date, from, to, limit, after |
Public trade tape |
GET /history/daily |
symbol, date, from, to, limit, after |
Omitting every time filter returns the latest available date; from/to (inclusive, dates not timestamps) return a series across days, oldest first |
GET /history/price-snapshots |
symbol (required), date, from, to, limit, after |
Intraday mid/bid/ask ticks (15-minute recording interval); unlike /trades//daily there is no "all symbols" mode |
GET /history/index-daily |
index_id, date, from, to, limit, after |
Same shape as /daily but for exchange indexes, including the from/to range |
GET /history/index-snapshots |
index_id (required), date, from, to, limit, after |
Intraday index level ticks; unlike /trades//daily there is no "all indexes" mode |
GET /history/index-ids |
date |
List of index IDs with recorded data; unpaginated |
GET /history/index-events |
index_id (required), from, to, types, max_records |
Structural/audit log; live round-trip to pm-index, not pm-stats — see below |
Daily rollups: one date, or a series¶
/history/daily and /history/index-daily answer two different questions
depending on which time filter you pass:
| Parameters | Result |
|---|---|
| (none) | The latest available date only |
date=YYYY-MM-DD |
That one date |
from= and/or to= |
Every date in range, oldest first; bounds are inclusive and either may be omitted |
from/to here are dates (YYYY-MM-DD), unlike /trades and
/price-snapshots where they are ISO timestamps — these tables are keyed by
date, not by tick time. Passing date together with a range is not an error;
the specific date wins.
Use the range for anything spanning days, such as a multi-day OHLC chart. Without it, a month of bars would take one request per calendar day.
GET /api/v1/history/daily?symbol=AAPL&from=2026-06-01&to=2026-06-30
Authorization: Bearer key-readonly-demo
The cursor differs between the two modes. Within a single date, symbol (or
index_id) alone identifies a row. Across a range it does not — the same
symbol appears on every date — so the range cursor carries (date, symbol).
Both are opaque either way; just pass next_cursor back as after.
Pagination¶
Every list-returning endpoint wraps its rows in an envelope with count and
has_more — a boolean that is true when the page came back full (exactly
limit rows), meaning more rows may exist. When has_more is true, the
response also includes next_cursor, an opaque string. Pass it back as the
after query parameter to fetch the next page; omit it to start from the
beginning. Cursors are keyset-based (not a row offset), so pages stay
correct — no skipped or duplicated rows — even if new data is being written
concurrently. Treat the cursor string as opaque: its internal shape is not a
stable contract and may change between releases.
Pagination exceptions
Three endpoints do not follow the count/has_more/next_cursor contract
above: GET /history/index-ids and GET /history/index-events are each
documented separately below as intentionally unbounded/unpaginated.
GET /history/orders/{order_id} is also unbounded — it returns
{ "events": [...], "count": N } with no has_more and no pagination at
all, since it's a single order's full lifecycle rather than an
open-ended list.
{
"trades": [
{ "ts": "2026-06-14T09:00:00.000+00:00", "trade_id": "T000", "symbol": "EDU100", "price": 100.0, "quantity": 10, "buy_gateway_id": "GW1", "sell_gateway_id": "GW2" },
{ "ts": "2026-06-14T09:01:00.000+00:00", "trade_id": "T001", "symbol": "EDU100", "price": 100.0, "quantity": 10, "buy_gateway_id": "GW1", "sell_gateway_id": "GW2" }
],
"count": 2,
"has_more": true,
"next_cursor": "eyJyb3dpZCI6MiwidHMiOiIyMDI2LTA2LTE0VDA5OjAxOjAwLjAwMCswMDowMCJ9"
}
GET /api/v1/history/trades?symbol=EDU100&limit=2&after=eyJyb3dpZCI6MiwidHMiOiIyMDI2LTA2LTE0VDA5OjAxOjAwLjAwMCswMDowMCJ9
Authorization: Bearer key-readonly-demo
returns the next two trades, and so on until a response comes back with
has_more: false and no next_cursor. A malformed or expired-schema
after value returns 422 with error code VALIDATION.
/history/index-ids has no limit/after — the number of distinct
exchange indexes is always small (EduMatcher caps this at 5 per config
file), so it is intentionally unbounded and unpaginated.
GET /api/v1/history/price-snapshots?symbol=AAPL&from=2026-06-14T09:00:00%2B00:00&to=2026-06-14T16:30:00%2B00:00&limit=100
Authorization: Bearer key-readonly-demo
{
"snapshots": [
{ "ts": "2026-06-14T09:00:00.000+00:00", "symbol": "AAPL", "mid_price": 150.5, "best_bid": 150.0, "best_ask": 151.0, "pct_change": null },
{ "ts": "2026-06-14T09:15:00.000+00:00", "symbol": "AAPL", "mid_price": 151.0, "best_bid": 150.5, "best_ask": 151.5, "pct_change": 0.3322 }
],
"count": 2,
"has_more": false
}
Rows come from pm-stats' periodic book snapshots — recorded at a fixed
interval (15 minutes by default, overridable via pm-stats --snapshot-interval
SEC), not on every tick. For live tick-by-tick mid-price movement, use the
CALF TOP channel instead; this endpoint is for historical/charting use,
not a substitute for a live feed. pct_change is the percent change versus
the previous recorded snapshot for that symbol (not versus the day's
open), and is null for the first snapshot recorded for a symbol since
pm-stats started, since there is no prior snapshot to compare against.
GET /api/v1/history/index-daily?index_id=EDU100&date=2026-06-14
Authorization: Bearer key-readonly-demo
{
"daily": [
{
"date": "2026-06-14",
"index_id": "EDU100",
"open_level": 1042.10,
"high_level": 1056.30,
"low_level": 1040.05,
"close_level": 1048.73,
"close_session_state": "CLOSED",
"open_aggregate_cap": 7300000000000.0,
"close_aggregate_cap": 7350000000000.0,
"update_count": 512
}
],
"count": 1,
"has_more": false
}
close_level is only final once close_session_state is CLOSED
close_level reflects the most recently recorded index.update for that
date. For a past date this is always final. For the current date, while
the session is still open, close_level is a live "latest tick so far"
and will keep changing — check close_session_state == "CLOSED" (or wait
for the date to roll over) before treating it as the official close. See
Statistics & Reporting.
GET /api/v1/history/index-snapshots?index_id=EDU100&from=2026-06-14T09:00:00%2B00:00&to=2026-06-14T16:30:00%2B00:00&limit=100
Authorization: Bearer key-readonly-demo
If no exchange index is configured, or pm-index/pm-stats have not run
yet, index-daily, index-snapshots, and price-snapshots return an empty
list (not an error) and index-ids returns { "index_ids": [], "count": 0 }.
Index structural/audit events¶
/history/index-events is unlike every other endpoint on this page: it does
not read pm-stats' SQLite data at all. pm-index's structural/audit log
(index creation, corporate actions, constituent additions, delistings) lives
only in pm-index's own append-only file and is never mirrored into
pm-stats, so answering this requires a live ZMQ request/reply round-trip to
the pm-index process itself. Practically, this means:
- It can return
503with error codeINDEX_TIMEOUTifpm-indexis not running or does not reply within the configured timeout — independent of whetherstats.dbexists. - It can return
502with error codeINDEX_ERRORifpm-indexrejects the request (for example, an unknownindex_id). - There is no
limit/has_more/afterpagination;max_records(default and max 10,000) caps the reply size directly, matchingpm-index's own request/reply contract.
GET /api/v1/history/index-events?index_id=EDU100&from=1750000000&to=1760000000
Authorization: Bearer key-readonly-demo
{
"events": [
{ "type": "INIT", "timestamp": 1750000012.5, "index_id": "EDU100" },
{ "type": "CORP_ACTION", "timestamp": 1751234000.0, "index_id": "EDU100", "action": "SPLIT", "symbol": "AAPL" }
],
"count": 2
}
from/to are Unix timestamps in seconds (not the ISO-8601 strings used by
the SQLite-backed endpoints), defaulting to the last 30 days and now
respectively — matching pm-index's own defaults. types restricts the
reply to a subset of INIT, CORP_ACTION, ADD_CONSTITUENT, DELIST
(repeat the query parameter for multiple values); omitting it returns all
four. There are no level or end-of-day tick records here — use
/history/index-daily and /history/index-snapshots for those.
Bootstrap endpoints¶
Bootstrap endpoints collapse the 6–13 sequential REST calls a browser client currently needs at login into a single round-trip per role. Sub-queries inside each handler run in parallel; the total wall-clock time is the slowest of the concurrent engine queries, not their sum.
How partial failures work¶
Each response carries an incomplete array. When any optional sub-query
times out or errors, the corresponding field is set to null and its name is
appended to incomplete. The rest of the response is still valid and useful.
Required fields — reference and orders for /bootstrap/trader and
/bootstrap/mm; reference for /bootstrap/admin — return 503
ENGINE_TIMEOUT if they fail rather than a partial response, because the UI
cannot render anything meaningful without them.
Role access¶
| Endpoint | Allowed | Returns 403 for |
|---|---|---|
GET /api/v1/bootstrap/trader |
Any valid key | — |
GET /api/v1/bootstrap/mm |
MARKET_MAKER | TRADER, ADMIN, read-only |
GET /api/v1/bootstrap/admin |
ADMIN | TRADER, MARKET_MAKER, read-only |
Read-only credentials (no gateway_id) may call /bootstrap/trader. They
receive gateway_role: "READ_ONLY", empty positions, and empty orders
without triggering any engine round-trip for those fields.
fills_limit query parameter¶
/bootstrap/trader and /bootstrap/mm accept an optional fills_limit
integer query parameter (default 50, max 500) that controls how many of
today's fill events are returned in the recent_fills field. The engine
session timezone in the stats database determines what "today" means,
consistent with GET /api/v1/history/fills.
Updated login sequence¶
With a bootstrap endpoint the login sequence becomes:
GET /api/v1/bootstrap/<role>— one HTTP request with parallel internal engine queries; populates identity, reference data, session state, orders, positions, and capability flags before any WebSocket opens.- Open WebSockets in parallel —
/events,/market-data, and (ADMIN)/admin/monitor— all of which can start immediately becausegateway_idandgateway_roleare already known from step 1.
For the full response shapes and error codes see Appendix: REST API Reference — Bootstrap.
Admin endpoints¶
Base path: /api/v1/admin.
These endpoints require an API key whose gateway_id maps to an engine gateway
configured with the ADMIN role (gateways.alf[].role: ADMIN). The gateway
role is resolved from the engine at call time, not from the API credential.
Callers without the ADMIN role receive 403 with error code ROLE_DENIED.
Role source
The API credential store does not carry role. The gateway resolves and caches the ADMIN role from the engine's gateway list reply, so the first admin call performs one extra engine round-trip.
| Method | Path | Request body | Response | Engine topic |
|---|---|---|---|---|
POST |
/admin/session/transition |
{ "to_state": "CONTINUOUS" } |
{ "requested_state": ..., "status":"PENDING" } |
session.transition |
GET |
/admin/session/schedule |
none | { "sessions_enabled":..., "schedule":{...} } |
system.session_schedule_request |
GET |
/admin/gateways |
none | { "gateways":[{id,role,description,connected}] } |
system.gateways_request |
POST |
/admin/gateways/{gid}/disconnect |
none | { "gateway_id":..., "status":"DISCONNECTED" } |
system.gateway_disconnect |
POST |
/admin/circuit-breaker/trigger |
{ "symbol":"AAPL", "level": "L1", "reason":null } |
engine halt ack | risk.symbol_halt |
POST |
/admin/circuit-breaker/resume |
{ "symbol":"AAPL", "reason":null } |
engine resume ack | risk.symbol_resume |
GET |
/admin/halts |
none | { "halted":[{symbol,resume_at_ns?,level?,...}] } |
system.halt_status_request |
GET |
/admin/risk/state |
none | { "symbols": [{symbol, collar_reference_price?, circuit_breaker?}] } |
system.risk_state_request |
GET |
/admin/orders |
?symbol=&gateway_id=&status= |
{ "count":N, "orders":[...], "retention_sec":N } |
none — served from cache |
GET |
/admin/orders/{order_id} |
?limit= |
{ "order_id":..., "count":N, "events":[...] } |
none — read from audit_index.db |
POST |
/admin/kill-switch/symbol |
{ "symbol":"AAPL", "reason":null } |
engine cancel-symbol ack | risk.cancel_symbol |
POST |
/admin/kill-switch/gateway |
{ "target_gateway_id":"TRADER02", "reason":null } |
engine gateway-targeted kill-switch ack | risk.kill_switch_gateway |
POST |
/admin/kill-switch/global |
{ "reason":null } |
engine market-wide kill-switch ack | risk.kill_switch_global |
GET |
/admin/indexes |
none | { "indexes":[{id,description,base_value,constituents}], "config_version":... } |
none — reuses the reference-data bundle |
POST |
/admin/indexes/{id}/rebalance |
{ "updates":[{"symbol":"AAPL","new_shares_outstanding":123}], "reason":null } |
pm-index rebalance ack | index.rebalance (pm-index, not the engine) |
POST |
/admin/reference/reload |
none | { "status":"RELOADED", "config_version":... } |
system.reference_reload |
Behaviour notes:
POST /admin/session/transitionwaits for the engine's verdict and returnsstatus: APPLIEDwith acommand_id, or 409TRANSITION_REJECTEDwhen the engine will not perform it (sessions not enabled, unknown state). It was previously fire-and-forget, which meant a rejected request was indistinguishable from a slow one.to_statemust be a validSessionState(PRE_OPEN,OPENING_AUCTION,CONTINUOUS,CLOSING_AUCTION,CLOSED). See Command correlation.- The circuit-breaker and kill-switch endpoints wait for the matching engine ACK.
When the engine rejects the command (for example, an ADMIN-gate or validation
failure) the ack carries
accepted: falseand the gateway returns403with the engine'sreason. POST /admin/circuit-breaker/trigger'slevel, when it names one of the symbol's configuredcircuit_breaker.levels, runs the halt through the same activation a price-triggered breaker uses — a realresume_at_nsand ACE reopening corridor, picked up automatically on the next resume tick. Omit it (or leave itnull) for the previous behaviour: an indefinite halt cleared only by an explicitPOST /admin/circuit-breaker/resume. An unrecognized level name, or a level on a symbol with no circuit breaker configured, is rejected.- The circuit-breaker, kill-switch, and rebalance endpoints accept an
optional
reasonfield — a free-text note. For the engine-backed endpoints (everything except rebalance) it is carried through to the correspondingadmin.actionmonitor event under the keynote; it is not otherwise interpreted by the engine orpm-index. - Write endpoints (
POST) are subject to the same per-key write rate limit as order entry and return429when the limit is exceeded. - Requests that receive no engine reply within the configured timeout return
503with error codeENGINE_TIMEOUT(orINDEX_TIMEOUTfor the twopm-index-backed endpoints,/admin/indexesand/admin/indexes/{id}/rebalance).
POST /admin/kill-switch/gateway and /admin/kill-switch/global¶
Two admin-only kill-switch scopes beyond the existing self-service
POST /kill-switch (caller's own gateway) and POST /admin/kill-switch/symbol
(one symbol, every gateway):
/admin/kill-switch/gatewaycancels every resting order and quote belonging totarget_gateway_id, across every symbol. UnlikePOST /kill-switch, the caller (must hold the ADMIN role) and the affected gateway are different participants./admin/kill-switch/globalis the full-market emergency stop: every resting order and quote, for every gateway, across every symbol, cancelled outright. This is distinct fromPOST /admin/circuit-breaker/triggerapplied symbol-by-symbol, or from a global circuit-breaker halt — those stop trading, while resting orders remain; this cancels the resting exposure itself. The response includesaffected_gateways, the count of distinct gateways that had something cancelled.
Both return 409 with the engine's reason if rejected (for example,
target_gateway_id missing, or the caller lacks the ADMIN role).
GET /admin/risk/state¶
Live per-symbol risk state — the current collar reference price and circuit breaker reference/trigger/expansion/corridor state, for every symbol that has either configured, halted or not:
{
"symbols": {
"AAPL": {
"collar_reference_price": 150.25,
"circuit_breaker": {
"halted": false,
"reference_price": 150.10,
"trigger_price": null,
"triggered_level": null,
"expansion_index": 0,
"corridor": { "corridor_low": null, "corridor_high": null, "expansion": null },
"resume_at_ns": null
}
}
}
}
This is distinct from GET /reference/risk (static,
named risk-level definitions shared across symbols) and from
GET /admin/halts (only the symbols currently halted, without the
non-halted reference/reopening detail). Use /admin/risk/state when you need
to see where a symbol's breaker actually stands right now, not just whether
it has fired.
Index administration (pm-index bridge)¶
GET /admin/indexes and POST /admin/indexes/{id}/rebalance are the one
place in this router that talks to pm-index instead of the engine — over
its own ZMQ PULL/PUB pair, the same one
pm-index-admin-cli uses for corporate actions and
constituent changes. Live symbol add/update on the engine and full corporate
actions/constituent changes on pm-index remain not exposed here — see
the note below.
GET /admin/indexes returns the same static configuration as
GET /reference/indexes (id, description, base_value,
constituents) — not live level/divisor. For the current level, use
GET /history/index-daily.
POST /admin/indexes/{id}/rebalance applies a batch shares-outstanding
update to existing constituents — each entry in updates mirrors the
SHARES_ISSUANCE corporate action applied to one symbol, but the whole batch
is validated (unknown symbols, non-positive share counts, duplicates) before
any of it is applied, and the index level is recomputed and published once
for the batch rather than once per symbol:
{
"accepted": true,
"reason": "",
"timestamp": 1750000000.0,
"updated_symbols": 1,
"index_id": "EDU100",
"level": 1048.90,
"divisor": 7123456.78
}
A rejected batch returns 409 REBALANCE_REJECTED with the reason (which
entry was invalid, or why); it can only fail once mutation has begun in the
rare case where an already-validated update still fails inside the
calculator (a non-positive aggregate cap) — whatever updates in the batch
had already applied at that point stay applied, matching the same limitation
the single-action corporate-action endpoint accepts. rebalance cannot add
or remove constituents or an index's symbol set — that remains
pm-index-admin-cli-only, same as splits, dividends, and delistings.
Not currently exposed
Live symbol add/update on the engine is not exposed as a REST endpoint —
the engine loads its symbol universe once at startup, and adding one
mid-session would require creating an order book and seeding
market-maker quotes outside the startup path that currently owns both.
Corporate actions (splits, dividends, shares issuance as a single-symbol
action) and constituent add/delist on pm-index are also not exposed
here — only the batch shares-outstanding rebalance above is. Use
pm-index-admin-cli for those.
pm-index-cli
is unrelated to pm-index's ZMQ sockets: it is a read-only tool that
parses pm-index's structural/audit JSONL files directly from disk.
admin.action monitor events¶
Every admin-gated engine command above — circuit-breaker trigger/resume
and all three kill-switch scopes — publishes one admin.action event on the
/api/v1/admin/monitor WebSocket in addition to
(never instead of) its own REST response. This gives a monitor client one
uniform shape to watch regardless of which command ran, rather than needing
to know each command's own ack shape:
{
"type": "admin.action",
"topic": "admin.action.ADMIN01",
"ts": "2026-08-05T09:30:00.000Z",
"data": {
"command_id": "cmd-...",
"initiator_gateway_id": "ADMIN01",
"action": "circuit_breaker.trigger",
"scope": { "symbol": "AAPL", "level": "L1", "note": "drill" },
"accepted": true,
"reason": ""
}
}
action is one of circuit_breaker.trigger, circuit_breaker.resume,
kill_switch.self, kill_switch.symbol, kill_switch.gateway,
kill_switch.global. scope carries what the command acted on and what it
did, and every key is optional because each action uses a different subset.
The set is closed — since phase 6.1d it is a declared record, and a key
outside it cannot reach the wire:
| Key | Type | Present on |
|---|---|---|
symbol |
str | the per-symbol actions |
target_gateway_id |
str | kill_switch.gateway |
level |
str | circuit_breaker.trigger |
note |
str | any action carrying the request's reason |
cancelled_orders |
int | accepted kill switches |
cancelled_quotes |
int | accepted kill switches |
affected_gateways |
int | an accepted kill_switch.global |
A key whose value is unset is absent rather than null, and scope is
{} on a rejection that named nothing. This event is admin-monitor-only: it
never reaches a trading gateway's private stream or the public market-data
stream, regardless of which gateway initiated it.
Index rebalance does not emit admin.action
POST /admin/indexes/{id}/rebalance talks to pm-index, a separate
process from the engine that the admin-monitor fan-out is not wired to.
Its own REST response (and pm-index's append-only structural history,
a REBALANCE record readable via GET /history/index-events) is
currently the only record of it — it does not appear on
/api/v1/admin/monitor.
Extended GET /status¶
GET /api/v1/status now includes gateway_role (the resolved
TRADER/MARKET_MAKER/ADMIN role) alongside the existing cache summary
fields. When the caller holds the ADMIN role, the response also includes
gateway_count, the number of currently connected gateways.
Cross-gateway admin views¶
These endpoints read pm-audit's database
GET /admin/orders/{order_id} opens audit_index.db read-only. This
is the only place the API gateway reads a store it does not own, and it
exists so the REST API can be a single stop rather than sending an
operator to pm-audit-cli for order history.
The dependency is optional and read-only. The gateway never writes the
file. If pm-audit is not deployed, or its index has not been built, that
one endpoint returns 503 AUDIT_INDEX_UNAVAILABLE naming what to do and
every other route is unaffected. Configure the path with
api_gateways[].audit_db; see Audit Trail for building
the index.
GET /api/v1/admin/orders¶
The cross-gateway active-order table. Served entirely from the gateway's own read model — no engine round-trip — because it already maintains a cache per gateway whose events pass through it.
| Query | Effect |
|---|---|
symbol |
Restrict to one instrument (case-insensitive) |
gateway_id |
Restrict to one participant (case-insensitive) |
status |
Restrict to one order status |
{
"count": 2,
"orders": [ { "order_id": "ORD-...", "gateway_id": "TRADER01", "symbol": "AAPL", "status": "NEW" } ],
"retention_sec": 3600
}
This is current state, not the day's history
Terminal orders age out after order_retention_sec (see
below), which is why the response repeats the
setting: a caller can tell what horizon it is being shown. For anything
older, use the lifecycle endpoint or the audit trail.
gateway_id is added on the way out — order payloads do not carry it, it is
the cache key.
GET /api/v1/admin/orders/{order_id}¶
The complete cross-gateway lifecycle of one order, in timestamp order, read from the audit index.
{
"order_id": "ORD-...",
"count": 3,
"events": [
{ "timestamp": "...", "topic": "order.ack.TRADER01", "gateway_id": "TRADER01", "symbol": "AAPL", "payload": "..." },
{ "timestamp": "...", "topic": "order.fill.TRADER01", "...": "..." }
]
}
| Status | Meaning |
|---|---|
200 |
Events found |
404 UNKNOWN_ORDER |
The index has no events for that id |
503 AUDIT_INDEX_UNAVAILABLE |
No audit index — pm-audit not running, or index not built |
Why not from the gateway's cache. The cache folds each event into current state and keeps no history, so a lifecycle served from it would be a weaker duplicate of an audit trail that already exists, is complete across every gateway, and survives restarts.
Order cache retention¶
The in-memory order cache backs GET /orders, the private orders.snapshot
frame, and GET /admin/orders. Terminal orders — FILLED, CANCELLED,
EXPIRED, REJECTED — are evicted after order_retention_sec (default
3600). Resting orders are never evicted regardless of age; positions are
never affected, since forgetting the order that created one would not undo it.
Set order_retention_sec: 0 to disable eviction, accepting that the cache
then grows for the lifetime of the process.
WebSocket endpoints¶
| Path | Purpose | First message |
|---|---|---|
/api/v1/events |
Private order/quote/risk lifecycle events for one gateway | { "api_key": "key-trader-demo" } |
/api/v1/market-data |
Public book, trade, depth, session, and circuit-breaker events | { "api_key": "key-dashboard-demo" } |
/api/v1/admin/monitor |
ADMIN-only cross-gateway monitor feed (all events) | { "api_key": "key-admin-demo" } |
The /api/v1/admin/monitor stream requires an ADMIN-role gateway. After
authentication it sends { "type": "authenticated" }, then a
monitor.snapshot, and then streams every engine event (order, fill, cancel,
session, and circuit-breaker) across all gateways. Non-admin keys receive an
error frame and are disconnected.
{
"type": "monitor.snapshot",
"ts": "2026-08-05T09:30:00.000Z",
"data": {
"orders": [ { "order_id": "ORD-...", "gateway_id": "TRADER01", "status": "NEW" } ],
"halts": { "halted": ["AAPL"] },
"gateways": { "gateways": [ { "id": "TRADER01", "role": "TRADER" } ] },
"last_seq": { "TRADER01": 9182 },
"incomplete": []
}
}
As with the private stream, the event sink is registered before the snapshot is taken, so the worst case is a duplicate rather than an event lost in the window while the snapshot still looked complete.
halts and gateways come from the engine, not from local state
The gateway's own view of connected participants covers only those that authenticated through this API gateway instance. An admin monitor built on it would silently omit every participant connected over ALF, BALF, or a second API gateway — so the snapshot asks the engine for the venue-wide answer instead.
Both queries are best-effort. If either times out the snapshot is still
delivered with that field null and its name listed in incomplete: a
monitor that opens with a partial view and says so is more useful than one
that refuses to open.
There is no monitor/events?from_seq= replay endpoint
Deliberately. It would need a bounded in-memory ring buffer, which would
be strictly weaker than what already exists — the audit trail is the
durable, complete, indexed cross-gateway event log, and it survives
restarts. Use monitor.snapshot for current state and
GET /admin/orders/{order_id} or
pm-audit-cli for history.
The event envelope¶
Every event on every one of the three sockets uses the same envelope:
{
"type": "order.fill",
"topic": "order.fill.TRADER01",
"seq": 4127,
"ts": "2026-06-24T10:15:03.221Z",
"gateway_id": "TRADER01",
"data": {
"order_id": "ORD-...",
"fill_qty": 50,
"fill_price": 150.50,
"remaining_qty": 50,
"status": "PARTIAL"
}
}
| Field | Meaning |
|---|---|
type |
Stable public event type (trade, book, depth, auction, session, circuit_breaker, order.fill, …) |
topic |
The engine topic the event came from, and what seq counts within |
seq |
Monotonic sequence number within topic, starting at 1 |
stream_seq |
Monotonic across all of one gateway's private events. Private events only — see Private event recovery |
ts |
Exchange time, not browser receipt time |
gateway_id |
Present on private events only |
data |
The event payload |
Order lifecycle events — order.ack, order.fill, order.cancelled,
order.expired — additionally carry the identifiers that tie the order to the
structure it belongs to, when it belongs to one:
| Field | Present when |
|---|---|
oco_group_id |
The order is one side of an OCO pair |
combo_parent_id |
The order is a combo leg |
leg_index |
The order is a combo leg (0-based) |
quote_id |
The order came from a market-maker quote |
They are omitted rather than null for an ordinary single order. This lets a consumer attribute a fill to its combo or OCO group without joining against its own record of the parent order — which after a reconnect it may not have.
Detecting dropped events¶
Each WebSocket client has a bounded outbound queue. A client that reads more
slowly than the market moves will have events discarded — that is
deliberate, because one slow consumer must not stall the gateway for everyone
else. seq is how you find out it happened.
Track the last seq per topic. A jump means events were dropped:
last: dict[str, int] = {}
async for raw in websocket:
event = json.loads(raw)
topic, seq = event.get("topic"), event.get("seq")
if topic is not None and seq is not None:
previous = last.get(topic)
if previous is not None and seq != previous + 1:
print(f"gap on {topic}: {seq - previous - 1} event(s) lost")
# book/depth carry full state, so the next message re-syncs you.
# A missed trade can be replayed with a `resume` (see below),
# or refetched from the history endpoints if it aged out.
last[topic] = seq
Why seq is per topic, not per connection
A connection-wide counter would arrive with holes wherever an event was
filtered out by your subscription, so every client would see permanent
phantom gaps and none could tell those from real loss. Per-topic numbering
is contiguous for anyone receiving that topic at all. Key your gap
detection on topic, not on type — one type (depth) spans many
topics (depth.AAPL, depth.MSFT), each independently numbered.
book and depth events carry complete state, not deltas, so a client
that missed some simply takes the next one. Trades are the events worth
reacting to: a dropped trade is not repeated on the live feed, but it can be
recovered without leaving the socket — send a
resume with the last seq you saw, and
the gateway replays the buffered prints (falling back to the
history endpoints only when the gap is older than
market_data_cache_sec).
The server side of the same signal is on GET /healthz, which reports
dropped_events per sink (market_data, private, admin). The gateway also
logs a warning on the first drop per sink and every hundredth thereafter.
Command correlation¶
Most commands already carry an identifier you can correlate on, and those are unchanged:
| Command | Correlate on |
|---|---|
POST /orders |
order_id (returned in the 202, echoed on every later event) |
| Combos / OCO | combo_id / oco_id |
| Symbol halt / resume / cancel-symbol | symbol |
| Quotes | symbol, quote_id |
Two commands had nothing to correlate on, and both now issue a command_id:
Mass cancel / kill switch. risk.kill_switch_ack echoes the command_id
of the request that caused it. Before this, two concurrent mass cancels for one
gateway were indistinguishable once both acks were in flight, and the gateway
had to serialise them behind a per-gateway lock to stay correct. They now run
concurrently.
Session transition. POST /admin/session/transition used to be
fire-and-forget: it returned 202 PENDING and awaited nothing. It now waits
for the engine's verdict on session.transition_ack.{gateway_id} and returns:
A request the engine cannot perform — sessions not enabled, unknown state —
now returns 409 with TRANSITION_REJECTED and the engine's reason. Those
cases previously produced no reply at all, so a caller saw a timeout
indistinguishable from a slow engine.
Why not a command_id on everything
A second identifier alongside a working one adds ambiguity rather than
removing it — particularly on POST /orders, which already accepts
client_order_id as its idempotency key. The ack correlation is where the
value is; echoing a command_id through every downstream event a command
causes (a mass cancel produces one order.cancelled per affected order)
is a much larger change for much less benefit, and the
group identifiers already let a client attribute
those cascades.
Private event recovery¶
/api/v1/events is designed so a reconnecting client needs one socket and no
REST calls to get back to a correct view.
After authentication the gateway sends two frames, in this order:
{
"type": "orders.snapshot",
"gateway_id": "TRADER01",
"stream_seq": 9182,
"ts": "2026-08-05T09:30:00.000Z",
"data": {
"orders": [ { "order_id": "ORD-...", "status": "NEW", "symbol": "AAPL" } ],
"positions": { "AAPL": 100 },
"quote_legs": []
}
}
The snapshot is the gateway's own view of your order state, maintained from
every engine event and retained across disconnects — so it is available
immediately, without a round-trip to the engine. stream_seq names the point
it is accurate as of: every subsequent event carries a higher stream_seq.
The reconnect procedure is therefore:
- Connect and authenticate.
- Replace your local order state with
orders.snapshot. - Apply live events from there.
Duplicates are possible; gaps are not
The event sink is registered before the snapshot is taken, so an event landing in that window appears both in the snapshot and as a live event. The reverse ordering would lose it silently while the snapshot still looked complete. Order state is idempotent — applying an event twice leaves the same result — so a duplicate is harmless and a gap is not.
stream_seq versus seq. Both are on every private event. seq is
per-topic, matching market data. stream_seq counts every event for your
gateway across all topics, which is possible here only because this socket
applies no filtering — you receive everything for your gateway. Tracking one
stream_seq is simpler than tracking a seq per topic; use whichever suits.
There is no resume for private events
The gateway keeps no replay buffer, so a missed event cannot be re-sent. It does not need to be: order state is recoverable in full from the snapshot. What a snapshot cannot tell you is the transitions you missed — an order that filled and was then cancelled appears simply as cancelled. For the fills themselves use the history endpoints; the drop-copy feed is the authoritative fill record.
Market-data subscriptions¶
Two accepted forms. The flat form applies one channel set to one symbol set:
The items form lets each rule have its own symbols, which is the only way to
express different channels for different instruments:
{
"action": "subscribe",
"items": [
{ "symbols": ["*"], "channels": ["book", "trades"] },
{ "symbols": ["AAPL"], "channels": ["depth", "auction"] }
]
}
That subscribes to top-of-book and trades for every symbol, and the full depth
ladder for AAPL only — the usual shape for an overview grid plus one focused
instrument. The flat form cannot express it: it has a single symbol set shared
by every channel, so asking for depth on AAPL asks for it on everything.
Available channels are book, trades, depth, and auction. An empty or
["*"] symbol list means every symbol. unsubscribe removes exactly the
symbol/channel pairs named.
The server acknowledges every control frame with the effective subscription:
{
"type": "subscription",
"data": {
"items": [
{ "symbols": ["*"], "channels": ["book", "trades"] },
{ "symbols": ["AAPL"], "channels": ["auction", "depth"] }
],
"symbols": ["AAPL"],
"channels": ["auction", "book", "depth", "trades"],
"always": ["session", "circuit_breaker"],
"rejected": []
}
}
symbols and channels are retained for clients written against the earlier
ack. They are lossy by construction — they cannot represent per-symbol
channels — so read items if you need the real answer.
rejected reports rules that did nothing, rather than discarding them
silently:
reason |
Meaning |
|---|---|
no_channels |
The item named symbols but no channels, so it subscribed to nothing |
wildcard_still_subscribed |
You unsubscribed a named symbol on a channel that also has a "*" rule, so events for it keep arriving |
session and circuit_breaker are not subscribable
They are delivered to every market-data client regardless of subscription,
and are reported under always in the ack. This is deliberate: a halt or a
session transition changes the meaning of every other channel, and a client
displaying a stale book during a halt is displaying something false.
Behaviour change: accumulated subscriptions no longer widen
Subscriptions are held as symbol/channel pairs. Previously they were two
separate accumulating sets whose cross product was delivered, so
subscribing {AAPL, [book]} and then {MSFT, [depth]} also delivered
depth for AAPL and book for MSFT. Each rule is now independent. A
single control frame behaves exactly as before.
Market-data snapshot and resume¶
Unlike the private stream, market data can be recovered without a REST round
trip: the gateway keeps a small in-memory cache of the latest book, depth,
and auction snapshot per symbol, plus a time-bounded tail of recent trade
prints. It serves that cache three ways.
Snapshot on subscribe. A subscribe is answered — after the subscription
ack — with the current cached snapshot for each newly matched symbol/channel,
so a (re)subscribing client renders immediately instead of waiting for the next
tick. When the cache is cold (nothing seen yet) the burst is simply empty and
the client waits for the first live event, exactly as before. This is additive:
a client that ignores the extra frames is unaffected, since they are ordinary
book/depth/auction/trade envelopes it already routes.
Explicit snapshot. Re-request the current snapshot for some
symbols/channels without changing the subscription — useful after a detected
gap on a snapshot channel:
resume. Recover the events missed on one stream after the last seq you
processed:
The topic names the stream; a symbol-qualified topic (book.AAPL,
depth.AAPL, auction.result.AAPL) carries its own symbol, while
trade.executed takes it from symbols. What happens next depends on the
channel:
| Channel | resume behaviour |
|---|---|
trades |
Buffered prints with seq > from_seq are replayed. If from_seq predates the retained window, the server sends a trades.reset, then a resume.rejected with reason: "too_old", then a fresh tail |
book / depth / auction |
Self-healing — the current snapshot is the resume, so the latest snapshot is sent. These channels carry full state, not deltas, so there is nothing older to replay |
You can also fold a resume into a subscribe with a per-item resume_from
hint, mapping a channel to the last seq you saw:
{
"action": "subscribe",
"items": [
{ "symbols": ["AAPL"], "channels": ["trades"], "resume_from": { "trades": 128400 } }
]
}
resume_from is honoured for trades (prints after that seq are replayed
instead of the whole tail) and ignored for the self-healing channels (which get
a plain snapshot).
Rejections arrive as a resume.rejected envelope rather than silently:
{ "type": "resume.rejected", "ts": "...", "data": { "topic": "trade.executed", "from_seq": 100, "reason": "too_old" } }
reason |
Meaning |
|---|---|
too_old |
from_seq predates the retained trades window; a trades.reset and fresh tail follow |
unknown_topic |
The topic is not a cached market-data topic, or nothing has ever been seen for it |
Retention is set by market_data_cache_sec
Default 60 s. It bounds only the trades tail — the latest
book/depth/auction snapshot per topic is kept regardless of age, so a
snapshot is always available even for a symbol that has been quiet longer
than the window. 0 disables the trade buffer while still serving
snapshots. There is intentionally no on-disk retention: this is the
classroom/local scale the gateway targets, and older trades live in the
history endpoints.
This does not exist for private events
/api/v1/events keeps no replay buffer — see
Private event recovery. Market data can offer
resume because book/depth/auction are self-healing snapshots and
the trades tail is cheap to retain; private order state is recovered in
full from orders.snapshot instead.
Python REST example¶
from api_gateway_client import ApiGatewayClient
client = ApiGatewayClient("http://127.0.0.1:8080", "key-trader-demo")
print(client.get_json("/api/v1/status"))
print(client.get_json("/api/v1/symbols"))
Runnable examples live under docs/examples/REST/python/.
C REST example¶
The C example uses a small POSIX socket helper for simple HTTP GET calls:
ApiGatewayClient client = api_gateway_client("127.0.0.1", 8080, "key-trader-demo");
char *body = api_gateway_get(&client, "/api/v1/status");
puts(body);
free(body);
Runnable examples live under docs/examples/REST/c/.
Implementation notes and design deviations¶
The original API gateway design described a separate api_gateway_config.yaml.
EduMatcher now keeps API gateway settings in the central engine_config.yaml
under api_gateways: so the API gateway follows the same configuration pattern
as the other gateway processes and supports multiple named API gateway process
configs.
The runtime rejects duplicate non-null gateway_id assignments across named
API gateway entries. This is deliberate: sharing one engine gateway identity
between two API gateway processes would split private session/event state across
process memory. Use separate ALF gateway IDs for separately managed write paths,
or use gateway_id: null for read-only dashboard credentials.
Swagger exposure is configurable with swagger_enabled. Plain bearer keys in
YAML are used for teaching and local labs; production deployments should put the
gateway behind TLS and manage secrets with the surrounding platform.
?wait=ack waits for the engine event matching both the topic and the specific
order_id. Concurrent requests sharing one gateway_id each resolve
independently.
engine_auth_sec, engine_reply_sec, and wait_ack_sec are all applied from
configuration.
The implementation keeps engine payloads close to the existing EduMatcher event model. Outbound WebSocket events are wrapped in a consistent envelope, but they do not attempt broad tick-to-display price rewriting beyond the payloads already published by the engine.
Cancel-replace is implemented as cancel, wait for the cancel event, then submit
the replacement. The replacement body uses the same shape as POST /orders,
including symbol.
Startup creates the engine client and listener, but does not fail the process
only because stats.db is absent. History endpoints depend on pm-stats having
created and populated the configured database.
Operational checklist¶
- Confirm
api_gateways.<NAME>.credentialsmaps to gateways allowed undergateways.alf - Confirm each non-null
gateway_idappears in only one API gateway entry - Start
pm-engine,pm-stats, thenpm-api-gwy --instance NAME - Open
/docsif Swagger is enabled - Test
GET /api/v1/healthz(no auth required) — returns{"ok": true}when the engine listener is running - Test
GET /api/v1/statuswith a bearer token - Connect
/api/v1/eventsbefore submitting orders if you want async outcomes - Use
/history/*only whenpm-statsis running and writingstats.db
Minimal MARKET order CLI¶
The script below lives at docs/examples/REST/python/submit_market_order.py
and reuses the same ApiGatewayClient library used by demo_info.py.
Run it from the examples directory:
cd docs/examples/REST/python
python3 submit_market_order.py --side BUY --symbol AAPL --qty 100
python3 submit_market_order.py --side SELL --symbol MSFT --qty 50 --wait-ack
Override gateway URL and key with environment variables:
EDUMATCHER_API_URL=http://127.0.0.1:8080 \
EDUMATCHER_API_KEY=key-trader-demo \
python3 submit_market_order.py --side BUY --symbol AAPL --qty 100
| Option | Required | Default | Description |
|---|---|---|---|
--side |
yes | — | BUY or SELL |
--symbol |
yes | — | Instrument symbol |
--qty |
yes | — | Order quantity |
--wait-ack |
no | off | Block until the matching engine ACKs the order |
--url |
no | $EDUMATCHER_API_URL or http://127.0.0.1:8080 |
Gateway base URL |
--key |
no | $EDUMATCHER_API_KEY or key-trader-demo |
Bearer API key |
Example output without --wait-ack:
Example output with --wait-ack:
order_id : ORD-3a7f1e2c
status : ACKED
accepted : True
engine ack:
{
"order_id": "ORD-3a7f1e2c",
"accepted": true,
"reason": null
}
MARKET orders must not include price or stop_price. The gateway validates
this and returns 400 VALIDATION if either field is present.
Troubleshooting¶
Check whether the port is in use¶
Before starting pm-api-gwy, or when a client cannot connect, verify that
something is listening on the configured port (default 8080).
macOS:
# lsof — shows the process name and PID holding the port
sudo lsof -iTCP:8080 -sTCP:LISTEN
# BSD netstat (ships with macOS)
netstat -an | grep LISTEN | grep 8080
Linux:
# ss — preferred on modern Linux
ss -tlnp 'sport = :8080'
# lsof
sudo lsof -iTCP:8080 -sTCP:LISTEN
# netstat (older distributions)
netstat -tlnp | grep 8080
Replace 8080 with the port value from your api_gateways.<name> config block.
If no output appears, the gateway is not running.
Test HTTP connectivity from the command line¶
The health endpoint requires no authentication and is the fastest connectivity check:
curl -s http://127.0.0.1:8080/api/v1/healthz
# Expected: {"ok": true, "enabled": true, "active_gateways": ["TRADER01"],
# "dropped_events": {}}
#
# A non-zero "dropped_events" entry means a WebSocket client read too slowly
# and lost events. The gateway is still healthy — shedding for a slow consumer
# is intended — but that client's data has holes. See "Detecting dropped
# events" above.
ok reflects whether the gateway is enabled in config and whether its
own engine-event listener thread is alive — it does not confirm that
pm-engine is actually up. The listener starts at process boot regardless of
whether a peer is listening on the other end of the ZMQ socket, so /healthz
can report {"ok": true} even before pm-engine has ever been started; it
only flips to false if the gateway itself is disabled or its listener
thread has crashed.
Test an authenticated endpoint:
Test WebSocket connectivity:
# websocat (brew install websocat / apt install websocat)
websocat ws://127.0.0.1:8080/api/v1/market-data
# curl — look for HTTP 101 Switching Protocols in the response headers
curl -v --no-buffer \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlc2FtcGxla2V5" \
http://127.0.0.1:8080/api/v1/market-data
Common problems¶
| Symptom | Likely cause | Fix |
|---|---|---|
Connection refused |
Gateway not started or wrong port | Confirm pm-api-gwy is running; check port in api_gateways config |
{"ok": false} from /healthz |
Gateway enabled: false in config, or its engine-listener thread crashed |
Check enabled in the config block and the gateway's own logs — restarting pm-engine alone will not fix an already-false result, since /healthz doesn't actually probe engine liveness |
401 Unauthorized |
Missing/wrong Authorization header (AUTH), or the engine rejected the gateway's own handshake for that gateway_id (ENGINE_AUTH, 403) |
Use Authorization: Bearer <key> with a key listed in credentials; if you get 403 ENGINE_AUTH instead, check that the gateway_id is allowed by the engine's gateways.alf list |
403 Forbidden |
Credential has no gateway_id (READ_ONLY); or lacks the ADMIN role on an /admin/* call (ROLE_DENIED) |
Use a credential with a non-null gateway_id for order-entry endpoints, or one whose engine gateway has role: ADMIN for admin endpoints |
409 Conflict (DUPLICATE) |
POST /orders reused a client_order_id already active in the session cache |
Use a fresh client_order_id per order |
429 Too Many Requests (RATE_LIMIT) |
Per-key write rate limit (rate_limit.writes_per_second/burst) exceeded |
Slow down write requests for that API key |
404 on all endpoints |
Wrong base path or wrong --instance flag |
Check pm-api-gwy --instance NAME matches the config block name |
| Swagger UI not loading | swagger_enabled: false |
Set swagger_enabled: true in the config block and restart |
| History endpoints return empty results | pm-stats not running or wrong stats_db path |
Start pm-stats; verify the stats_db path in config points to the correct file |
GET /admin/orders/{order_id} returns 503 AUDIT_INDEX_UNAVAILABLE |
pm-audit not deployed, or its index not built |
Start pm-audit and run pm-audit-cli index; check audit_db. Every other endpoint is unaffected |
An order vanished from GET /admin/orders |
It reached a terminal status more than order_retention_sec ago |
Expected — that view is current state. Use GET /admin/orders/{order_id} for its history |
GET /admin/orders grows without bound |
order_retention_sec: 0 disables eviction |
Set a positive value |
| WebSocket disconnects immediately | Engine not running or client rate limit hit | Start engine; check gateway logs for disconnect reason |
Reference¶
Quick index of the endpoints on this page, grouped by how the UI uses them.
For the full normative request/response contract for each endpoint, see the Appendix: REST API Reference.
Bootstrap¶
Single-fetch startup payloads. Any valid key for /bootstrap/trader;
MARKET_MAKER key for /bootstrap/mm; ADMIN role for /bootstrap/admin.
| Endpoint | Use |
|---|---|
GET /api/v1/bootstrap/trader |
Identity, reference, session, positions, orders, recent fills, capabilities |
GET /api/v1/bootstrap/mm |
All of trader + quote bootstrap and quote legs |
GET /api/v1/bootstrap/admin |
Reference, session, gateways, halts, order counts, monitor sequence |
Trading REST¶
trading auth.
| Endpoint | Use |
|---|---|
POST /api/v1/orders |
Submit one order |
DELETE /api/v1/orders/{order_id} |
Cancel one order |
PATCH /api/v1/orders/{order_id} |
Amend price and/or qty |
POST /api/v1/orders/{order_id}/replace |
Cancel then resubmit |
GET /api/v1/orders |
Live orders for the gateway |
GET /api/v1/orders/{order_id} |
Cached order state |
POST /api/v1/oco |
Submit OCO pair |
DELETE /api/v1/oco/{oco_id} |
Cancel OCO pair |
POST /api/v1/combos |
Submit combo order |
DELETE /api/v1/combos/{combo_id} |
Cancel combo |
POST /api/v1/quotes |
Submit two-sided quote |
DELETE /api/v1/quotes/{symbol} |
Cancel quote |
POST /api/v1/mass-cancel |
Cancel symbol or all exposure |
POST /api/v1/kill-switch |
Alias of mass-cancel |
GET /api/v1/symbols |
Instrument metadata |
GET /api/v1/session |
Current session state |
GET /api/v1/status |
Gateway cache summary |
GET /api/v1/healthz |
Liveness probe |
Reference Data¶
any valid key auth.
| Endpoint | Use |
|---|---|
GET /api/v1/reference |
Full reference bundle |
GET /api/v1/reference/config-version |
Bundle version hash |
GET /api/v1/reference/symbols |
Tick sizes and per-symbol config |
GET /api/v1/reference/risk |
Risk bands and CB levels |
GET /api/v1/reference/indexes |
Index definitions |
GET /api/v1/reference/schedule |
Session schedule metadata |
History¶
trading auth for private history; any valid key for public market data.
| Endpoint | Use |
|---|---|
GET /api/v1/history/orders |
Order lifecycle list |
GET /api/v1/history/orders/{order_id} |
One order's full lifecycle |
GET /api/v1/history/fills |
Fill history |
GET /api/v1/history/trades |
Public trade tape |
GET /api/v1/history/daily |
Daily OHLCV rows |
GET /api/v1/history/price-snapshots |
Intraday price snapshots |
GET /api/v1/history/index-daily |
Daily index rows |
GET /api/v1/history/index-snapshots |
Intraday index snapshots |
GET /api/v1/history/index-ids |
Index ids with data |
GET /api/v1/history/index-events |
Index structural/audit log |
Admin REST¶
ADMIN role required.
| Endpoint | Use |
|---|---|
POST /api/v1/admin/session/transition |
Change session phase |
GET /api/v1/admin/session/schedule |
Read schedule settings |
GET /api/v1/admin/gateways |
List gateways |
POST /api/v1/admin/gateways/{gid}/disconnect |
Kick a gateway |
POST /api/v1/admin/circuit-breaker/trigger |
Halt a symbol |
POST /api/v1/admin/circuit-breaker/resume |
Resume a symbol |
GET /api/v1/admin/halts |
Active halts table |
GET /api/v1/admin/risk/state |
Live risk state |
GET /api/v1/admin/orders |
Cross-gateway active orders |
GET /api/v1/admin/orders/{order_id} |
Cross-gateway order lifecycle |
POST /api/v1/admin/kill-switch/symbol |
Kill one symbol |
POST /api/v1/admin/kill-switch/gateway |
Kill one gateway |
POST /api/v1/admin/kill-switch/global |
Kill the market |
GET /api/v1/admin/indexes |
Index configuration |
POST /api/v1/admin/indexes/{id}/rebalance |
Rebalance an index |
POST /api/v1/admin/reference/reload |
Reload compiled reference data |
WebSocket Streams¶
/api/v1/events is private, /api/v1/market-data is public, and /api/v1/admin/monitor is ADMIN-only.
| Endpoint | Use |
|---|---|
WS /api/v1/events |
Private order, quote, and risk lifecycle |
WS /api/v1/market-data |
Public book, trade, depth, session, CB events |
WS /api/v1/admin/monitor |
Cross-gateway admin monitor feed |