A walkthrough of paper-trading 0 DTE SPX options via the API, end to end. Every step is a working curl command — copy, adjust the values, and run. The tutorial is written as a story (a trader sitting down at the start of a day) but every example is also valid as a self-contained reference.
For the full endpoint reference, see /openapi.yaml. For the domain model — fill rules, fees, margin, settlement — see the API overview and the Core-concepts pages (Orders, Fees, Buying power, Settlement).
Conventions
- Base URL:
https://api.0dtespx.com. - Authentication: a bearer session token returned by
POST /auth/sessions. Send it as the bare value of theAuthorizationheader (noBearerprefix):Authorization: 8a4f... - Content type: every authenticated request sends
Content-Type: application/json. Responses are JSON unless otherwise noted. - Errors: most non-2xx responses carry a small JSON envelope —
{"message": "…"}with the reason, plus a machine-readableerrorcode on rejections you may want to branch on ({"error": "session_exists", "message": "…"}). Exceptions:401s from the auth gate, login failures, and live-order rejections relayed from the exchange are short plain-text bodies. The HTTP status carries the meaning either way — branch on it (and theerrorcode where present), never on the message prose. - Decimals: monetary fields are JSON strings (
"500000","1.45") to preserve precision. Parse them with a decimal library, notparseFloat. - Times: session timestamps are ISO 8601 with a timezone (
2025-01-15T14:30:00Z). Snapshot timestamps in URL paths useYYYY-MM-DDTHH:MM:SS(UTC timezone).
The examples below assume two shell variables:
BASE='https://api.0dtespx.com'
TOKEN='' # filled in after login
Part 1 — Discover the API
Several documents are served unauthenticated by the web app — the human-readable documentation, the OpenAPI specification, and the llmstxt.org index for AI agents:
curl -s "https://www.0dtespx.com/llms.txt" # llmstxt.org index for AI agents
curl -s "https://www.0dtespx.com/openapi.yaml" # full HTTP/WebSocket reference
A liveness probe:
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/health"
# 200
Part 2 — Create an account
Registration uses a 6-digit email verification code. Each code expires in 15 minutes and is locked after 5 failed submissions.
2.1 Check whether an email is already registered
curl -s "$BASE/auth/check-email" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]"}'
Response when the account exists:
{ "hasPassword": true }
hasPassword is false for accounts that only ever logged in via verification code (passwordless). A 404 means no account yet — go ahead and register.
2.2 Request a verification code
curl -s -i "$BASE/auth/verify-email" \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]"}'
HTTP/1.1 204 No Content
Rate-limited to one request per 60 seconds — repeated calls return 429 Too Many Requests.
2.3 Register
The 6-digit code arrives by email. Submit it together with the email and a password:
curl -s "$BASE/auth/register" \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"password": "correct-horse-battery",
"verification_code": "482913"
}'
{ "token": "8a4f3e21d6c94b0e9f2a1c5b7d8e4f60" }
Registration consumes the verification code (codes are single-use) and returns the bearer token of your first session — you are logged in immediately. Save that token:
TOKEN='8a4f3e21d6c94b0e9f2a1c5b7d8e4f60'
password is optional — omit it for a passwordless account that always logs in via fresh verification code.
2.4 Log in (returning users)
On later visits, create a new session with your email and password:
curl -s "$BASE/auth/sessions" \
-H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"password": "correct-horse-battery"
}'
{ "token": "8a4f3e21d6c94b0e9f2a1c5b7d8e4f60" }
Alternative login: email + a fresh verification_code (from a new POST /auth/verify-email) instead of a password (one-time login).
Forgot the password? POST /auth/forgot-password {"email": …} always returns 204 (it never reveals whether an account exists) and emails a reset link; POST /auth/reset-password {"token": …, "new_password": …} completes the reset with the emailed token.
2.5 View your profile
curl -s "$BASE/user" \
-H "Authorization: $TOKEN"
{
"id": "9f8a7b6c-5d4e-3f2a-1b0c-9d8e7f6a5b4c",
"email": "[email protected]",
"usage_percent": 0,
"slippage": "0.05",
"fee_schedule": {
"buy_equity": "0.0008",
"sell_equity": "0.003986",
"buy_to_open_option": "1.72",
"sell_to_open_option": "1.72",
"buy_to_close_option": "0.72",
"sell_to_close_option": "0.72",
"exercise_option": "5"
}
}
usage_percent reports the rate-limit bucket fill (0-100); see rate limits.
slippage is your account-wide execution-drag setting — a multiple of $0.05 applied everywhere (practice, live, and backtest read-time). It makes a limit/stop order's fill condition stricter rather than changing what a resting limit prints at; every fill that prints at the market — a triggered stop, a limit you priced past the market — is worsened by it directly, though never past the natural (a market order is unaffected: it books the natural already). New accounts start at "0.05"; change it with PATCH /user {"slippage": "0.10"} or clear it with null. See Slippage.
fee_schedule always reflects the effective schedule that will be applied to this user's orders — the platform default unless you override it (see below).
2.6 Customize fees
Any registered user can override the fee schedule with PATCH /user. All seven fields are required; values must be non-negative decimal strings and are capped at 10× the platform default.
curl -s -X PATCH "$BASE/user" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"fee_schedule": {
"buy_equity": "0.001",
"sell_equity": "0.005",
"buy_to_open_option": "2.000",
"sell_to_open_option": "2.000",
"buy_to_close_option": "0.250",
"sell_to_close_option": "0.250",
"exercise_option": "7.50"
}}'
Reverting back to the defaults:
curl -s -X PATCH "$BASE/user" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"fee_schedule": null}'
The schedule applies to all of the user's trading — pending-order estimates, fills, end-of-day settlement, and per-second history are all recomputed against it.
2.7 Delete your account
Deleting your account is permanent. First ask for a confirmation code, which is emailed to your address (one send per 60 seconds):
curl -s -X POST "$BASE/user/delete-code" \
-H "Authorization: $TOKEN"
Then confirm with the 6-digit code. Every trading session has to be closed first. Stopping a bot is not enough on its own — it halts the strategy and the session stays open holding its positions — so close them (a liquidation does it in one call) or wait for the market close, which settles whatever is still open. Otherwise the call returns 409 {"error":"session_open"}:
curl -s -X POST "$BASE/user/delete" \
-H "Authorization: $TOKEN" \
-H "Content-Type: application/json" \
-d '{"verification_code": "123456"}'
On success every session is revoked (this token stops working) and your account is permanently deleted. Your saved strategies, backtest results, portfolios, trading history, and assistant conversations are gone for good — there is no way to recover them.
Part 3 — Pick a trading day
A practice session replays a single trading day. List what is available:
curl -s "$BASE/market-data/sessions" \
-H "Authorization: $TOKEN"
Every block from here to Part 10 is also runnable straight from this page — signed in, they hit the real API with your own token, and each one hands the next the id it captured. Start by picking a date out of this response and using it everywhere the examples say 2025-01-15:
GET /market-data/sessions
Lists every trading session with its time bounds. Runnable signed out too, though a visitor sees every session flagged restricted except the most recent completed one and today's in-progress session.
Interactive — run this request from the docs
{
"2025-01-13": {
"start-time": "2025-01-13T14:30:00Z",
"end-time": "2025-01-13T21:00:00Z",
"data-start-time": "2025-01-13T14:31:00Z",
"data-end-time": "2025-01-13T21:00:00Z"
},
"2025-01-14": {
"start-time": "2025-01-14T14:30:00Z",
"end-time": "2025-01-14T21:00:00Z",
"data-start-time": "2025-01-14T14:31:00Z",
"data-end-time": "2025-01-14T21:00:00Z"
},
"2025-01-15": {
"start-time": "2025-01-15T14:30:00Z",
"end-time": "2025-01-15T21:00:00Z",
"data-start-time": "2025-01-15T14:31:00Z",
"data-end-time": "2025-01-15T21:00:00Z",
"current": true
},
"2024-08-05": {
"start-time": "2024-08-05T13:30:00Z",
"end-time": "2024-08-05T20:00:00Z",
"data-start-time": "2024-08-05T13:31:00Z",
"data-end-time": "2024-08-05T20:00:00Z",
"restricted": true
}
}
start-time / end-time are the actual trading-session bounds reported by the broker. data-start-time / data-end-time are the bounds of historical market data we have for the session (typically a minute or two narrower on the open). restricted: true marks sessions unauthenticated callers cannot access (every session except the most recent completed one and today's in-progress session); for authenticated users no session is restricted. current: true is today's in-progress session — always 1-second data for everyone — and upcoming: true marks a listed session that hasn't opened yet. Times are UTC; the trading day is 09:30–16:00 ET (14:30–21:00 UTC outside DST, 13:30–20:00 UTC during DST).
3.1 List available SPX strikes for the day
DATE='2025-01-15'
curl -s "$BASE/market-data/strikes/$DATE" \
-H "Authorization: $TOKEN"
GET /market-data/strikes/{date}
The day's strike ladder, ascending — signed in. Pick one near the SPX level for the orders further down.
Interactive — run this request from the docs
[5800, 5805, 5810, 5815, 5820, 5825, 5830, 5835, 5840, 5845, 5850, ...]
Pick a strike near the current SPX level — that is the at-the-money strike. Rate-limit cost: 5 credits.
3.2 Look at the option chain right after the open
Snapshot timestamp format is YYYY-MM-DDTHH:MM:SS, UTC timezone.
Not from the very first tick, though. The chain is served from the session's data-start-time onward — the per-date value in the sessions map at the top of Part 3, usually a minute past the open but not always. A timestamp before it answers 404; it's the same bound that gates order placement in Part 5. The range form (/option-chain-snapshots/{startTime}/{endTime}) clamps instead: a range starting before the bound drops its early snapshots, and a range lying entirely before it is a 404. 14:35 UTC here is 09:35 ET, comfortably past the bound.
TS='2025-01-15T14:35:00'
curl -s "$BASE/market-data/option-chain-snapshots/$TS" \
-H "Authorization: $TOKEN"
GET /market-data/option-chain-snapshots/{timestamp}
The whole option chain at that moment — every strike's call and put with bid, ask and delta. The path timestamp is UTC, so an intraday moment sits between 14:30 and 21:00 (13:30–20:00 during DST), and it has to be at or after the session's data-start-time — earlier is a 404. A whole chain is a big payload — the viewer caps what it draws.
Interactive — run this request from the docs
{
"call_5950": { "bid": 4.2, "ask": 4.4, "delta": 0.523 },
"put_5950": { "bid": 3.1, "ask": 3.3, "delta": 0.477 },
"call_5955": { "...": "..." }
}
Keys are call_<strike> and put_<strike>. Each quote carries bid, ask, and delta. The live_option_chain WebSocket channel carries the same three fields per side.
Scaling conventions (apply to snapshots, positions, and the WS chain):
delta— a signed decimal: the wire preserves the stored sign, so a put delta may be negative. Current data emits magnitudes, so in practice the values are0..1positive (as in the example above); do not assume they are always unsigned. Multiply by 100 if you want the "30-delta" shorthand.
3.3 Aggregate market context
For a feel of how the day moved (SPX price, expected move):
curl -s "$BASE/market-data/historical/$DATE?series=spx,vix,spxExpectedMove" \
-H "Authorization: $TOKEN"
GET /market-data/historical/{date}
The day's index series — one row per sample, with the series you select. Signed out it serves only the most recent completed session and today's in-progress one, so a fixed example date needs an account. Leave the series toggle off for the default spx,spxExpectedMove. A full session at 1-second resolution is thousands of rows.
Interactive — run this request from the docs
[
{
"datetime": "2025-01-15T14:30:00Z",
"datetimeUnix": 1736951400,
"spx": "5949.57",
"vix": "15.83",
"spx_expected_move": "0.0048"
},
{
"datetime": "2025-01-15T14:30:01Z",
"datetimeUnix": 1736951401,
"spx": "5949.62",
"vix": "15.84",
"spx_expected_move": "0.0048"
}
]
Selectable series: spx, vix, spxExpectedMove, spxOTMBids, spxExtrinsic. Default: spx,spxExpectedMove. This endpoint is public (works without auth) for the most recent and current sessions.
3.4 Price a custom multi-leg order across the day
Post the legs of an order (up to 4) and get its net mid price for every second of the session — no slippage, no fees. Here's a call vertical (long the 6000 call, short the 6010 call):
curl -s "$BASE/market-data/options-prices/$DATE" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"items": [
{ "direction": "long", "side": "call", "strike": 6000, "quantity": 1 },
{ "direction": "short", "side": "call", "strike": 6010, "quantity": 1 }
]
}'
POST /market-data/options-prices/{date}
A read-only POST — it prices the basket across the session and writes nothing. Signed out it serves only the most recent completed session, so a fixed example date needs an account. Use strikes the day actually has, or it answers 404.
Interactive — run this request from the docs
[
{ "timestamp": "2025-01-15T14:31:00Z", "datetimeUnix": 1736951460, "price": "2.50", "effect": "debit" },
{ "timestamp": "2025-01-15T14:31:01Z", "datetimeUnix": 1736951461, "price": "2.48", "effect": "debit" }
]
price is the absolute net at two decimals; effect tells you the direction (debit = you pay, credit = you collect). Rate-limit cost: 10 credits.
Part 4 — Open a practice session
Replaying a past trading day doesn't need a trading account — just your bearer token. Practice is a one-click, account-less $100,000 sandbox: you just open a day. (For a persistent account that trades today's live session, you'd create a live_sim account — that's Part 12; for now we replay a past day.)
4.1 Open a session for the day
A session is one trading day. To replay a past trading day, open a practice session — pick any past trading date, in any order (today's in-progress session cannot be replayed; it answers 400):
curl -s "$BASE/practice/sessions" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date": "2025-01-15"}'
POST /practice/sessions
Opens a real practice day on your profile — a throwaway $100,000 sandbox you can delete afterwards. Its id feeds every block below, so run this one first.
Interactive — run this request from the docs
{
"id": "9a1b2c3d-…",
"date": "2025-01-15",
"opening_balance": "100000",
"starting_capital": "100000",
"status": "open",
"ended": false,
"time": "2025-01-15T14:30:00Z",
"start_time": "2025-01-15T14:30:00Z",
"end_time": "2025-01-15T21:00:00Z",
"deposits": "100000",
"withdrawals": "0",
"credits": "0",
"debits": "0",
"fees": "0",
"maintenance_buying_power": "0",
"available_buying_power": "100000",
"net_liquidation_value": "100000",
"unrealized_profit_loss": "0",
"realized_profit_loss": "0",
"profit_loss": "0",
"equities_credits": "0",
"equities_debits": "0",
"equities_fees": "0",
"equities_unrealized_profit_loss": "0",
"equities_realized_profit_loss": "0",
"equities_profit_loss": "0",
"equities_maintenance_buying_power": "0",
"equities_net_liquidation_value": "0",
"equity_options_credits": "0",
"equity_options_debits": "0",
"equity_options_fees": "0",
"equity_options_unrealized_profit_loss": "0",
"equity_options_realized_profit_loss": "0",
"equity_options_profit_loss": "0",
"equity_options_maintenance_buying_power": "0",
"equity_options_net_liquidation_value": "0"
}
Save the session ID:
SID='9a1b2c3d-…'
The session opens at start_time with a fixed $100,000. It carries no account_id — practice is account-less. status is open (ended is false until it settles). Each POST /practice/sessions opens a new, independent session, so you can run several simulations of the same day; to edit a settled day, rewind its clock with PATCH. List your practice days with GET /practice/sessions.
Part 5 — Move time forward to the open
The session begins parked at start_time. Set the clock by PATCH:
curl -s "$BASE/practice/sessions/$SID" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T14:35:00Z"}'
PATCH /practice/sessions/{{session_id}}
Parks the clock five minutes into the day — past data-start-time, so the order in Part 6 is accepted. Keep the date in step with the session you opened.
Interactive — run this request from the docs
The response is the session object, with time filled in and financials computed at the new time. Setting the clock persists the new time and recomputes positions, delta, P&L, and buying power from the existing transactions; it never writes orders or transactions. (The one lifecycle effect: landing on end_time settles the day, and rewinding a settled day re-opens it — Part 10.)
Time must satisfy start_time <= time <= end_time:
$ curl -s -o /dev/null -w '%{http_code}\n' "$BASE/practice/sessions/$SID" \
-X PATCH -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T22:00:00Z"}'
400
Body: session time cannot be after the end time.
Part 6 — Place your first trade: a debit call vertical
Orders can only be placed once the session clock is at or after the day's data-start-time (14:31:00Z here — from GET /market-data/sessions), the moment historical data begins; an order placed earlier is rejected with 400. We advanced the clock to 14:35:00Z in Part 5, so we're clear.
Strategy: SPX is at 5949, IV is muted, expected move is small. Buy a $20-wide call vertical for a defined-risk directional bet.
- Buy 1 × ATM call (strike 5950) — opens a long
- Sell 1 × OTM call (strike 5970) — opens a short, caps the upside
The instrument string for SPX options is the canonical 21-char OPRA/OSI form: a 6-char root left-justified and space-padded (SPXW followed by two spaces — SPX 0 DTE uses the SPXW weekly root), the YYMMDD expiry date, a C or P side letter, and the strike × 1000 zero-padded to 8 digits. The option is identified by root + date + side + strike; the session-close time is implied, not encoded. So a 5950 call expiring 2025-01-15 is SPXW 250115C05950000.
Dry-run is live-only. Practice sessions have no orders/dry-run route — pre-trade preview is a feature of live-account sessions (POST /accounts/{id}/sessions/{sid}/orders/dry-run), covered in live trading. In practice you place the order directly; because you control the clock, you can always delete it and try again.
6.1 Submit the order (limit)
A multi-leg order must be a limit (only limit orders may be multi-leg). A single-leg market order would instead fill at the natural price — ask for a buy, bid for a sell, immediately.
A limit order fills at once only if its price reaches past the market. Priced strictly past the market — better than the mid-based execution price, not merely level with it — it crosses the spread and fills on the very check that sees that: on the second you sent it, or, for an order already working, on the first second the market moves through it. Anything else has its fill condition checked once a second and needs it to hold for 3 consecutive seconds, so the earliest it fills is 2 seconds after placement. (When the platform cannot price the second you placed on, a crossing order rests instead and then fills at its own price on the first check that sees the market through it.) What the order prints at depends on how it got there: one the market had to come to fills at the price you asked for — not at the better mid it touched — while one whose price was already strictly past the market the second that price was set fills at the market price as it stood then, which is strictly better than your price. Usually that second is the one you sent it on, but giving an order a new price starts it over — a PUT replace, and each step of a smart liquidation's re-pricing ladder, count as a fresh placement here. The example below is the resting case: it comes back live — resting on the book — with a pre-computed fill_datetime a few seconds out, the market came to 10.45, and the order printed the 10.50 it asked for; nudge the clock past that moment and the same order reads as filled.
curl -s "$BASE/practice/sessions/$SID/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "limit",
"price": "10.50",
"price_effect": "debit",
"legs": [
{"instrument":"SPXW 250115C05950000","quantity":"1","action":"buy to open"},
{"instrument":"SPXW 250115C05970000","quantity":"1","action":"sell to open"}
]
}'
POST /practice/sessions/{{session_id}}/orders
Places the debit call vertical from the curl above in your practice day. It comes back `live` (resting on the book) — a limit order the market has to come to needs its price to hold for three seconds; one priced past the market would have filled on the spot. The legs' YYMMDD has to be the session's own date (0DTE only) and both strikes have to exist on it — otherwise this is a 400 with the reason in the body.
Interactive — run this request from the docs
PATCH /practice/sessions/{{session_id}}
Nudges the clock a minute forward, past the pre-computed fill moment, so the order below reads as filled. If the market never held at your price, it stays `live` — that is the real answer for the day you picked.
Interactive — run this request from the docs
{
"id": "a7e2c9d1-3b85-4f60-9a4d-2c1f8e7b6d50",
"datetime": "2025-01-15T14:35:00Z",
"underlying": "SPX",
"type": "limit",
"price": "10.50",
"price_effect": "debit",
"fees": "3.44",
"status": "filled",
"fill_price": "10.50",
"fill_price_effect": "debit",
"slippage": "0.05",
"execution_price": "10.45",
"fill_datetime": "2025-01-15T14:35:09Z",
"legs": [
{ "instrument": "SPXW 250115C05950000", "quantity": "1", "action": "buy to open" },
{ "instrument": "SPXW 250115C05970000", "quantity": "1", "action": "sell to open" }
],
"transactions": [
{
"id": "b2d1f5a4-...",
"datetime": "2025-01-15T14:35:09Z",
"instrument": "SPXW 250115C05950000",
"type": "buy to open",
"quantity": "1",
"price": "1400",
"value": "1400",
"effect": "debit",
"fees": "1.72"
},
{
"id": "c3e2a6b5-...",
"datetime": "2025-01-15T14:35:09Z",
"instrument": "SPXW 250115C05970000",
"type": "sell to open",
"quantity": "1",
"price": "350",
"value": "-350",
"effect": "credit",
"fees": "1.72"
}
],
"buying-power-effect": {
"change-in-margin-requirement": "0",
"change-in-margin-requirement-effect": "None",
"change-in-buying-power": "1053.44",
"change-in-buying-power-effect": "Debit",
"current-buying-power": "100000",
"current-buying-power-effect": "Credit",
"new-buying-power": "98946.56",
"new-buying-power-effect": "Credit",
"value": "1053.44",
"effect": "Debit"
}
}
What happened:
- Fill at your own limit:
fill_priceis thepriceyou asked for —10.50here — per unit of the structure. What the market decided was only when.execution_priceis the number the mid had to reach and hold for three consecutive seconds — this order rested; one priced strictly past the market would have filled on the spot instead: the combo's net mid (snapped to the SPX tick, rounded one tick toward the market maker on an odd-tick net spread) moved to10.45, because your account'sslippagesetting (a non-negative multiple of$0.05, limit/stop only) pushes the condition that much further in the market maker's favor.slippageechoes the applied value. (A single-legmarketorder, a triggeredstopand a limit that crossed the spread are the exceptions — they fill marketable, at that mid-based price shifted by slippage and bounded at the natural.) - Per-leg transactions: each leg gets one transaction;
value = price × quantity(price already includes the ×100 contract multiplier). - Fees (
$1.72per option contract bought to open,$1.72per option sold to open) are stored on each transaction and rolled up toorder.fees. See the Fees page for the full schedule. - Atomic execution: both legs fill or none does.
Validation order (order of checks when placing an order):
- Order type / leg shape parsed;
marketandstoporders must be single-leg. Every leg must be a 0DTE SPX index option (only 0DTE SPX index options can be traded). pricerequired forlimit,stop_triggerforstop,price_effectforlimitandstop. (Slippage comes from your account setting, not the request body.)- SPX option tick rules — single-leg < $3 → $0.05; ≥ $3 → $0.10; multi-leg → $0.05.
- Limit
pricecannot exceed the order's structural maximum profit — e.g. a $5-wide vertical caps at $5.00. Skipped when max profit is unbounded (naked long calls, ratio backspreads). - Market data must exist for every leg at the session's current time.
- Stop orders cannot be already triggered.
- Available-to-close: a
to closeleg can't exceed what you hold (insufficient quantity to close). - Defined-risk gate: the resulting position can't hold an uncovered short option (
naked short positions are not allowed) — checked before buying power, so it fires regardless of capital. - Buying-power check including all existing working orders.
ORDER='a7e2c9d1-3b85-4f60-9a4d-2c1f8e7b6d50'
6.2 Inspect the order, transactions, and updated financials
curl -s "$BASE/practice/sessions/$SID/orders" -H "Authorization: $TOKEN" # list
curl -s "$BASE/practice/sessions/$SID/orders/$ORDER" -H "Authorization: $TOKEN" # single
curl -s "$BASE/practice/sessions/$SID/transactions" -H "Authorization: $TOKEN"
curl -s "$BASE/practice/sessions/$SID" -H "Authorization: $TOKEN"
GET /practice/sessions/{{session_id}}/orders/{{order_id}}
The order you just placed, with its per-leg transactions and buying-power effect.
Interactive — run this request from the docs
GET /practice/sessions/{{session_id}}
The session itself carries the financials — buying power, net liquidation value, realized and unrealized P&L at the current clock. There is no separate /financials route here.
Interactive — run this request from the docs
The session's available_buying_power will have decreased by the debit paid plus fees. For a debit vertical, maintenance_buying_power stays at 0 — the long leg fully covers the short. (Credit verticals require margin equal to the spread width × 100; iron condors use the max of the two sides. Naked shorts are not accepted — every short leg must be covered. See the Buying power page.) The buying-power-effect block returned by POST /practice/sessions/{sid}/orders (and by its live counterpart POST /accounts/{id}/sessions/{sid}/orders) reports this same impact at order placement time, broken down into margin and buying-power components.
6.3 View live positions and Greeks
curl -s "$BASE/practice/sessions/$SID/positions" -H "Authorization: $TOKEN"
GET /practice/sessions/{{session_id}}/positions
Computed on the fly from the visible trades — two legs, each with its own signed delta.
Interactive — run this request from the docs
[
{
"id": "33dffe55-9be0-5b8a-ae07-8c8c6f5d4e2b",
"instrument": "SPXW 250115C05950000",
"direction": "long",
"quantity": "1",
"cost": "1401.72",
"cost_basis": "1400",
"debit": "1400",
"credit": "0",
"unrealized_profit_loss": "5",
"realized_profit_loss": "0",
"fees": "1.72",
"total_profit_loss": "3.28",
"net_liquidity": "1405",
"price": "14.05",
"delta": "0.523"
},
{
"instrument": "SPXW 250115C05970000",
"direction": "short",
"quantity": "1",
"...": "..."
}
]
Only delta is surfaced (the other Greeks are no longer provided). Delta on a position carries the option's own sign, inverted when you are short — long calls positive, short calls negative, long puts negative (the position layer takes the delta magnitude and applies the side's natural sign, flipped for a short), short puts positive. It updates on every time change.
The id field is a synthetic UUID derived from session + instrument + direction so positions have stable IDs across requests, but they are not stored — they are computed on the fly from the visible transactions.
Part 7 — Advance time and watch P&L move
curl -s "$BASE/practice/sessions/$SID" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T17:00:00Z"}'
PATCH /practice/sessions/{{session_id}}
Jump to midday and re-run the positions block above — same trades, new marks.
Interactive — run this request from the docs
unrealized_profit_loss reflects the new mark-to-market on the spread. Refetch positions to see the updated delta and P&L; refetch the session to see updated net_liquidation_value and available_buying_power.
7.1 Per-second financial history (for charts)
curl -s "$BASE/practice/sessions/$SID/history" -H "Authorization: $TOKEN"
GET /practice/sessions/{{session_id}}/history
Returns the session's financial history — net liquidation value, P&L and buying power at each sample. One row per second by default; switch the interval on to down-sample the curve before it hits the viewer.
Interactive — run this request from the docs
[
{
"timestamp": "2025-01-15T14:30:00Z",
"net_liquidation_value": "100000",
"profit_loss": "0",
"unrealized_profit_loss": "0",
"realized_profit_loss": "0",
"credits": "0",
"debits": "0",
"fees": "0",
"maintenance_buying_power": "0",
"available_buying_power": "100000",
"...": "..."
},
{
"timestamp": "2025-01-15T14:35:00Z",
"net_liquidation_value": "99997.73",
"profit_loss": "-2.27",
"...": "..."
}
]
History is sampled at 1-second resolution. It is recomputed and saved on every order create/delete; time changes do not write to history.
7.2 Time reversal
Set the time backward. Filled orders revert to live if their fill_datetime is now in the future, order.fees falls back to the schedule-based estimate for those orders, and positions, Greeks and P&L recompute at the earlier moment. The transaction log is not clock-scoped: GET …/transactions still answers with the whole day, so the fills you rewound past stay listed — pass ?at= when you want the ledger as it stood at a moment. No data is deleted either way.
curl -s "$BASE/practice/sessions/$SID" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T14:30:00Z"}'
That put the clock at 14:30:00Z, which is before this day's data-start-time
(14:31:00Z). Placing an order there would be rejected with 400 — order
placement requires the clock to be at or after data-start-time. Move it forward
again before the next orders:
curl -s "$BASE/practice/sessions/$SID" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T14:35:00Z"}'
Part 8 — Pending limit and stop orders
8.1 Limit order that doesn't fill yet
Place a credit put vertical at a price the market can't reach right now. The order rests on the book (status: "live") and the system pre-computes when (if ever) it will fill by scanning the rest of the session.
curl -s "$BASE/practice/sessions/$SID/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "limit",
"price": "1.50",
"price_effect": "credit",
"legs": [
{"instrument":"SPXW 250115P05900000","quantity":"1","action":"sell to open"},
{"instrument":"SPXW 250115P05880000","quantity":"1","action":"buy to open"}
]
}'
POST /practice/sessions/{{session_id}}/orders
Places a credit put spread priced away from the market — a new order on your practice session. It comes back live (resting) either way; whether a fill_datetime was found later in the day depends on the day you picked.
Interactive — run this request from the docs
{
"id": "c4f1a2b3-...",
"datetime": "2025-01-15T14:35:00Z",
"underlying": "SPX",
"type": "limit",
"price": "1.50",
"price_effect": "credit",
"fees": "3.44",
"status": "live",
"legs": ["..."],
"buying-power-effect": {
"change-in-margin-requirement": "2000",
"change-in-margin-requirement-effect": "Debit",
"change-in-buying-power": "1853.44",
"change-in-buying-power-effect": "Debit",
"current-buying-power": "100000",
"current-buying-power-effect": "Credit",
"new-buying-power": "98146.56",
"new-buying-power-effect": "Credit",
"value": "1853.44",
"effect": "Debit"
}
}
The buying-power-effect block reflects the projected impact of the order
as if it were filled — even for orders that are still working. margin fields cover
maintenance margin only (here: spread width × 100 = $20 × 100 = $2,000 for
the credit vertical). The buying-power fields fold in the projected
order debit/credit and estimated fees.
While the order is unfilled, fees shows an estimate from the fee schedule based on legs and quantities, so callers see the projected cost before fill. There are no transactions in the body. Realized fees only impact the session's cash totals after the order fills. Buying-power is debited at order placement to reserve margin for the spread, and is released if the order is deleted before it fills.
8.2 Advance time past the fill
curl -s "$BASE/practice/sessions/$SID" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T18:30:00Z"}'
curl -s "$BASE/practice/sessions/$SID/orders/$LIMIT_ORDER_ID" -H "Authorization: $TOKEN"
PATCH /practice/sessions/{{session_id}}
Push the clock past the pre-computed fill moment.
Interactive — run this request from the docs
GET /practice/sessions/{{session_id}}/orders/{{pending_order_id}}
Same order, read at the new clock — status and fees are recomputed against it, never stored.
Interactive — run this request from the docs
Once the session's time is at or past fill_datetime, the order's status is computed as filled, its pre-saved transactions become visible, and fees are recomputed from the actual transaction fees. Move time back before fill_datetime and the order returns to live with fees shown as the schedule-based estimate again. fill_datetime is the last of the consecutive seconds the price held: the third of three, so at least two seconds after the order was placed — or, for a price strictly past the market, the second the crossing held, which for an order that reached past the market when you sent it is the placement second itself.
8.3 Stop order
A stop rests on the book until the order's net mid crosses the trigger:
- Debit stop (
price_effect: debit): triggers whenmid >= stop_trigger. Fires as the price rises to your level — a stop-loss on a short, or a breakout entry. - Credit stop (
price_effect: credit): triggers whenmid <= stop_trigger. Fires as the price falls to your level — a protective stop-loss on a long.
curl -s "$BASE/practice/sessions/$SID/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "stop",
"stop_trigger": "12.00",
"price_effect": "debit",
"legs": [
{"instrument":"SPXW 250115C05960000","quantity":"1","action":"buy to open"}
]
}'
If the trigger is already crossed at submission, the API rejects with 400 (stop order would execute immediately). The trigger also has to hold for 3 consecutive seconds before the stop fires, so a one-second spike no longer stops you out. When it does fire, it fills marketable: fill_price is the mid-based fill at that moment (same pricing as a limit that crossed the spread, including your slippage setting and its bound at the natural), not the trigger itself — the order's execution_price carries the trigger.
8.4 Cancel a pending order
curl -s -X DELETE "$BASE/practice/sessions/$SID/orders/$ORDER_ID" \
-H "Authorization: $TOKEN"
DELETE /practice/sessions/{{session_id}}/orders/{{pending_order_id}}
Removes the credit spread from Part 8.1 — its order, its trades, and any settlement it touched — and recomputes the day's history.
Interactive — run this request from the docs
HTTP/1.1 204 No Content
Deletion removes the order, its transactions, and any settlement transactions affected by it; history is recomputed.
Part 9 — Close a position
To close a position, submit the opposite leg actions. To exit the original debit call vertical:
curl -s "$BASE/practice/sessions/$SID/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "limit",
"price": "0.05",
"price_effect": "credit",
"legs": [
{"instrument":"SPXW 250115C05950000","quantity":"1","action":"sell to close"},
{"instrument":"SPXW 250115C05970000","quantity":"1","action":"buy to close"}
]
}'
POST /practice/sessions/{{session_id}}/orders
Flattens the vertical from Part 6. Run the positions block again afterwards — the spread is gone and its P&L has moved into the session's realized total.
Interactive — run this request from the docs
The closing spread is multi-leg, so it is a limit order (multi-leg orders must be limit). After this fills, query positions again — the spread is gone. The realized P&L moves from the position-level into the session totals (realized_profit_loss).
9.1 Close everything at once
Unwinding one position at a time gets tedious once you hold several. A liquidation closes the whole book in one call: the server groups your positions into closing orders and works them for you. Open something to close first — a put credit spread priced so the market is already through it, which fills a couple of seconds later:
curl -s "$BASE/practice/sessions/$SID/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"type": "limit",
"price": "0.05",
"price_effect": "credit",
"legs": [
{"instrument":"SPXW 250115P05900000","quantity":"1","action":"sell to open"},
{"instrument":"SPXW 250115P05880000","quantity":"1","action":"buy to open"}
]
}'
POST /practice/sessions/{{session_id}}/orders
Sells a put spread for any credit down to $0.05, so it fills within a few seconds of the current clock and gives the liquidation below something to close.
Interactive — run this request from the docs
Now flatten everything. method is either smart (grouped limit orders, re-priced every 5 seconds until they fill) or aggressive (a market order per position, shorts bought back first):
curl -s "$BASE/practice/sessions/$SID/liquidations" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"method":"smart"}'
POST /practice/sessions/{{session_id}}/liquidations
Closes every open position on the practice day. Synchronous here — the response IS the finished summary. It also cancels any working orders on the session, and they are not restored.
Interactive — run this request from the docs
{
"method": "smart",
"status": "completed",
"started_at": "2025-01-15T18:30:00Z",
"ended_at": "2025-01-15T18:31:15Z",
"orders": [
{
"order_id": "9f3c1a24-...",
"status": "filled",
"legs_summary": "buy to close 1 SPXW 250115P05900000 + sell to close 1 SPXW 250115P05880000",
"current_price": "1.35"
}
],
"positions_remaining": 0,
"errors": []
}
status is completed when the book ends flat and partial when something is still open — a group whose price never came before the close is not recorded at all and comes back with status unfilled and no order_id. The walk moves forward through the day in session time, so started_at/ended_at and the orders it records are session times that can sit after your current clock: advance it with PATCH and watch the closes land. Your slippage setting decides how quickly they fill.
On a live session the same call is asynchronous — POST /accounts/$ACCT/sessions/$SID/liquidations returns 202, you follow the workflow with GET .../liquidations/current and stop it with DELETE .../liquidations/current. Full contract in the orders API.
Part 10 — End-of-day settlement
Set the clock to end_time (the 16:00 ET close). This is the latest time the API accepts — the clock can only be set within [start_time, end_time]:
curl -s "$BASE/practice/sessions/$SID" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T21:00:00Z"}'
PATCH /practice/sessions/{{session_id}}
Settles the day: status becomes settled and ended flips to true. Rewind the clock and the day re-opens for editing.
Interactive — run this request from the docs
The day now settles: status becomes settled ("ended": true), and the settlement transactions are the ones the ledger has carried all along — they are computed up front from your orders and dated at the close, so GET …/transactions listed them before your clock got here (a ?at= read before the close is what leaves them out):
- OTM options →
expiration(no price/value/effect fields) - ITM calls/puts →
exercise(a credit for a long leg, a debit for a short)
SPX, NDX, VIX, and XSP are cash-settled — no shares delivered, so a short in-the-money leg settles as an exercise with debit effect (never an assignment). Settlement value is (underlying − strike) × 100 for calls and (strike − underlying) × 100 for puts. Each in-the-money leg of a spread settles as its own exercise at its own strike; the short leg's debit and the long leg's credit net to at most the spread width.
curl -s "$BASE/practice/sessions/$SID/transactions" \
-H "Authorization: $TOKEN" \
| jq '[.[] | select(.order_id == null)]'
GET /practice/sessions/{{session_id}}/transactions
The full tape: every fill of the day plus the settlement entries — the ones with no order_id belong to the session, not to any order.
Interactive — run this request from the docs
Settlement transactions have no order_id — they belong to the session, not to any individual order.
Part 11 — WebSocket: live market data
Endpoint: wss://api.0dtespx.com/__ws. After connecting, the client has 5 seconds to send an auth message:
{ "auth": "public" }
or
{ "token": "8a4f3e21d6c94b0e9f2a1c5b7d8e4f60" }
After auth, subscribe to the channels you want. A freshly authenticated connection has no channel subscriptions — you must explicitly subscribe to receive market-data frames. Public connections can subscribe to live_aggregate_data; live_option_chain requires a token-authenticated user.
{"action": "subscribe", "channels": ["live_aggregate_data", "live_option_chain"]}
{"action": "unsubscribe", "channel": "live_option_chain"}
Add "encoding": "columnar" to a live_option_chain subscribe to receive the compact structure-of-arrays frame instead of the default array-of-structs one (both are shown under Wire format below):
{ "action": "subscribe", "channels": ["live_option_chain"], "encoding": "columnar" }
| Channel | Payload | Default |
|---|---|---|
live_aggregate_data |
Live aggregate market data, 1-second cadence | opt-in |
live_option_chain |
Latest SPX option chain; each side carries bid, ask, delta only |
opt-in; token auth required |
live_option_chain starts at the session's data-start-time, not at the open. Subscribe whenever you like — the subscription is accepted and simply produces no frames until that instant passes (the per-date value in GET /market-data/sessions, usually a minute after the open but not always), then the 1-second cadence begins. Nothing is rejected and there is nothing to re-subscribe; don't treat the quiet stretch as a dropped connection. The same holds for the type:"quote" frames of the per-symbol market_data channel below, since they come off the same tick. live_aggregate_data and the SPX/VIX type:"trade" frames are unaffected and stream from the open, and GET /market-data/option-chain-snapshots/{timestamp} applies the same bound (404 below it).
Wire format. Every server-sent message is {"channel":"<name>","payload":<value>}. Clients dispatch on the top-level channel field — no payload-shape sniffing. session_events carries top-level id, type, session_id and account_id alongside its nested payload, so multi-session users can route to per-session state; backtest_events carries top-level id and a data object instead of a payload.
{"channel": "live_aggregate_data", "payload": {"datetime": "...", ...}}
{"channel": "live_option_chain", "payload": [{"strike": 5950, "call": {"bid": 4.2, "ask": 4.4, "delta": 0.523}, "put": {"bid": 3.1, "ask": 3.3, "delta": 0.477}}, ...]}
{"channel": "session_events", "id": 42, "session_id": "<uuid>", "account_id": "<uuid>", "type": "order_update", "payload": {...}}
{"channel": "end_of_data"}
When you subscribe with "encoding": "columnar", the live_option_chain payload is instead a structure-of-arrays object — seven equal-length, index-aligned integer arrays (k strike whole dollars; cb/ca, pb/pa call/put bid/ask in cents ÷100; cd/pd call/put delta in signed ten-thousandths ÷10000). It is semantically equivalent to the array-of-structs frame, just much smaller on the wire:
{ "channel": "live_option_chain", "payload": { "k": [5950, 5975], "cb": [420, 210], "ca": [440, 230], "cd": [5230, 3110], "pb": [310, 505], "pa": [330, 525], "pd": [6890, 4770] } }
Client-to-server messages today are the auth handshake and subscribe / unsubscribe. Any future client message that targets a channel's data plane must include a top-level channel field for the same routing-without-sniffing reason. Auth and subscribe / unsubscribe are protocol-level and exempt.
Quick websocat example — the upgrade needs an allowed Origin header, so a non-browser client must send https://www.0dtespx.com or the handshake is refused with HTTP 403:
( echo '{"auth":"public"}'; cat ) \
| websocat --origin 'https://www.0dtespx.com' 'wss://api.0dtespx.com/__ws'
Per-symbol market data (market_data)
Instead of the whole aggregate feed or the whole option chain, subscribe to exactly the symbols you want. The underlyings SPX/VIX are public; a 21-character OSI for one of today's SPX 0DTE option contracts is token-authenticated only.
( echo '{"auth":"public"}'
echo '{"action":"subscribe","channel":"market_data","symbols":["SPX","VIX"]}'
cat ) | websocat --origin 'https://www.0dtespx.com' 'wss://api.0dtespx.com/__ws'
Underlyings arrive as type:"trade" frames (index price); option symbols arrive as type:"quote" frames (bid/ask/signed delta). datetime is offset-less UTC:
{"channel":"market_data","payload":{"symbol":"SPX","type":"trade","datetime":"2026-07-17T14:30:00","price":5951.2}}
{"channel":"market_data","payload":{"symbol":"SPXW 260717C05900000","type":"quote","datetime":"2026-07-17T14:30:00","bid":4.2,"ask":4.4,"delta":0.523}}
The OSI's embedded expiration must be today's session — a wrong-day or unknown symbol simply yields no frames. Delivery is lossy latest-wins (each tick supersedes the last); at most 64 symbols per subscribe frame and 256 per connection. Unsubscribe with a symbols list to drop specific symbols, or with no symbols to clear all.
A frame that arrives before the handshake completes is read as the auth message, so a subscribe sent ahead of auth fails the handshake and the connection is closed — always authenticate first. Unknown channel names are silently dropped.
Connections authenticated as {"auth": "public"} (no account) stream for at most 5 minutes. When the cap is reached the server sends a final message and closes the connection:
{ "channel": "end_of_data" }
Token-authenticated connections are not subject to the 5-minute cap and have no concurrency limit.
Part 12 — Live trading (today's session)
Practice mode (everything above) replays a past session. Live mode trades today's real-time tick stream. The request and response shapes are identical — the same session-scoped order, position and transaction endpoints, under the account prefix /accounts/{id}/sessions/{sid}/… instead of /practice/sessions/{sid}/… (live also adds dry-run and cancel/replace, which practice does not have) — you just open the session on a live_sim account instead of a practice one. Create the live account once:
LIVE_ACCT=$(curl -s "$BASE/accounts" \
-X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Live paper","engine":"live_sim"}' | jq -r .id)
The new account starts with a fixed $100,000; its balance then carries day to day.
Open today's session
Eligibility: a registered account, market currently open, current time at or after the session's data-start-time, market data fresh.
LIVE_SID=$(curl -s "$BASE/accounts/$LIVE_ACCT/sessions" \
-X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{}' | jq -r .id)
The body is empty — the date is implicitly today and the opening balance is the account's carried cash_balance (you don't pass a starting capital here). Outside market hours, or before live data is flowing, you get 400. Re-requesting today's session while it is open returns it (200). Find the open session any time with GET /accounts/$LIVE_ACCT/sessions/current.
Place a live order
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: ord-2025-05-07-001' \
-d '{
"type":"limit",
"price":"5.00",
"price_effect":"debit",
"legs":[{"instrument":"SPXW 260507C05950000","quantity":"1","action":"buy to open"}]
}'
Idempotency-Key is optional but recommended — scoped per session, a duplicate within the retention window returns the prior request's mapped response, so a client retry is safe. The same key under a different account/session mints a distinct order.
The request also accepts an optional client_order_id (UUID) which the matcher uses as the inserted order id. A replay with the same client_order_id resolves to the existing terminal order instead of re-running buying-power / fees. When omitted, the API derives one deterministically from the Idempotency-Key (if present) or generates a fresh UUID per request — so most clients should never need to supply it explicitly.
Status transitions
Live orders never time-travel. They walk one ladder, always forward, and a rung may be skipped:
pending → routed → live → {filled, canceled, expired, rejected}
| Status | Meaning |
|---|---|
pending |
We accepted the order; nothing downstream has confirmed it yet. Transient — a fraction of a second today |
routed |
Confirmed by the next hop, not on a book yet. Reserved — nothing emits it today |
live |
Resting on the book: fillable, cancelable, replaceable |
filled canceled expired rejected |
Terminal. The first terminal recorded wins; there is no replay or rollback |
The POST above returns live for a resting order — the API waits for the exchange, so the confirmed state is what you get back. A market order, and a limit whose price reaches strictly past the market, come back filled from the call itself. pending is visible on the event stream, which emits the order at admission and again at acceptance.
All three non-terminal states are working: they reserve buying power, count toward your open orders, and accept a cancel. Only live accepts a replace.
# request cancellation of a working order
curl -s -X DELETE "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/orders/$ORDER_ID" -H "Authorization: $TOKEN"
# atomically cancel + replace a resting (live) limit order
curl -s -X PUT "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/orders/$ORDER_ID" \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"price":"5.50","price_effect":"debit"}'
A cancel is a request, and best-effort. The 204 means it was accepted and queued behind whatever is already in flight for that order — not that the order is gone. From then on the order carries cancel_requested: true (show it as "canceling…") until it resolves one of three ways: it was resting → canceled; it never reached the book → rejected, and the cancel is a no-op; it filled first → filled, with cancel_requested still true. That last case is irreducible, so read the terminal status, never the 204, as the outcome.
A 409 Conflict from cancel means the order is already terminal. From replace it means one of three things: the order is not yet live (still pending or routed — retry shortly, or cancel instead), it is already being canceled, or it is terminal.
Two retry-related statuses can come back from any mutating live request (place, cancel, replace). A 503 means live trading was briefly unavailable and nothing changed — always safe to retry as-is. A 504 outcome_unknown means the exchange couldn't confirm within the deadline whether the request applied, so it may or may not have: retry under the same Idempotency-Key and you'll get the original outcome (a 409 duplicate_idempotency_key while it's still in flight means keep retrying), or — if you didn't send a key — reconcile by re-reading the order/session over GET before resubmitting.
Read your live state
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/current" -H "Authorization: $TOKEN" # the open session
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/orders" -H "Authorization: $TOKEN" # orders
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/positions" -H "Authorization: $TOKEN" # positions vs the latest tick
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/history" -H "Authorization: $TOKEN" # per-second P&L history (chart line)
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/transactions" -H "Authorization: $TOKEN" # transactions through the latest processed second
The market at the fill
Read a filled live order back and it also carries fill_quotes — the market quotes at the time of the fill:
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/orders/$ORDER_ID" -H "Authorization: $TOKEN"
{
"id": "<order-uuid>",
"type": "limit",
"price": "5.00",
"price_effect": "debit",
"status": "filled",
"fill_price": "5.00",
"fill_price_effect": "debit",
"fill_datetime": "2026-05-07T14:31:07Z",
"fill_quotes": {
"quotes": { "SPXW 260507C05950000": { "bid": "4.90", "ask": "5.20" } },
"quotes_at": { "SPXW 260507C05950000": "2026-05-07T14:31:07Z" },
"net_bid": "4.90",
"net_ask": "5.20"
},
"fees": "1.14",
"legs": [ … ],
"transactions": [ … ]
}
Per-leg bid/ask keyed by option symbol, the timestamp of the quote used for each leg (its own time — within a second either side of fill_datetime for an order the market came to, and further back for a market order, whose fill is stamped with the placement moment; a limit that crossed the spread at placement is priced off the placement second's own market), and the order's net bid/ask per unit (the leg-quantity greatest common divisor divided out) with the direction carried by fill_price_effect rather than by a sign — so net_bid is not promised positive: on a near-zero or very wide combo it can read 0 or negative, and only net_bid ≤ net_ask holds.
It is the market at that moment, not the derivation of fill_price: an order the market came to fills at its own price, so net_bid/net_ask do not bracket the fill and none of this is a slippage readout. filled frames on the event stream carry the same object; every other status omits the key entirely, and so does every practice order (Part 6), which is priced against historical data. Full field reference: Orders → The market at the fill.
Real-time event stream
Live-session events stream over the same /__ws endpoint as Part 11. Each envelope carries top-level session_id and account_id so a client tracking more than one session can route to per-session state. Subscribe explicitly to a session:
{"action":"subscribe","channel":"session_events","session_id":"<uuid>","last_event_by_sim":{"<uuid>":<int>}}
There is no implicit or default subscription — a connection receives a session's envelopes only after it sends the session_events subscribe frame above for that session id. Envelope shape:
{"channel":"session_events","id":42,"session_id":"<uuid>","account_id":"<uuid>","type":"order_update","payload":{...}}
Types:
| Type | Payload |
|---|---|
order_update |
full live-order JSON — one event per change, including the pending admission and the live acceptance (see below) |
financials_update |
cash balance, buying power, realized P&L, position quantities by leg |
ended |
empty (settlement completed) |
settlement_failed |
empty (the market data needed to settle never became available) |
An order_update is written for every change to an order, in the same transaction as the change, so the sequence is the order's whole history: the admission frame (status: "pending", accepted_seq: null, emitted before the POST response is even written), the acceptance frame (status: "live", accepted_seq stamped), and the terminal frame. The ladder's routed rung is reserved and produces no event today. A cancel request adds a frame of its own — unchanged status, now with cancel_requested: true — and that flag rides every later frame for the order.
{"channel":"session_events","id":42,"session_id":"<uuid>","account_id":"<uuid>","type":"order_update","payload":{"id":"<order-uuid>","status":"pending","accepted_seq":null,"price":"5.00","...":"..."}}
{"channel":"session_events","id":43,"session_id":"<uuid>","account_id":"<uuid>","type":"order_update","payload":{"id":"<order-uuid>","status":"live","accepted_seq":7,"price":"5.00","...":"..."}}
Per-tick continuous values (NLV, unrealized P&L, per-leg market value, delta) are not pushed on session_events — clients compute them continuously from the per-second live_option_chain stream (which carries bid/ask/delta per side). The other Greeks aren't available anywhere in the API — delta is the only Greek in the data model.
id is monotonically increasing per session. Track the highest id received as last_seq. The server replays missed envelopes for connections that briefly drop a NOTIFY (60s safety-net poll). After a full disconnect, refetch positions/orders/transactions over REST to recover state.
Per-account financials (account_financials)
To stream an account's live per-second financials — net liquidation value, the realized/unrealized P&L split, buying power, and the equity-options/equities breakdowns — subscribe with an account_ids list (token-authenticated only; manual and bot accounts both work):
( echo '{"token":"'"$TOKEN"'"}'
echo '{"action":"subscribe","channel":"account_financials","account_ids":["'"$LIVE_ACCT"'"]}'
cat ) | websocat --origin 'https://www.0dtespx.com' 'wss://api.0dtespx.com/__ws'
On subscribe you get one initial snapshot frame for each newly subscribed account that already has session history — an account with none yet enrolls without one — then a live frame on every change. Money fields are JSON strings (trailing zeros stripped) and timestamp is RFC 3339 with a Z — the same encoding GET /accounts/{id}/sessions/{sid}/history returns:
{ "channel": "account_financials", "payload": { "account_id": "<uuid>", "session_id": "<uuid>", "timestamp": "2026-07-17T14:30:05Z", "net_liquidation_value": "101234.5", "unrealized_profit_loss": "800", "...": "..." } }
This channel is lossy latest-wins and not authoritative on its own: after a disconnect or gap, reseed from GET /accounts/$LIVE_ACCT/sessions/$LIVE_SID/history. At most 64 account ids per subscribe frame and 64 per connection; unsubscribe with an account_ids list to drop specific accounts, or with no account_ids to clear all.
REST/WS consistency
REST responses for mutations may arrive before or after the corresponding WS envelope, depending on network ordering. REST is authoritative for the immediate operation; the WS envelope is the source of truth for any state change the client did not initiate (other tabs, matcher fills, session close). De-dupe by order id, and let the later position on the status ladder win — it only ever moves forward, so an envelope reporting an earlier state than the one you hold is stale.
Settlement
Settlement runs automatically after the close (16:00 ET) once the close-time tick is published. Until then the session is "closed but not settled" — status stays open and ended is false, and the session's settles_at field says when settlement is expected (the close plus a few minutes) — poll the session list around that time to pick up the settled day. Once settlement completes, settled_at is stamped, status becomes settled (ended flips to true), and the closing balance carries into the account's cash_balance.
Once settled, the day becomes fully replayable: its positions and transactions are reconstructed from the market data, so you can scrub any moment with ?at=<RFC3339> (see Scrubbing a settled day). This is the read-only review path for a live account's past days.
curl -s "$BASE/accounts/$LIVE_ACCT/sessions/$LIVE_SID/positions?at=2025-01-15T18:30:00Z" -H "Authorization: $TOKEN"
Review your trading
Once days have settled, GET /accounts/{id}/analytics folds their transactions into round-trip trades and rolls up trade-level metrics across the account's whole settled history — read-only and unmetered:
curl -s "$BASE/accounts/$LIVE_ACCT/analytics" -H "Authorization: $TOKEN" \
| jq '.summary | {trades: .total_trades, win_rate: .win_rate_pct, profit_factor, net: .total_net_profit_loss, fees: .total_fees, avg_hold_s: .average_hold_seconds}'
{
"trades": 42,
"win_rate": 62.5,
"profit_factor": "1.84",
"net": "1234.56",
"fees": "94.67",
"avg_hold_s": 3841
}
net (total_net_profit_loss) is net of fees; win_rate excludes scratches and is null when no trade is decided; profit_factor is null with no losers. The response also carries days (per-day trade counts) and trades (the round-trip log, newest first, capped at the most recent 1000 with trades_truncated). Legs opened by a single order group into one trade. See the analytics reference for the full shape.
Ending or restarting
Once started, a live session can't be ended early — there is no stop or delete. It runs to the 4:00 PM ET close and settles there. After the day settles, you can open a fresh session on the account for the next eligible day.
Part 13 — Saved strategies & results
A 0 DTE strategy is authored with the structured Strategy Builder form, then saved. There is no separate "backtest" object to manage: a saved strategy owns its results. Every distinct configuration compiles to a deterministic program keyed by a hash — the source_hash you'll see in the endpoints below. Strategies are immutable: to change one, save a new configuration (clone), which is simply a different hash.
Results are computed the same way for every strategy: every trading session, run independently at a fixed $100,000 per-session capital, fee-free. Fees are applied at read time from your account's fee schedule (or the default), so results always render with your costs. Results cover the full history, and newly added sessions can be appended at any time; every response labels the window (window_label — always all trading sessions — plus window_from, window_to).
There are no run credits. The backtester is protected by a per-user cap of 3 concurrently-executing runs (429 too_many_active_backtests — retry with backoff) and a priority queue.
13.1 Preview a configuration
POST /strategies/preview is the builder's per-change action. Every distinct config is a persisted backtest: the call validates the config (a bad one returns 400 invalid_config with a fields map and creates nothing), ensures the backtest for its hash exists, starts the coverage run if sessions are missing, and returns the current fee-overlaid snapshot. For a configuration that already has results, this is an instant cache hit.
PREVIEW=$(curl -s -X POST "$BASE/strategies/preview" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"config":{"legs":[{"direction":"sell","type":"put","qty":1,"strike":{"method":"delta","value":0.3}},{"direction":"buy","type":"put","qty":1,"strike":{"method":"delta","value":0.2}}],"entry":{"time":"10:00"},"exit":{"profit_target_pct":50,"time":"15:55"}}}')
HASH=$(echo "$PREVIEW" | jq -r .snapshot.source_hash)
echo "$PREVIEW" | jq '{hash: .snapshot.source_hash, exec: .snapshot.exec_status, covered: .snapshot.covered_days, total: .snapshot.total_sessions, window: .snapshot.window_label}'
When you change a parameter, send the new config with abandon_hash set to the previous hash — the superseded run stops at the next session boundary (completed sessions are kept; coming back resumes where it left off):
curl -s -X POST "$BASE/strategies/preview" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d "{\"config\":{…},\"abandon_hash\":\"$HASH\"}"
13.2 Read preview results (and keep the run alive)
GET /strategies/preview/{source_hash}/results is the pre-save read path: the full day list, folded metrics, equity curve, and SPX benchmark, net of your fee schedule. Calling it also refreshes your interest heartbeat — an unsaved preview keeps running only while you're watching (interest expires 15 minutes after the last fetch; saved strategies always run to completion).
curl -s "$BASE/strategies/preview/$HASH/results" -H "Authorization: $TOKEN" \
| jq '{exec: .exec_status, covered: .covered_days, total: .total_sessions, return: .results.metrics.total_return}'
Poll it until exec_status is idle and covered_days == total_sessions — or subscribe to the backtest_events WebSocket channel (below) and refetch on each day_completed tick. When you're done with an unsaved preview, POST /strategies/preview/abandon with {"source_hash": …} (best-effort; the interest TTL backstops it).
13.3 Save (publish) the strategy
Saving publishes the immutable strategy for the hash — keeping the backtest that's already running or done. It returns immediately; results may still be streaming in. Saving never trips the run cap (at the cap the coverage run is parked and starts automatically when one of your runs finishes).
STRAT_ID=$(curl -s -X POST "$BASE/strategies" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"config":{"legs":[{"direction":"sell","type":"put","qty":1,"strike":{"method":"delta","value":0.3}},{"direction":"buy","type":"put","qty":1,"strike":{"method":"delta","value":0.2}}],"entry":{"time":"10:00"},"exit":{"profit_target_pct":50,"time":"15:55"}}}' \
| jq -r .id)
echo "strategy: $STRAT_ID"
If you previously removed the same configuration, the same id comes back with its results (the entry is resurrected).
You can hold at most 500 saved strategies. A new save while at the cap returns 409 {"error":"strategy_limit_reached"} and creates nothing — delete one first. Re-saving a strategy you already have doesn't count against the cap.
New strategies are private by default. To publish on save, add "privacy":"public" to the body; to share an existing one later, PATCH it. The same PATCH also renames the strategy — pass a title (1–120 characters):
curl -s -X PATCH "$BASE/strategies/$STRAT_ID" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"title":"My put credit spread","privacy":"public"}'
For a few seconds after a save, the short display title is still being generated automatically — a title in the PATCH is refused with 409 {"error":"title_generating"} during that window (a description/privacy-only patch still goes through). GET /strategies/{id} signals it with title_pending: true; wait for the flag to clear, then rename.
A public strategy is unlisted, not discoverable — there's no directory and the id can't be guessed — so it's reachable only by someone you give the link to. Once they have it, any user (or an unauthenticated visitor) can view and clone it.
13.4 Track progress on backtest_events
Subscribe per source hash on the WebSocket:
{ "action": "subscribe", "channels": ["backtest_events"], "source_hash": "<hex>" }
Each envelope carries data.type in started | day_completed | completed | stopped | failed and data.snapshot — the full fee-overlaid results snapshot (the same shape GET /strategies/preview/{source_hash}/results returns), folded for you, so you can render straight from it. There is no replay cursor (a missed frame self-heals on the next), the HTTP results endpoint backs the initial load and reconnect, frames are swept minutes after a run ends, and a dropped socket never stops a run — reconnect and re-subscribe.
13.5 Read a saved strategy's results
curl -s "$BASE/strategies" -H "Authorization: $TOKEN" | jq '.strategies[0]'
curl -s "$BASE/strategies/$STRAT_ID" -H "Authorization: $TOKEN" | jq '{title: .title, desc: .description, params: .params, snapshot: .snapshot | {exec_status, covered_days, total_sessions, new_sessions_available, window_label}}'
curl -s "$BASE/strategies/$STRAT_ID/results/days" -H "Authorization: $TOKEN" | jq '.days[0]'
GET /strategies— the leaderboard: every saved strategy with its full-history headline metrics (return, Sharpe, max drawdown, win rate), coverage, and staleness. Each row also carriesparams— the standardized{legs, entry, exit}summary (three plain-text lines, ready to render as labeled rows):
{
"legs": "sell 1 put at 30 Δ; buy 1 put at 20 Δ",
"entry": "enter at 09:45 ET on Mon/Wed/Fri",
"exit": "take profit at 50% of premium, stop at 200% of premium, close by 15:50 ET"
}
GET /strategies/{id}— the immutable logic (config, generated description, summary, risks, and the sameparamssummary —nullhere, rather than omitted, while an older strategy's summary hasn't been filled in yet), the short auto-generatedtitle(empty briefly right after a save) withtitle_pending— renameable viaPATCHonce that flag clears — plus the results snapshot, andprivacy+is_owner. Auth is optional: a public strategy is readable by anyone with the link (no token needed) — it's unlisted, so they have to be given the link; a private one you don't own returns404. To clone a public strategy, read itsconfighere andPOST /strategiesit as your own.GET /strategies/{id}/results/days— the full day list: per sessiongross_pnl,fees(your schedule),net_pnl, order counts, status (completed | skipped | failed | halted).GET /strategies/{id}/results/days.csv— the same day list as a CSV attachment (plusintraday_max_dd— the engine's fee-free intraday max drawdown —spx_closeandhalt_reason):
curl -sOJ "$BASE/strategies/$STRAT_ID/results/days.csv" -H "Authorization: $TOKEN"
13.6 Drill into one session (recomputed on demand)
The heavy per-session detail — orders, transactions, decision log, intraday curve — is not stored. It is recomputed by re-running that single session through the engine, then overlaying your fee schedule on the output. Expect the response to take seconds; it is cached server-side for a few minutes and costs 10 rate-limit credits.
DATE=$(curl -s "$BASE/strategies/$STRAT_ID/results/days" -H "Authorization: $TOKEN" | jq -r '.days[0].date')
curl -s "$BASE/strategies/$STRAT_ID/results/days/$DATE" -H "Authorization: $TOKEN" \
| jq '{status, gross_pnl, fees, slippage, net_pnl, orders: (.orders|length), log: (.decision_log|length)}'
Each transaction carries an overlay_fee (your schedule applied at read time); the headline net_pnl is gross minus both fees and slippage (your per-contract slippage on the day's option fills), and the response carries the intraday_curve, cost-adjusted for fees + slippage and downsampled to at most 600 points across the session. A reconciliation_warning: true in the response means the engine has changed since the session was stored — the stored results will be recomputed.
13.7 Update results with new sessions
As new market days become available, a saved strategy's results go stale (new_sessions_available > 0). An update runs only the missing sessions (days are independent) and refolds the summaries — nothing already computed is re-run. It's allowed whenever new sessions exist:
curl -s -X POST "$BASE/strategies/$STRAT_ID/results/update" -H "Authorization: $TOKEN"
# 202 {"status":"queued"} (429 at the 3-active-runs cap)
13.8 Manage saved strategies
curl -s -X PATCH "$BASE/strategies/$STRAT_ID" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' -d '{"title":"My put credit spread","description":"my spread"}'
curl -s -X DELETE "$BASE/strategies/$STRAT_ID" -H "Authorization: $TOKEN"
PATCH touches per-user metadata only (a title and a description override, plus the privacy flag) — the logic is immutable. A title is accepted once the strategy's automatic title generation has concluded (409 title_generating during that brief post-save window; see 13.3). DELETE removes the strategy from your list; re-saving the same configuration restores it with its results. There are no /backtests* endpoints.
Authoring with the AI assistant instead of a config
The AI assistant authors a strategy through a per-user draft rather than a form config. Create one, converse, then save. The three calls that start a turn — draft create, …/messages, and …/fork — return 503 {"error":"assistant_unavailable"} while the assistant is temporarily offline platform-wide; nothing is charged and existing drafts stay readable, so retry later.
# Create a draft (seeds a short iron condor + an immediate preview).
DRAFT=$(curl -s -X POST "$BASE/strategies/assistant/drafts" -H "Authorization: $TOKEN")
DRAFT_ID=$(echo "$DRAFT" | jq -r .draft_id)
HASH=$(echo "$DRAFT" | jq -r .source_hash)
# Refine it. Returns 202 {turn_id}; the assistant's reply, the updated preview
# hash, and the refreshed description + params stream over the
# assistant_draft_events WebSocket channel.
curl -s -X POST "$BASE/strategies/assistant/drafts/$DRAFT_ID/messages" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"content":"tighten the wings to $30 and exit at 50% of the credit"}'
# Read the live preview by the draft's current source_hash (same endpoint the
# Builder uses); after a turn, re-read GET /strategies/assistant/drafts/$DRAFT_ID
# for the new source_hash.
curl -s "$BASE/strategies/preview/$HASH/results" -H "Authorization: $TOKEN" | jq '.exec_status, .covered_days'
# Save: reuse POST /strategies with {draft_id} instead of {config}.
curl -s -X POST "$BASE/strategies" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' \
-d "{\"draft_id\":\"$DRAFT_ID\",\"privacy\":\"private\"}"
# Clone to iterate later: a saved conversation is frozen, so cloning FORKS it into
# a new editable draft (bundle + transcript copied). Owner-only; returns a new id.
curl -s -X POST "$BASE/strategies/assistant/drafts/$DRAFT_ID/fork" -H "Authorization: $TOKEN" | jq -r .draft_id
The saved strategy has origin: "assistant" and config: null (it was written as source, not a form config — so it can't be opened in the Builder). It can still be run as a bot like any saved strategy, provided its source meets the current live-trading rules (POST /bots re-checks it and returns 403 strategy_not_bot_eligible otherwise — regenerate the source in the assistant and save again). Because a saved conversation is frozen, you clone an assistant strategy by forking its conversation into a fresh editable draft (POST /strategies/assistant/drafts/{id}/fork) — owner-only, since it copies your private chat. In the app this is the Clone button on the strategy page.
Part 13a — Run a strategy as a live bot
A bot runs a saved strategy live, hands-free. A bot is a dedicated bot-type account on the live_sim engine, so its sessions trade exactly like Part 12 — you just don't place the orders, the strategy does. Its carried balance compounds across sessions (continuous, like every account).
Create a bot
Pass your saved-strategy link id (the $STRAT_ID from Part 13) as strategy_id:
BOT=$(curl -s "$BASE/bots" \
-X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d "{
\"strategy_id\": \"$STRAT_ID\",
\"name\": \"Delta-30 put spread\"
}" | jq -r .id)
strategy_id and name are required; the risk-policy fields are optional. The bot starts with a fixed $100,000 (starting_capital: "100000" in the response) — there is no capital to choose. name must be unique among your active bots (409 name_taken); a bad strategy_id returns 403 strategy_not_found_or_not_owned. The bot pins the immutable strategy behind the link — unlinking the strategy later doesn't change what the bot runs.
The optional auto_start boolean (default false) makes the platform open this bot's session automatically at each session open, under the same eligibility rules as a manual start; enabling it once today's session is already open takes effect at the next open, not immediately.
List your bots
curl -s "$BASE/bots" -H "Authorization: $TOKEN" | jq '.[] | {id, name, runtime_status, cash_balance}'
# only bots pinned to this strategy link
curl -s "$BASE/bots?strategy_id=$STRAT_ID" -H "Authorization: $TOKEN" | jq -r '.[].id'
Start today's session
No body — the date is implicitly today and the opening balance is the bot's carried cash_balance:
SID=$(curl -s -X POST "$BASE/bots/$BOT/sessions" -H "Authorization: $TOKEN" | jq -r .session_id)
Outside regular trading hours you get 422 outside_rth; if the strategy's entry window (the bot's entry field) excludes today or has already passed for the day, 422 not_entry_day or 422 entry_window_passed; if the bot already has today's session, 409 bot_session_exists (or 409 bot_not_idle); if the live subsystem is briefly down, 503 live_trading_unavailable.
Watch it run
Poll the session, and read its live data with the same shapes as Part 12:
curl -s "$BASE/bots/$BOT/sessions/$SID" -H "Authorization: $TOKEN" | jq '{status, end_time, total_pl, num_fills, tick_count}'
curl -s "$BASE/bots/$BOT/sessions/$SID/orders" -H "Authorization: $TOKEN" # live orders
curl -s "$BASE/bots/$BOT/sessions/$SID/positions" -H "Authorization: $TOKEN" # positions vs the latest tick
curl -s "$BASE/bots/$BOT/sessions/$SID/transactions" -H "Authorization: $TOKEN" # the whole transaction log (?at= to cut it off)
curl -s "$BASE/bots/$BOT/sessions/$SID/financials" -H "Authorization: $TOKEN" # session hydrated with financials (bot status enum)
curl -s "$BASE/bots/$BOT/sessions/$SID/history" -H "Authorization: $TOKEN" # per-second P&L curve (?interval= to down-sample)
curl -s "$BASE/bots/$BOT/sessions/$SID/decision-log" -H "Authorization: $TOKEN" # newest 500: ctx.log() + session_started/session_ended lifecycle entries, oldest first
curl -s "$BASE/bots/$BOT/sessions/$SID/decision-log?full=true" -H "Authorization: $TOKEN" # …the complete log instead of the newest 500
The session moves scheduled → registering → waiting_for_data → running → settling → completed, and only if it is still holding something at the 4:00 PM ET close: a session that goes flat during the day (entered and exited, or the entry window closed unfilled) ends right there as stopped with halt_reason: "flat_book". Its P&L fields are absent (omitted from the JSON) until it finalizes. The session's policy_snapshot also carries a platform-derived, read-only order_guardrail block (submission caps): a bot that submits abnormally many orders is auto-halted with halt_reason: "order_guardrail_tripped" — see Automatic malfunction protection.
The decision log is also where a refused entry shows up. A submission the exchange turns down appears as an order_rejected entry with order_created: false — the order is under /orders reading rejected, but it never reached the book, so there was never anything to fill or cancel. If the refusal was a temporary one, the platform rebuilds the entry from the current market and submits it again while the window is still open — one entry_retried entry per attempt (order_id, attempt, max_attempts, reason), each with a new order_id that names the re-submission itself — it becomes a working order only once one of them is accepted. If the entry cannot be recovered, at most one entry_retry_abandoned entry (attempts, reason) closes the story and the day ends without a trade:
curl -s "$BASE/bots/$BOT/sessions/$SID/decision-log" -H "Authorization: $TOKEN" \
| jq '[.[] | select(.event == "order_rejected" or .event == "entry_retried" or .event == "entry_retry_abandoned")
| {datetime, level, event, payload}]'
Finishing early ends the session, not the day. end_time above is when this session's trading day closes (4:00 PM ET, earlier on a half day), frozen at session start — so a session that finalized at 11:20 is still the bot's current day until 4:00 PM, and only then does it become a past day.
Once the session is terminal its replay reads work — but treat that as "the data is frozen", not "the day is over": the day only becomes a past one to review after end_time. positions and transactions take an at cursor (RFC 3339, UTC) that reconstructs the day at any moment, and decision-log takes the same at as a shifted window (or full=true for the whole log at once). Without a cursor, positions is a snapshot at the session's terminal clock while transactions is simply the whole ledger. A settled natural close (completed, or the transient settled before the runner finalizes it) reconstructs at its settled close; a halted / stopped / failed / abandoned day reconstructs with no synthetic settlement, with at clamped to the clock it stopped on:
curl -s "$BASE/bots/$BOT/sessions/$SID/positions?at=2026-07-17T18:30:00Z" -H "Authorization: $TOKEN"
curl -s "$BASE/bots/$BOT/sessions/$SID/transactions?at=2026-07-17T18:30:00Z" -H "Authorization: $TOKEN"
List the bot's sessions newest-first with GET /bots/$BOT/sessions (default 90; pass ?limit= up to 1000 for the full history).
Or stop polling entirely. The live_bot_events WebSocket channel pushes the whole lifecycle as it happens, for every bot on your account — it takes no per-key scoping, so one subscribe is the whole dashboard:
( echo '{"token":"'"$TOKEN"'"}'
echo '{"action":"subscribe","channel":"live_bot_events"}'
cat ) | websocat --origin 'https://www.0dtespx.com' 'wss://api.0dtespx.com/__ws'
{ "channel": "live_bot_events", "id": 8241, "data": { "type": "tick_progress", "account_id": "<bot-uuid>", "session_id": "<uuid>", "tick_index": 812, "tick_at": "2026-07-29T14:30:05Z", "nlv": "101234.5", "open_positions": 2, "open_orders": 0 } }
Each envelope carries data.account_id (the bot id) and data.session_id for routing. data.type runs the full lifecycle — session_started, registering, waiting_for_data, tick_progress (a ~10-second heartbeat), decision_logged (each decision-log entry as it is written), signal, session_resumed, missed_ticks, halted, session_completed, and error. The outbox is durable, so a reconnect that adds "last_event_id": <highest id seen> to the subscribe replays the exact gap. Note what the cursor-less subscribe above does: an omitted last_event_id means 0, and that delivers the newest 500 events before live traffic starts — a short overlap with what you have already pulled over REST, not your whole history. Expect to see events you already have and de-duplicate on the envelope's id (and on data.log_id for a decision_logged event you also fetched from decision-log above). Pass last_event_id to resume from a known point, or "last_event_id": 1 to ask for the full backfill — that is every row above id 1, so everything retained except a row with that exact id. Retention is per type — tick_progress heartbeats are kept 7 days, every other type indefinitely — so a cursor older than that replays what remains, silently: an expired heartbeat takes its point-in-time tick_index / open_positions / open_orders with it and nothing else stores those, but the numbers you act on are on the REST reads above.
The socket is live traffic plus the overlap, and the REST endpoints back most of it — but not all of it. decision_logged is mirrored into decision-log and the session_started / session_completed pair into the session record; signal, registering, waiting_for_data, session_resumed, missed_ticks, halted and error have no REST endpoint at all (error is the one carrying a strategy traceback). If your client needs those, hold a cursor across reconnects or ask for the backfill — they are retained indefinitely. Order and financials updates still arrive on session_events for the bot session, just like the manual account in Part 12. Full contract: WebSocket → live_bot_events.
Stop it
# stop the session — working orders are cancelled, the POSITIONS STAY OPEN
curl -s -X DELETE "$BASE/bots/$BOT/sessions/$SID" -H "Authorization: $TOKEN"
It returns 202. Stopping is about the strategy, not the money: the bot makes no further decisions and its working orders are cancelled, but the session stays live and keeps whatever it holds. status does not change; watch the pair of timestamps on the session instead — strategy_stopped_at is your request, and strategy_stop_applied_at appears once the bot is provably out of the market:
curl -s "$BASE/bots/$BOT/sessions/$SID" -H "Authorization: $TOKEN" \
| jq '{status, strategy_stopped_at, strategy_stopped_by, strategy_stop_applied_at, liquidated_at}'
If the bot was holding nothing when you stopped it — it had already exited, or it never entered — there is nothing to keep alive and the session is simply over: status stopped, halt_reason user_stop. The same happens without any stop at all: a session whose strategy entered and fully exited, or whose entry window closed with no fill, finalizes itself as stopped with halt_reason: "flat_book" the moment its book is flat — hours before the close, if that is when it happened. Terminal frees the day slot, so POST /bots/$BOT/sessions can open a fresh session for the same date.
Change your mind: restart it
A stopped session that is still live is one holding positions, and that stop is reversible:
curl -s -X POST "$BASE/bots/$BOT/sessions/$SID/restart" -H "Authorization: $TOKEN"
202, and the strategy resumes on the same session with the same positions and the state it had built up. The stop stamps clear, so the session reads like one that was never stopped; the restart lands in the decision log as a strategy_restarted entry (source: "api") and the resumed run emits session_resumed on live_bot_events. It never gets a second entry — the one-entry budget is reconstructed from the orders it already filled.
Refusals are all 409: bot_not_stopped (nothing acknowledged to undo — including a restart that already landed), bot_session_not_restartable (the session has finished), and bot_liquidated (below).
Or close the book
Hold to the 4:00 PM ET close and the day settles normally, or close the book yourself with a liquidation on the same session — the same workflow the manual account uses, on the bot path:
curl -s -X POST "$BASE/bots/$BOT/sessions/$SID/liquidations" \
-H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{"method":"smart"}'
curl -s "$BASE/bots/$BOT/sessions/$SID/liquidations/current" -H "Authorization: $TOKEN" # progress
curl -s -X DELETE "$BASE/bots/$BOT/sessions/$SID/liquidations/current" -H "Authorization: $TOKEN" # cancel it
The liquidation is only accepted once strategy_stop_applied_at is set and the session is still live — 409 bot_not_stopped otherwise. Contract in the orders API; the bot-specific rules in the bots API.
Liquidating is the point of no return. The POST stamps liquidated_at on the session before it places anything, and restart is refused with 409 bot_liquidated from then on — permanently, even if you cancel the workflow with the DELETE above while positions are still open (which leaves the session live and holding, strategy still off; the remaining exits are another liquidation or the close).
Once the book is flat the session finishes on its own as stopped, with its balance carried to the next session. A bot with a live session is frozen meanwhile — neither PATCH /bots/$BOT nor DELETE /bots/$BOT is accepted (409 bot_has_active_session) — so edits and deletes land after the session is done, not merely after the stop. Lifetime performance lives at GET /bots/$BOT/summary.
Review the bot's trading
GET /bots/$BOT/analytics is the bot counterpart of the account analytics: it folds the bot's finished, flat sessions into round-trip trades and returns the same trade-level metrics — plus a day_series (one entry per folded session) for an equity curve or P&L calendar:
curl -s "$BASE/bots/$BOT/analytics" -H "Authorization: $TOKEN" \
| jq '{trades: .summary.total_trades, win_rate: .summary.win_rate_pct, net: .summary.total_net_profit_loss, days: (.day_series | length)}'
The summary, days, trades, and trades_truncated fields are exactly the account-analytics shape; the extra day_series carries each folded session's {session_id, date, opening_balance, profit_loss, fees} (ascending, uncapped — a date repeats if the bot ran twice that day). The two outcomes that end a day with nothing open are folded — completed (settled at the close) and stopped (flat before it) — while halted / failed days are excluded, so this aggregate can differ from GET /bots/$BOT/summary, which includes every finalized session. A day joins the fold after its own end_time, so the session you just watched finish flat at 11:20 shows up here after the close — until then read GET /bots/$BOT/summary, which has no timing rule. Read-only. See the analytics reference for the shared shape.
Portfolios
A portfolio combines saved strategies into one aggregated results view — every member an equal $100k sleeve, results folded at read time over the window every member covers. Full reference: Portfolios API; concepts: Portfolios.
Preview → create → view → update
Save a second strategy (any different config from Part 13 works), then combine the two:
# Preview the blend BEFORE creating anything: the same fold the detail read
# does, zero side effects (no portfolio row, no backtest jobs). This is what
# the create page's live preview calls on every selection change.
curl -s -X POST "$BASE/portfolios/preview" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d "{\"strategy_ids\":[\"$STRAT_ID\",\"$STRAT_ID_2\"]}" \
| jq '{member_count, folded_members, window_from, window_to, starting_capital,
return: .results.metrics.total_return}'
# Create: 1–20 of your saved-strategy ids. Name defaults to "Portfolio N".
# You can own at most 100 portfolios; creating one while at the cap returns
# 409 {"error":"portfolio_limit_reached"} and creates nothing.
PORTFOLIO=$(curl -s -X POST "$BASE/portfolios" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d "{\"name\":\"Income mix\",\"strategy_ids\":[\"$STRAT_ID\",\"$STRAT_ID_2\"]}")
PF_ID=$(echo "$PORTFOLIO" | jq -r .id)
# The detail snapshot: intersection window, N×100k capital, combined folds.
curl -s "$BASE/portfolios/$PF_ID" -H "Authorization: $TOKEN" \
| jq '{window_from, window_to, starting_capital, exec_status, new_sessions_total,
return: .results.metrics.total_return, members: [.members[] | {id, params, has_results, new_sessions_available}]}'
# One stale member truncates the whole window — update every stale member in
# one call. 202 with a per-member outcome; never 429s (over-cap members park
# as deferred and auto-start as your runs finish).
curl -s -X POST "$BASE/portfolios/$PF_ID/results/update" -H "Authorization: $TOKEN" | jq
While members run, exec_status is running; re-fetch the snapshot (or subscribe to each member's source_hash on the backtest_events WebSocket channel) and watch window_to extend as members catch up.
Daily data and the combined day view
# Combined daily CSV: portfolio totals + one net-P&L column per member.
curl -sOJ "$BASE/portfolios/$PF_ID/results/days.csv" -H "Authorization: $TOKEN"
# Aggregated session drill-in (recomputed per member; the COLD path can take
# tens of seconds at high member counts — warm views are cached). Costs
# member_count × 10 rate-limit credits.
DAY=$(curl -s "$BASE/portfolios/$PF_ID" -H "Authorization: $TOKEN" | jq -r '.results.days[0].date')
curl -s "$BASE/portfolios/$PF_ID/results/days/$DAY" -H "Authorization: $TOKEN" \
| jq '{status, net_pnl, members: [.members[] | {strategy, status, net_pnl}]}'
Guards worth knowing
# Deleting a member strategy is blocked while it's in a portfolio:
curl -s -X DELETE "$BASE/strategies/$STRAT_ID" -H "Authorization: $TOKEN" | jq
# → 409 {"error":"strategy_in_portfolio","portfolios":[{"id":"…","name":"Income mix"}]}
# Remove the membership first (PATCH replaces the member list), then delete:
curl -s -X PATCH "$BASE/portfolios/$PF_ID" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' -d "{\"strategy_ids\":[\"$STRAT_ID_2\"]}" >/dev/null
curl -s -X DELETE "$BASE/strategies/$STRAT_ID" -H "Authorization: $TOKEN"
# A portfolio can go public only when every member is public (409
# private_members otherwise), and a member can't go private while inside a
# public portfolio (409 strategy_in_public_portfolio).
# Deleting the portfolio never touches the member strategies:
curl -s -X DELETE "$BASE/portfolios/$PF_ID" -H "Authorization: $TOKEN"
Part 14 — Logout
curl -s -X DELETE "$BASE/auth/sessions" -H "Authorization: $TOKEN"
HTTP/1.1 204 No Content
The token is invalidated server-side. Reusing it returns 401 Unauthorized.
Reference: instrument strings
| Type | Format | Example |
|---|---|---|
| Equity | <SYMBOL> |
SPX |
| Equity option | <ROOT><YYMMDD><C|P><strike×1000, 8-digit> (21-char OSI) |
SPXW 250115C05950000 |
The equity-option string is canonical OPRA/OSI: a 6-char root left-justified and space-padded, the YYMMDD expiry date, a C or P side letter, and the strike × 1000 zero-padded to 8 digits. SPX 0 DTE options use the SPXW weekly root (so SPXW followed by two spaces). An option is identified by root + date + side + strike; the session-close time is implied, not encoded.
Reference: leg actions
| Action | Position effect | Cash effect |
|---|---|---|
buy to open |
Opens a long | Debit |
sell to open |
Opens a short | Credit |
buy to close |
Closes an existing short | Debit |
sell to close |
Closes an existing long | Credit |
Reference: SPX option price ticks
| Order shape | Tick |
|---|---|
| Single-leg SPX option, net price < $3.00 | $0.05 |
| Single-leg SPX option, net price ≥ $3.00 | $0.10 |
| Multi-leg SPX option structure | $0.05 |
For limit/stop orders, the submitted price and stop_trigger must already land on the correct tick or the API responds 400.
Reference: HTTP status meanings
| Status | When |
|---|---|
| 200 | OK with response body |
| 201 | Resource created (registration, login, account, session, order) |
| 204 | OK, no body (verification email, logout, account/session/order delete, profile patch) |
| 400 | Validation error (the JSON message — or plain-text body on live orders — has the reason) |
| 401 | Missing or invalid auth |
| 403 | Forbidden — the operation is not allowed for this account |
| 404 | Resource not found (or hidden because it's not yours) |
| 429 | Rate limit hit |
| 500 | Server error |
Common pitfalls
- Forgetting the timezone on the session
time— the API parsesRFC3339, so always includeZor an offset (2025-01-15T14:30:00Z). - Stop order rejected with "would execute immediately" — the trigger is on the wrong side of the current mid. For a debit stop, set the trigger above the current mid (it fires as the price rises — a breakout entry, or a stop-loss on a short); for a credit stop, below.
naked short positions are not allowed— the platform is defined-risk only, so an order that would leave an uncovered short option is rejected regardless of capital. Cover the short with a long of the same type (a cheap far-OTM one will do) to make it a spread. A short put needs a long put and a short call a long call — a long call does not cover a short put.- SPX option price not on tick —
$3.05is rejected (the dime tick kicks in at $3.00, so the next valid price after $3.00 is $3.10). Multi-leg SPX always uses $0.05. - Authorization header — bare token, no
Bearerprefix.