API walkthrough
An end-to-end paper-trading tutorial with copy-pasteable curl examples.
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: non-2xx responses have a plain-text body (
text/plain) with a short message. The HTTP status carries the meaning. - Decimals: monetary fields are JSON strings (
"500000","1.45") to preserve precision. Parse them with a decimal library, notparseFloat. - Times: simulation 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).
2.5 View your profile
curl -s "$BASE/user" \
-H "Authorization: $TOKEN"
{
"id": "9f8a7b6c-5d4e-3f2a-1b0c-9d8e7f6a5b4c",
"email": "[email protected]",
"usage_percent": 0,
"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.
fee_schedule always reflects the effective schedule that will be applied to this user's simulator orders — the platform default unless you override it (see below).
2.6 Customize simulator fees
Any registered user can override the simulator's 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 simulations — pending-order estimates, fills, end-of-day settlement, and per-second history are all recomputed against it.
Part 3 — Pick a trading day
A simulation is anchored to a single historical session. List what is available:
curl -s "$BASE/market-data/sessions" \
-H "Authorization: $TOKEN"
{
"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); for authenticated users no session is restricted. current: true is today's in-progress session — always 1-second data for everyone. 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"
[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.
TS='2025-01-15T09:35:00'
curl -s "$BASE/market-data/option-chain-snapshots/$TS" \
-H "Authorization: $TOKEN"
{
"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 the unsigned 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— decimal 0..1, unsigned. Both calls and puts reportAbs(delta); multiply by 100 if you want "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"
[
{
"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.
Part 4 — Create the simulation
Decide on a starting capital — [1000, 10000000]. Naked short options need ~$500K because of FINRA margin (strike × 0.20 × qty × 100); spread strategies are far cheaper.
curl -s "$BASE/simulations" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"date": "2025-01-15",
"starting_capital": 100000,
"description": "Iron condor day"
}'
{
"id": "f3c88e1a-2d04-4a6b-9c12-7e8f5d6a4b3c",
"date": "2025-01-15",
"starting_capital": "100000",
"description": "Iron condor day",
"ended": false,
"start_time": "2025-01-15T14:30:00Z",
"end_time": "2025-01-15T21:00:00Z",
"deposits": "100000",
"withdrawals": "0",
"credits": "0",
"debits": "0",
"fees": "0",
"maintenance_buying_power": "0",
"available_buying_power": "100000",
"net_liquidation_value": "100000",
"unrealized_profit_loss": "0",
"realized_profit_loss": "0",
"profit_loss": "0",
"equities_credits": "0",
"equities_debits": "0",
"equities_fees": "0",
"equities_unrealized_profit_loss": "0",
"equities_realized_profit_loss": "0",
"equities_profit_loss": "0",
"equities_maintenance_buying_power": "0",
"equities_net_liquidation_value": "0",
"equity_options_credits": "0",
"equity_options_debits": "0",
"equity_options_fees": "0",
"equity_options_unrealized_profit_loss": "0",
"equity_options_realized_profit_loss": "0",
"equity_options_profit_loss": "0",
"equity_options_maintenance_buying_power": "0",
"equity_options_net_liquidation_value": "0"
}
Save the simulation ID:
SIM='f3c88e1a-2d04-4a6b-9c12-7e8f5d6a4b3c'
Note: time is null until the first PATCH. Constraint: one simulation per date per user.
4.1 List existing simulations
curl -s "$BASE/simulations" \
-H "Authorization: $TOKEN"
Returns an array of simulation objects with the same shape as the create response.
Part 5 — Move time forward to the open
The simulation begins parked at start_time with no clock. Set the clock by PATCH:
curl -s "$BASE/simulations/$SIM" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T14:35:00Z"}'
The response is the same shape as POST /simulations, with time filled in and financials computed at the new time. Time changes are read-only — they recompute positions, Greeks, P&L, and buying power from existing transactions; they do not write to orders or transactions.
Time must satisfy start_time <= time <= end_time:
$ curl -s -o /dev/null -w '%{http_code}\n' "$BASE/simulations/$SIM" \
-X PATCH -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T22:00:00Z"}'
400
Body: simulation time cannot be after the end time.
Part 6 — Place your first trade: a debit call vertical
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.
6.0 (Optional) Dry-run the order first
Before committing, you can preview the order via POST /simulations/{id}/orders/dry-run. Same request body as the real endpoint, same response shape (status, fill_price, fill_price_effect, fill_datetime, fees, buying-power-effect), but nothing is persisted — no order, no transactions, no history change. Returns 200 OK instead of 201 Created.
curl -s "$BASE/simulations/$SIM/orders/dry-run" \
-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"}
]
}'
{
"id": "00000000-0000-0000-0000-000000000000",
"datetime": "2025-01-15T14:35:00Z",
"underlying": "SPX",
"type": "limit",
"price": "10.50",
"price_effect": "debit",
"fees": "3.44",
"status": "filled",
"fill_price": "10.45",
"fill_price_effect": "debit",
"execution_price": "10.50",
"fill_datetime": "2025-01-15T14:35:00Z",
"legs": [
{ "instrument": "SPXW 250115C05950000", "quantity": "1", "action": "buy to open" },
{ "instrument": "SPXW 250115C05970000", "quantity": "1", "action": "sell to open" }
],
"buying-power-effect": {
"change-in-margin-requirement": "0",
"change-in-margin-requirement-effect": "None",
"change-in-buying-power": "108.44",
"change-in-buying-power-effect": "Debit",
"current-buying-power": "100000",
"current-buying-power-effect": "Credit",
"new-buying-power": "99891.56",
"new-buying-power-effect": "Credit",
"value": "108.44",
"effect": "Debit"
}
}
Differences vs. the real endpoint: id is synthetic (no order exists with that id), the transactions array is omitted on filled orders, and fees is the schedule-based estimate. All validation (type, leg shape, SPX tick rules, max-profit cap, market-data availability, stop-immediacy, buying-power) runs identically and surfaces the same 400 errors, so a successful dry-run is a strong signal that the real POST will also succeed.
A follow-up GET /simulations/$SIM/orders confirms nothing was persisted.
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.
curl -s "$BASE/simulations/$SIM/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"}
]
}'
{
"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.45",
"fill_price_effect": "debit",
"execution_price": "10.50",
"fill_datetime": "2025-01-15T14:35:00Z",
"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:00Z",
"instrument": "SPXW 250115C05950000",
"type": "buy to open",
"quantity": "1",
"price": "430",
"value": "430",
"effect": "debit",
"fees": "1.72"
},
{
"id": "c3e2a6b5-...",
"datetime": "2025-01-15T14:35:00Z",
"instrument": "SPXW 250115C05970000",
"type": "sell to open",
"quantity": "1",
"price": "325",
"value": "-325",
"effect": "credit",
"fees": "1.72"
}
],
"buying-power-effect": {
"change-in-margin-requirement": "0",
"change-in-margin-requirement-effect": "None",
"change-in-buying-power": "108.44",
"change-in-buying-power-effect": "Debit",
"current-buying-power": "100000",
"current-buying-power-effect": "Credit",
"new-buying-power": "99891.56",
"new-buying-power-effect": "Credit",
"value": "108.44",
"effect": "Debit"
}
}
What happened:
- Execution-price fill:
fill_priceis the combo's net execution price — the net mid, snapped to the SPX tick, rounded one tick toward the market maker when the net spread is an odd number of ticks. (A single-legmarketorder would instead fill at the natural price.) Your account'sslippagesetting (a non-negative multiple of$0.05, limit/stop only) worsens this in the market maker's favor;execution_priceis the mid the order had to reach to fill, andslippageechoes the applied value. - Per-leg transactions: each leg gets one transaction;
value = price × quantity(price already includes the ×100 contract multiplier). - Fees (
$1.72per option contract bought to open,$1.72per option sold to open) are stored on each transaction and rolled up toorder.fees. See the Fees page for the full schedule. - Atomic execution: both legs fill or none does.
Validation order (order of checks for POST /simulations/{id}/orders):
- Order type / leg shape parsed;
marketandstoporders must be single-leg. 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, calendars). - Market data must exist for every leg at sim time.
- Stop orders cannot be already triggered.
- Buying-power check including all existing pending orders.
ORDER='a7e2c9d1-3b85-4f60-9a4d-2c1f8e7b6d50'
6.2 Inspect the order, transactions, and updated financials
curl -s "$BASE/simulations/$SIM/orders" -H "Authorization: $TOKEN" # list
curl -s "$BASE/simulations/$SIM/orders/$ORDER" -H "Authorization: $TOKEN" # single
curl -s "$BASE/simulations/$SIM/transactions" -H "Authorization: $TOKEN"
curl -s "$BASE/simulations/$SIM" -H "Authorization: $TOKEN"
The simulation'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 use strike × 0.20 × qty × 100. See the Buying power page.) The buying-power-effect block returned by POST /simulations/{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/simulations/$SIM/positions" -H "Authorization: $TOKEN"
[
{
"id": "33dffe55-9be0-5b8a-ae07-8c8c6f5d4e2b",
"instrument": "SPXW 250115C05950000",
"direction": "long",
"quantity": "1",
"cost": "431.72",
"cost_basis": "430",
"debit": "430",
"credit": "0",
"unrealized_profit_loss": "5",
"realized_profit_loss": "0",
"fees": "1.72",
"total_profit_loss": "3.28",
"net_liquidity": "435",
"price": "4.35",
"delta": "0.523"
},
{
"instrument": "SPXW 250115C05970000",
"direction": "short",
"quantity": "1",
"...": "..."
}
]
Only delta is surfaced (the other Greeks are no longer recorded). Delta on a position is Abs(market delta) × direction_sign — long calls positive, short calls negative, long puts positive (still unsigned at the market layer; the direction provides the sign at the position layer), short puts negative. It updates on every time change.
The id field is a synthetic UUID derived from simulation + 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/simulations/$SIM" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T17:00:00Z"}'
unrealized_profit_loss reflects the new mark-to-market on the spread. Refetch positions to see the updated delta and P&L; refetch the simulation to see updated net_liquidation_value and available_buying_power.
7.1 Per-second financial history (for charts)
curl -s "$BASE/simulations/$SIM/history" -H "Authorization: $TOKEN"
[
{
"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 pending if their fill_datetime is now in the future, transactions visible at sim time shrink, order.fees falls back to the schedule-based estimate for those orders, and Greeks/P&L recompute. No data is deleted.
curl -s "$BASE/simulations/$SIM" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T14:30: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 stays pending and the system pre-computes when (if ever) it will fill by scanning the rest of the session.
curl -s "$BASE/simulations/$SIM/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"}
]
}'
{
"id": "c4f1a2b3-...",
"datetime": "2025-01-15T17:00:00Z",
"underlying": "SPX",
"type": "limit",
"price": "1.50",
"price_effect": "credit",
"fees": "3.44",
"status": "pending",
"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 pending orders. 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 status == "pending", fees shows an estimate from the fee schedule based on legs and quantities, so callers see the projected cost before fill. There are no transactions in the body. Realized fees only impact the simulation'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/simulations/$SIM" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T18:30:00Z"}'
curl -s "$BASE/simulations/$SIM/orders/$LIMIT_ORDER_ID" -H "Authorization: $TOKEN"
Once sim.time >= 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 pending with fees shown as the schedule-based estimate again.
8.3 Stop order
A stop is a pending order that triggers when the reference price (mid for options/multi-leg, trade for single-leg equities) crosses the trigger:
- Debit stop (
price_effect: debit): triggers whenmid <= stop_trigger. Use this to enter on a pullback. - Credit stop (
price_effect: credit): triggers whenmid >= stop_trigger. Use this to enter on a rally.
curl -s "$BASE/simulations/$SIM/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). When it fills, the fill price is the mid (same logic as a market order), not the trigger.
8.4 Cancel a pending order
curl -s -X DELETE "$BASE/simulations/$SIM/orders/$ORDER_ID" \
-H "Authorization: $TOKEN"
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/simulations/$SIM/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"}
]
}'
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 simulation totals (realized_profit_loss).
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/simulations/$SIM" \
-X PATCH \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"time":"2025-01-15T21:00:00Z"}'
The response now has "ended": true. Settlement transactions appear in GET /transactions:
- OTM options →
expiration(no price/value/effect fields) - ITM calls/puts →
exercise(long) orassignment(short)
SPX, NDX, VIX, and XSP are cash-settled — no shares delivered. Settlement value is (underlying − strike) × 100. Covered shorts are netted against their long counterpart in the same spread.
curl -s "$BASE/simulations/$SIM/transactions" \
-H "Authorization: $TOKEN" \
| jq '[.[] | select(.order_id == null)]'
Settlement transactions have no order_id — they belong to the simulation, 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"}
| 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 |
Wire format. Every server-sent message is {"channel":"<name>","payload":<value>}. Clients dispatch on the top-level channel field — no payload-shape sniffing. simulation_events and backtest_events carry top-level id and data/type siblings instead of a nested payload. simulation_events additionally carries top-level live_simulation_id so multi-sim users can route to per-sim state.
{"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": "simulation_events", "id": 42, "type": "order_update", "payload": {...}}
{"channel": "end_of_data"}
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:
( echo '{"auth":"public"}'; cat ) \
| websocat 'wss://api.0dtespx.com/__ws'
Subscribe messages received before auth completes are silently ignored. Unknown channel names are silently dropped.
Connections authenticated as {"auth": "public"} (no account) stream for at most 5 minutes. When the cap is reached the server sends a final message and closes the connection:
{ "channel": "end_of_data" }
Token-authenticated connections are not subject to the 5-minute cap and have no concurrency limit.
Part 12 — Live trading (today's session)
Historical mode (everything above) replays a past session. Live mode trades against today's real-time tick stream. The shape of the API mirrors historical mode but lives under /live and operates on a single, in-progress simulation per user.
Eligibility: a registered account, market currently open, current time at or after the session's data-start-time, recorder ticks fresh.
Start the live simulation
curl -s "$BASE/live" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"starting_capital":"100000","description":"my live session"}'
Returns 201 with the LiveSimulation JSON. The session date is implicitly today (NY trading-day calendar). At most one active live simulation per user at a time; there is no daily cap on how many you create.
Place a live order
curl -s "$BASE/live/orders" \
-X POST \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: ord-2025-05-07-001' \
-d '{
"type":"limit",
"price":"5.00",
"price_effect":"debit",
"legs":[{"instrument":"SPXW 260507C05950000","quantity":"1","action":"buy to open"}]
}'
Idempotency-Key is optional but recommended — duplicates within the retention window return the prior request's mapped response, so a client retry is safe.
The request also accepts an optional client_order_id (UUID) which the matcher uses as the inserted live_orders.id. A replay with the same client_order_id resolves to the existing terminal row 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. Statuses: pending, filled, canceled, expired, rejected. Once terminal, the status stays — there is no replay or rollback.
# cancel a still-pending order
curl -s -X DELETE "$BASE/live/orders/$ORDER_ID" -H "Authorization: $TOKEN"
# atomically cancel + replace
curl -s -X PUT "$BASE/live/orders" \
-H "Authorization: $TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"order_id\":\"$ORDER_ID\",
\"type\":\"limit\",\"price\":\"5.50\",\"price_effect\":\"debit\",
\"legs\":[{\"instrument\":\"SPXW 260507C05950000\",\"quantity\":\"1\",\"action\":\"buy to open\"}]
}"
A 409 Conflict from cancel/replace means the order is no longer pending (already filled, expired, etc).
Read your live state
curl -s "$BASE/live" -H "Authorization: $TOKEN" # current sim with summary
curl -s "$BASE/live/orders" -H "Authorization: $TOKEN" # orders
curl -s "$BASE/live/positions" -H "Authorization: $TOKEN" # positions vs the latest tick
curl -s "$BASE/live/history" -H "Authorization: $TOKEN" # per-second P&L history (chart line)
curl -s "$BASE/live/transactions" -H "Authorization: $TOKEN" # transactions through the latest processed second
Real-time event stream
Live simulation events stream over the same /__ws endpoint as Part 11. Each envelope carries top-level live_simulation_id so a client tracking more than one simulation can route to per-sim state. Subscribe explicitly to a sim:
{"action":"subscribe","channel":"simulation_events","live_simulation_id":"<uuid>","last_event_by_sim":{"<uuid>":<int>}}
A token-authenticated conn that does not subscribe explicitly still receives envelopes for the user's active manual sim as a backwards-compatible default. Envelope shape:
{"channel":"simulation_events","id":42,"live_simulation_id":"<uuid>","type":"order_update","payload":{...}}
Types:
| Type | Payload |
|---|---|
order_update |
full LiveOrder JSON (status-conditional fields) |
financials_update |
cash balance, buying power, realized P&L, position quantities by leg |
ended |
empty (settlement completed) |
settlement_failed |
empty (settlement abandoned after retry exhaustion) |
Per-tick continuous values (NLV, unrealized P&L, per-leg market value, delta) are not pushed on simulation_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 on the stream; pull them from the REST option-chain snapshot when needed.
id is monotonically increasing per simulation. Track the highest id received as last_seq. The server replays missed envelopes for connections that briefly drop a NOTIFY (60s safety-net poll). After a full disconnect, refetch positions/orders/transactions over REST to recover state.
REST/WS consistency
REST responses for mutations may arrive before or after the corresponding WS envelope, depending on network ordering. REST is authoritative for the immediate operation; the WS envelope is the source of truth for any state change the client did not initiate (other tabs, matcher fills, session close). De-dupe by live_orders.id and prefer the higher updated_at.
Settlement
Settlement runs automatically after the close (16:00 ET) once the recorder has published the close-time tick. Until then the simulation is "closed but not settled" — ended stays false. Once settlement completes, settled_at is stamped and ended flips to true.
Ending or restarting
curl -s -X DELETE "$BASE/live" -H "Authorization: $TOKEN"
Deletes the simulation, cascading to its orders and transactions. After this, you can POST /live again to start a fresh session for the same day.
Part 13 — Saved strategies & results
A 0 DTE strategy is authored with the structured Strategy Builder form, then saved. There is no separate "backtest" object to manage: a saved strategy owns its results. Strategies are content-addressed — the form config is compiled to a deterministic program and keyed by the hash of that source — so identical configurations are one shared strategy with one shared set of results, reused across users. Strategies are immutable: to change one, save a new configuration (clone), which is simply a different hash.
Results are computed the same way for everyone: every recorded 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 the same shared results render with your costs. Everyone sees the full recorded history and can append newly recorded sessions; every response labels the window (window_label — always all recorded 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 content-addressed backtest for its hash, starts the coverage run if sessions are missing, and returns the current fee-overlaid snapshot. For a configuration anyone has previewed or saved before, 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":{"times":["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 if nobody else is watching it (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 someone is 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 upserts the shared immutable strategy for the hash and links it to your account — adopting 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":{"times":["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 (the link is resurrected). If another user already saved the identical configuration, you share their results instantly.
New strategies are private by default. To publish on save, add "privacy":"public" to the body; to share an existing one later, PATCH it:
curl -s -X PATCH "$BASE/strategies/$STRAT_ID" \
-H "Authorization: $TOKEN" -H 'content-type: application/json' \
-d '{"privacy":"public"}'
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 '{desc: .description, 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.GET /strategies/{id}— the immutable logic (config, generated description, summary, risks) 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_close,error_message,halt_reason):
curl -sOJ "$BASE/strategies/$STRAT_ID/results/days.csv" -H "Authorization: $TOKEN"
13.6 Drill into one session (recomputed on demand)
The heavy per-session detail — orders, transactions, decision log, intraday curve — is not stored. It is recomputed by re-running that single session through the engine, then overlaying your fee schedule on the output. Expect the response to take seconds; it is cached server-side for a few minutes and costs 10 rate-limit credits.
DATE=$(curl -s "$BASE/strategies/$STRAT_ID/results/days" -H "Authorization: $TOKEN" | jq -r '.days[0].date')
curl -s "$BASE/strategies/$STRAT_ID/results/days/$DATE" -H "Authorization: $TOKEN" \
| jq '{status, gross_pnl, fees, slippage, net_pnl, orders: (.orders|length), log: (.decision_log|length)}'
Each transaction carries an overlay_fee (your schedule applied at read time); the headline net_pnl is gross minus both fees and slippage (your per-contract slippage on the day's option fills), and the response always includes the second-by-second intraday_curve, cost-adjusted for fees + slippage. A reconciliation_warning: true in the response means the engine has changed since the session was stored — a platform-wide results recompute will follow.
13.7 Update results with new sessions
As new market days are recorded, 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 — shared with everyone on the same configuration. Any user may trigger it 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 '{"description":"my spread"}'
curl -s -X DELETE "$BASE/strategies/$STRAT_ID" -H "Authorization: $TOKEN"
PATCH touches per-user metadata only (a description override) — the logic is immutable. DELETE removes the strategy from your list; the shared strategy and its results survive for other users, and re-saving the same configuration restores your entry. There are no /backtests* endpoints.
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, simulation, order) |
| 204 | OK, no body (verification email, logout, simulation/order delete, profile patch) |
| 400 | Validation error (plain-text body 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 simulation
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 (you want to enter on a pullback); for a credit stop, below.
- Naked short rejected for buying power — naked shorts need
strike × 0.20 × qty × 100. With strike 5950 and 1 contract, that's $119,000 of margin. Use a higherstarting_capital, or trade a defined-risk spread. - SPX option price not on tick —
$3.05is rejected (the dime tick kicks in above $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.