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": "day_closed", "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_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_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 a live account (POST /accounts/{id}/orders/dry-run), covered in live trading. Note that on a live account a dry-run opens today's trading day, exactly as a real order does. 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. The rate follows what the leg does to your account, not the wording ofaction: a leg that reduces a position you already hold is charged the closing rate, one that adds new exposure the opening rate, and a leg that does both — it buys through a short and past flat — is charged part at each, still on the single transaction that leg books. Here nothing is held, so both legs open. 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}/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. The order is shown on its own, with no position context, so this estimate prices each leg by its stated action — a to close leg at the closing rate — rather than by the account effect the fill will be charged under. For an order labelled the way it will act the two are the same number; where they differ, the fill is authoritative. 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 account the same call is asynchronous — POST /accounts/$ACCT/liquidations returns 202 (it targets the open trading day, and never opens one), 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 trading day)
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 order, position and transaction endpoints, under the account prefix /accounts/{id}/… instead of /practice/sessions/{sid}/… (live also adds dry-run and cancel/replace, which practice does not have) — except that you never name a day: the account-level routes resolve the account's own trading day, and that day opens by itself. 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.
Your trading day opens itself
There is no call that opens a trading day. Your first order of the day opens it — and so does a dry-run. Eligibility is checked on that call: a registered account, market currently open, current time at or after the day's data-start-time, market data fresh; outside those you get 400, and the order is not placed either.
The opening balance is the account's carried cash_balance (you don't pass a starting capital anywhere).
/days/current answers null when no day is open — before your first order, and again the moment the day settles — so read it after the placement below, never before. You never need the day's id for anything on this page: every route here resolves the day from the account.
Opening the day doesn't wait on the exchange: the day is created by that first call alone and the exchange is told about it in the background, so the placement answers on its own terms and the orders you place next are queued behind that hand-off. One refusal is worth handling: 409 previous_session_unsettled, when an earlier day of this account is still open and hasn't settled. Today can't start on a balance that day hasn't finished writing; retry later. That earlier day stays readable at /accounts/{id}/days/{date}/… meanwhile, and its cancel path stays available; its close has passed, so a PUT revision there answers 400 market_closed, and the day resolves whatever is still working on it at that close.
Place a live order
Live orders are today's SPX 0DTE only, so the leg's expiry has to be today's session date: in the OSI symbol below, 260507 is 2026-05-07 — replace it with today's YYMMDD (date -u +%y%m%d, or the date of the session from GET /market-data/sessions) or the order is rejected.
ORDER=$(curl -s "$BASE/accounts/$LIVE_ACCT/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"}]
}')
ORDER_ID=$(jq -r .id <<<"$ORDER") # the order the cancel / replace / read below name
# The day that order just opened — now it exists, ask which one you got.
LIVE_DATE=$(curl -s "$BASE/accounts/$LIVE_ACCT/days/current" -H "Authorization: $TOKEN" | jq -r .date)
# YYYY-MM-DD — the key for the date-keyed reads
Idempotency-Key is optional but recommended — scoped per trading day, 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 or day 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. Usually a fraction of a second, and it lasts until the exchange processes the order |
routed |
Confirmed by the next hop, not on a book yet. Reserved — nothing emits it today |
live |
Resting on the book: fillable |
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 up to five seconds for the exchange, so the confirmed state is normally what you get back. A market order, and a limit whose price reaches strictly past the market, come back filled from the call itself. If the exchange hasn't confirmed a limit or a stop inside that wait, the answer is 202 with the order pending: it is placed and stays queued until the exchange processes it — or until you cancel it, or until the close resolves it (rejected, not_processed_before_close). pending is also visible on the event stream, which emits the order at admission and again at acceptance.
All three non-terminal states are working: they count toward your open orders, and both a cancel and a replace are accepted on any of them. Buying power is reserved once the order rests on the book — while it is pending it is accepted but unconfirmed, and reserves nothing; it is checked when the exchange processes the order.
# request cancellation of a working order
curl -s -X DELETE "$BASE/accounts/$LIVE_ACCT/orders/$ORDER_ID" -H "Authorization: $TOKEN"
# revise a working limit order's price (cancel + replace, atomically)
curl -s -X PUT "$BASE/accounts/$LIVE_ACCT/orders/$ORDER_ID" \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: rev-2025-05-07-001' \
-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 four ways: it was resting → canceled; it was still queued and the exchange hadn't taken it up → canceled at once, and the exchange never sees the placement; it was already with the exchange and never reached the book → rejected (deadline_exceeded for a market order, not_processed_before_close at the close), 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 cancel the exchange hasn't confirmed inside the wait answers 202 with the order as it stands and keeps travelling; a cancel is accepted after the close too.
The replace answers with the revision, not the original. A revision is an order in its own right: it exists the moment the PUT is accepted, carrying replaces_order_id (the id in the path), and the exchange then cancels the original and rests the revision in a single transaction — so the book never holds two copies, while your order list holds both, linked. Chase a price by revising the revision (A → B → C); what is refused is a second revision of the same order while the first is still working.
A 409 Conflict from cancel means the order is already terminal, or the Idempotency-Key belongs to a different order (idempotency_key_reuse). From replace it is one of: the order is terminal, it is already being canceled, it already carries a working revision, or — on a reused Idempotency-Key — idempotency_key_reuse (that key belongs to a different order) or replace_attempt_resolved (the revision that key already minted has resolved; send the new price under a new key).
Two retry-related statuses can still come back from a mutating live request (place, cancel, replace). A 503 means the exchange could not process the request in time — always safe to retry as-is, under the same key — but read it as unconfirmed, not as proof that nothing was applied: the order list is where it resolves, and a market order the exchange never confirmed is stamped rejected there. It is a market-order answer now, plus any order write in the last five seconds before the close — and, for a cancel, at any time after it, since a cancel stays accepted past the close but has no session left to ride to; a limit, a stop, a replace and a cancel inside the session are queued and answered 202 instead. An unconfirmed replace usually leaves two rows that tell you what happened: the original still working, the revision pending or rejected. The exception is a request that applied without being confirmed — then the list shows the revision live (or filled) and the original canceled, which is why the list, not the status code, is the truth. A 504 outcome_unknown means the exchange couldn't confirm within the deadline whether the request applied, so it may or may not have and nothing resolves it for you: retry under the same Idempotency-Key and you'll get the original outcome (a 409 duplicate_idempotency_key means the original five-second request — a market order, or any write in the last five seconds before the close — is still in flight, so keep retrying; a replay of a still-QUEUED write answers 202 with its row instead, which is an answer, not a conflict), 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/days/current" -H "Authorization: $TOKEN" # the open day, or null
curl -s "$BASE/accounts/$LIVE_ACCT/orders" -H "Authorization: $TOKEN" # orders
curl -s "$BASE/accounts/$LIVE_ACCT/positions" -H "Authorization: $TOKEN" # positions vs the latest tick
curl -s "$BASE/accounts/$LIVE_ACCT/history" -H "Authorization: $TOKEN" # per-second P&L history (chart line)
curl -s "$BASE/accounts/$LIVE_ACCT/transactions" -H "Authorization: $TOKEN" # transactions through the latest processed second
curl -s "$BASE/accounts/$LIVE_ACCT/days" -H "Authorization: $TOKEN" # every trading day, newest first
# any other day, by date
curl -s "$BASE/accounts/$LIVE_ACCT/days/$LIVE_DATE/positions" -H "Authorization: $TOKEN"
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/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
Trading-day events stream over the same /__ws endpoint as Part 11. Each envelope carries top-level session_id (the day's id) and account_id so a client tracking more than one account can route to per-day state. Subscribe by account — the day you are about to open does not exist yet, so there is nothing else to key on:
{"action":"subscribe","channel":"session_events","account_id":"<account-uuid>","last_event_by_sim":{"<uuid>":<int>}}
Subscribe once, when you pick the account, and leave it: the server enrolls the account's open day now, and any day that opens later — including the one your own first order creates. (A session_id form exists too and is what bots use, since a bot runs several sessions per date.) There is no implicit or default subscription — a connection receives a day's envelopes only after one of those frames. 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","...":"..."}}
A replace adds a whole order's worth of frames, because the revision is its own order. Its admission frame comes first — status: "pending", with replaces_order_id naming the order you revised — written before the PUT response, ahead of the original's canceled frame and the revision's own acceptance. replaces_order_id rides every one of the revision's frames, the terminal one included, so a revision that was never applied still says what it was revising:
{"channel":"session_events","id":44,"session_id":"<uuid>","account_id":"<uuid>","type":"order_update","payload":{"id":"<revision-uuid>","status":"pending","accepted_seq":null,"replaces_order_id":"<order-uuid>","price":"4.80","...":"..."}}
{"channel":"session_events","id":45,"session_id":"<uuid>","account_id":"<uuid>","type":"order_update","payload":{"id":"<order-uuid>","status":"canceled","accepted_seq":7,"...":"..."}}
{"channel":"session_events","id":46,"session_id":"<uuid>","account_id":"<uuid>","type":"order_update","payload":{"id":"<revision-uuid>","status":"live","accepted_seq":8,"replaces_order_id":"<order-uuid>","price":"4.80","...":"..."}}
If the replace is never applied, the revision's next frame is rejected instead, and the order you revised usually has no terminal frame at all — it just keeps working. Usually, because that order can also be what ended the revision, by filling or being canceled while it was in flight; then it emits its own terminal frame. Read the frames, not the PUT's status code, to see which order is live.
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 day. Track the highest id received as last_seq, and hand it back in last_event_by_sim on a reconnect. The server replays missed envelopes for connections that briefly drop a NOTIFY (60s safety-net poll), and a cursor naming a day whose events have been cleaned up answers a one-shot stale_cursor instead of a silently empty replay. 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 trading-day 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}/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/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, the day's 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 day is "closed but not settled" — status stays open and ended is false, and its settles_at field says when settlement is expected (the close plus a few minutes) — poll GET /accounts/{id}/days around that time to pick up the settled day. Once settlement completes, settled_at is stamped, status becomes settled (ended flips to true), the closing balance carries into the account's cash_balance, and /days/current answers null again.
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/days/$LIVE_DATE/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 open, a trading day can't be ended early — there is no stop or delete. It runs to the 4:00 PM ET close and settles there. There is nothing to open afterwards either: your next order, on the next eligible day, opens that day by itself.
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 a loss of 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), trades: .trades, log: (.decision_log|length)}'
trades rolls those orders up: one row per trade — an entry and the exits that closed it — with its label (the structure it opened, or the strategy's own name for it), its status (working / open / closing / closed / stopped / not_filled, and a settled day reads terminal throughout; a trade whose close has nothing to close at the account level — because another trade holds the other side — waits in closing until the book changes), the entry and exit fill prices, and its own gross_pnl / fees / net_pnl. Each order carries the matching trade_id, so the two lists group onto each other. Most strategies open a single trade per session, so expect one row; one that opens several reports a row each.
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, describe the trade, then save. A new draft holds no strategy: create answers an empty description and a null snapshot, and the assistant writes the first strategy on the first message that describes a trade — a direction, a structure, a strategy name, or a risk/time preference is enough, and it fills the rest in with sane defaults. A message with no trading content gets a clarifying question and writes nothing. 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 an EMPTY conversation: no strategy yet, so "description" is "" and
# "snapshot" is null.
DRAFT=$(curl -s -X POST "$BASE/strategies/assistant/drafts" -H "Authorization: $TOKEN")
DRAFT_ID=$(echo "$DRAFT" | jq -r .draft_id)
echo "$DRAFT" | jq '{description, snapshot}' # {"description": "", "snapshot": null}
# Saving now is refused — there is nothing to publish.
curl -s -X POST "$BASE/strategies" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' -d "{\"draft_id\":\"$DRAFT_ID\"}"
# 400 {"error":"draft_not_written","message":"This conversation hasn't produced a strategy
# yet — describe the trade you want and save after the assistant writes it."}
# Describe the trade — this is the message that writes the strategy. Returns
# 202 {turn_id}; the assistant's reply, the preview hash, and the description +
# risks + 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":"sell a 20-delta put credit spread with $30 wings, exit at 50% of the credit"}'
# Out of turns for now? The budget rejection names the moment the rolling
# window reopens, so you can wait for it instead of polling blindly:
# 429 {"error":"assistant_budget_exceeded",
# "message":"You've reached today's assistant limit. Please try again later.",
# "retry_after_seconds":7912,"retry_at":"2026-08-24T18:45:12Z"}
# The same instant is on the profile read, without spending a turn to find it.
curl -s "$BASE/user" -H "Authorization: $TOKEN" | jq '.assistant_usage | {percent, limited, retry_at}'
# Without the websocket, poll the draft read until the strategy exists: "risks"
# appears with the first edit, and the draft's source_hash is then the preview key.
# BOUNDED on purpose — a turn that only answered a question writes no strategy, so
# "risks" may never appear; when the loop runs out, send a message describing the
# trade you want and poll again.
for _ in $(seq 40); do
curl -s "$BASE/strategies/assistant/drafts/$DRAFT_ID" -H "Authorization: $TOKEN" \
| jq -e '.risks' >/dev/null && break
sleep 3
done
HASH=$(curl -s "$BASE/strategies/assistant/drafts/$DRAFT_ID" -H "Authorization: $TOKEN" | jq -r .draft.source_hash)
# An OLD conversation may answer "needs_refresh": true — its strategy was written
# for an earlier platform runtime and cannot be backtested as it stands, so the
# preview read below has nothing to serve. Send a message (this canned one will
# do) and the assistant refreshes the strategy as part of that turn. Results after
# a refresh can differ from the numbers discussed earlier in the conversation.
if curl -s "$BASE/strategies/assistant/drafts/$DRAFT_ID" -H "Authorization: $TOKEN" \
| jq -e '.needs_refresh' >/dev/null; then
curl -s -X POST "$BASE/strategies/assistant/drafts/$DRAFT_ID/messages" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"content":"Please refresh my strategy so it works with the latest platform updates."}'
# 202 is the ACCEPT, not the end of the turn. Poll until the flag clears, then
# take the NEW source_hash — the refresh mints one, and the pre-refresh hash
# 404s forever. A turn that fails, or that answers without updating the
# strategy, leaves the flag set — that is a FAILURE here, not a fall-through
# (the old hash has nothing to serve): send another message and run this
# block again.
REFRESHED=
for _ in $(seq 330); do # 330 × 2 s ≈ 11 min, past the turn cap
sleep 2
DRAFT_NOW=$(curl -s "$BASE/strategies/assistant/drafts/$DRAFT_ID" -H "Authorization: $TOKEN")
if ! echo "$DRAFT_NOW" | jq -e '.needs_refresh' >/dev/null; then REFRESHED=1; break; fi
# Turn settled (or the POST above was refused) with the flag still set.
[ "$(echo "$DRAFT_NOW" | jq -r '.active_turn')" = "null" ] && break
done
[ -n "$REFRESHED" ] || { echo "strategy still needs a refresh — send another message" >&2; exit 1; }
HASH=$(echo "$DRAFT_NOW" | jq -r .draft.source_hash)
fi
# Read the live preview by that source_hash (the same endpoint the Builder uses).
# Every later turn that edits the strategy moves the hash — re-read the draft.
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}. This
# publishes the code the conversation is holding right now, and CHANGES NOTHING
# about the conversation — it keeps taking messages afterwards.
curl -s -X POST "$BASE/strategies" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' \
-d "{\"draft_id\":\"$DRAFT_ID\",\"privacy\":\"private\"}"
# Keep going. The next turn writes another strategy, and the conversation now
# lists both versions — one per turn that wrote code, keyed by the message that
# turn ended on.
BEFORE=$(curl -s "$BASE/strategies/assistant/drafts/$DRAFT_ID" -H "Authorization: $TOKEN" \
| jq '.snapshots | length')
curl -s -X POST "$BASE/strategies/assistant/drafts/$DRAFT_ID/messages" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"content":"add a stop at 2x the credit"}'
# …but /messages answers 202 the moment the turn is ACCEPTED, not when it ends.
# Read the list straight away and you get the PREVIOUS version's message_id — or
# `null` on a conversation that had none yet. Poll until the new version shows up
# (or follow the assistant_draft_events channel, which announces it). The bound
# is past the 10-minute turn cap, and running out is a FAILURE, not a fall-through
# to whatever the list held before — that is exactly the wrong-version read.
SETTLED=
for _ in $(seq 330); do # 330 × 2 s ≈ 11 min
DRAFT_NOW=$(curl -s "$BASE/strategies/assistant/drafts/$DRAFT_ID" -H "Authorization: $TOKEN")
[ "$(echo "$DRAFT_NOW" | jq '.snapshots | length')" -gt "$BEFORE" ] && { SETTLED=1; break; }
# A turn that answered without writing code ends with no new version at all,
# so a cleared active_turn is the other way out of this loop — the newest
# version is then still the one from before.
[ "$(echo "$DRAFT_NOW" | jq -r '.active_turn')" = "null" ] && { SETTLED=1; break; }
sleep 2
done
[ -n "$SETTLED" ] || { echo "the turn never settled — refusing to read a version id" >&2; exit 1; }
MSG_ID=$(echo "$DRAFT_NOW" | jq -r '.snapshots[-1].message_id')
# Open one version in full — description, params, risks and its results. A POST
# because it restarts that version's backtest if it had been cleaned up. Free.
curl -s -X POST "$BASE/strategies/assistant/drafts/$DRAFT_ID/snapshots/$MSG_ID/view" \
-H "Authorization: $TOKEN" | jq '{source_hash, description}'
# Publish THAT version too — name it with message_id. Same conversation, second
# strategy. `saved_strategy_id` on each snapshot says which are in your library.
curl -s -X POST "$BASE/strategies" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' \
-d "{\"draft_id\":\"$DRAFT_ID\",\"message_id\":$MSG_ID,\"privacy\":\"private\"}"
# Take the idea somewhere else: forking copies the conversation (bundle +
# transcript) into a separate draft. Owner-only; returns a new id. Both threads
# stay open and either can keep publishing — a fork branches an idea, it does
# not move it.
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 code, 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 — update it in the AI assistant, or open its code in the editor and save a fixed version). Your own copy also carries draft_id + draft_message_id — the conversation that published it and the message whose version it was — which is what the app's Go to conversation link follows back into the chat. To take an assistant strategy somewhere else, fork its conversation into a separate draft (POST /strategies/assistant/drafts/{id}/fork) — owner-only, since it copies your private chat.
Authoring with raw code
The third producer skips both the form and the chat: you send the strategy as code. Check it first — POST /strategies/validate runs the very gate the save runs, and creates nothing.
# A source with no entry-window declaration: rejected, and told why.
curl -s -X POST "$BASE/strategies/validate" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' \
-d '{"source":"def build_spread(ctx):\n return None\n"}'
# {"error":"invalid_source","message":"The code failed validation: every strategy must declare
# its entry window at module level so the platform can own the entry: PLATFORM_ENTRY_WINDOW =
# {\"start\": \"09:31\", \"end\": \"09:45\", \"days\": [0, 1, 2, 3, 4]} — times are ET, \"end\" is
# EXCLUSIVE …"}
Write real sources to a file rather than escaping them by hand, and let jq build the body:
cat > /tmp/strategy.star <<'STAR'
PLATFORM_ENTRY_WINDOW = {"start": "09:45", "end": "09:50"}
def build_spread(ctx):
strike = ctx.find_strike_by_delta("put", 0.20)
if strike == None:
return None
short_put = ctx.option(strike, "put")
long_put = ctx.option(strike - 25, "put")
if short_put == None or long_put == None:
return None
credit = short_put.mid - long_put.mid
if ctx.cmp(credit, 0.0) <= 0:
return None
entry_legs = [ctx.leg(short_put, "sell", 1), ctx.leg(long_put, "buy", 1)]
return [entry_legs, ctx.snap_to_tick(credit), "credit"]
def on_tick(ctx):
# The label is the latch: this opens the trade once, inside the window above,
# and every later tick hands back the same trade instead of opening another.
ctx.open_trade("put_spread", build_spread)
STAR
# Validate: a 200 is a promise that the save will not reject these same bytes.
jq -Rs '{source: .}' < /tmp/strategy.star > /tmp/body.json
curl -s -X POST "$BASE/strategies/validate" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' --data-binary @/tmp/body.json
# {"ok":true,"source_hash":"9c1b…"}
To see the backtest before saving, POST /strategies/preview (Part 5) also takes source where a config would go: same gate, no credits, and it answers {snapshot} — read the live results through GET /strategies/preview/{source_hash}/results exactly as the builder does (this producer never 429s at the active-run cap; the run parks and starts when a slot frees):
curl -s -X POST "$BASE/strategies/preview" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' --data-binary @/tmp/body.json | jq .snapshot.source_hash
Saving reuses POST /strategies, with source where a config would go:
jq -Rs '{source: ., privacy: "private"}' < /tmp/strategy.star > /tmp/save.json
MANUAL_ID=$(curl -s -X POST "$BASE/strategies" -H "Authorization: $TOKEN" \
-H 'content-type: application/json' --data-binary @/tmp/save.json | jq -r .id)
# Read a strategy's code back — yours, or any public one. Signed-in only.
curl -s "$BASE/strategies/$MANUAL_ID/source" -H "Authorization: $TOKEN" | jq -r .source
The saved strategy has origin: "manual" and config: null, and its entry window is derived from the PLATFORM_ENTRY_WINDOW in your own code — there is no entry field to send. Validating and saving from source cost 10 credits each (a source save is the only POST /strategies that is charged at all); reading a source costs 2. Two 503s are worth branching on: smoke_busy (too many checks in flight — back off, and note the call is still charged) and smoke_unavailable (the check could not run — refunded).
Forking someone else's work is the same two calls in the other order: read a public strategy's source, edit it, save it as your own. Nothing links the two, and code written under an older contract may need a repair on the way through — validate names what to fix.
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/trades" -H "Authorization: $TOKEN" # the session's trades: one row per entry + the exits that closed it (?at= to read it at a moment)
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
trades is the same session one level up — one row per trade, with its label (the structure it opened, or the strategy's own name for it), status (working / open / closing / closed / stopped / not_filled — closing can persist while a close waits for the account book to give it something to close), entry_fill_price / exit_price and its own gross_pnl / fees / net_pnl, plus a free-text exit_reason on a terminal one. Its money is folded from the orders and transactions above, so order_ids groups the orders list and instruments groups the positions list. Most strategies open a single trade per session, so expect one row; one that opens several reports a row each, and an entry that never filled still reports one, as not_filled. Add ?at= to read the session at a moment — each trade's state is reconstructed from the fills that had happened by then, with nothing later showing through. An order a strategy placed also carries trade_id / trade_label of its own, on the REST reads and on its order_update frames alike.
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" — or "strategy_liquidated" when the strategy handed the session to the platform to close, and "user_stop" when a stop of yours ended it. 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 submits the entry again while the window is still open — re-pricing the same legs from the current market rather than re-picking them, which only a strategy saved before platform-managed entry windows still does — 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, liquidated_by}'
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.
One case answers on its own schedule: if the strategy has handed the session to the platform to close (liquidated_by: "strategy", below), the platform is working the exit and a stop you issue mid-way does not interrupt it — the ladder has to finish, or it would strand half-closed. strategy_stop_applied_at therefore appears when that liquidation ends, which is the instant the stamp's own meaning — the bot is provably out of the market — becomes true. That is either the moment the book goes flat, and the session finalizes as stopped with halt_reason: "strategy_liquidated"; or the 4:00 PM ET close, if the platform could not close everything by then, in which case whatever is left settles there and the day ends completed. Either way you are not left watching a stop that says "stopping…" on a finished day.
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 (and liquidated_by: "user") 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).
A liquidation the strategy started is not yours to run. A strategy can close itself out without any stop from you: it hands the session to the platform, which cancels what it had working and closes every position with this same workflow. Such a session reads liquidated_by: "strategy", and both the POST and the DELETE above answer 409 liquidation_strategy_owned for the rest of its life — including after the platform has finished, since there is then nothing left to close. The GET is how you follow it; because the platform runs that liquidation itself, the snapshot is a progress report on its cadence rather than a live read, and it is final once the liquidation ends. The reverse case is worth knowing too: a platform restart abandons a liquidation you started rather than resuming it — the orders it had placed stay resting as ordinary working orders you can see and cancel, and the GET reports the workflow failed with the reason in errors[]. Run a fresh POST to close what is still open. When the book goes flat the session ends stopped with halt_reason: "strategy_liquidated", balance carried.
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.