# 0DTESPX > 0DTESPX.com is a paper-trading platform for zero-days-to-expiration (0DTE) SPX index options. Replay any past trading day in an account-less, fixed-$100k practice sandbox, or paper-trade today's market on a persistent live account with a continuous cash balance; track positions with live delta and P&L, build and backtest strategies, and drive it all through a public HTTP + WebSocket API. ## Getting started - [Overview](https://www.0dtespx.com/learn): What 0DTESPX.com is and how the platform fits together. - [Quick start](https://www.0dtespx.com/learn/quick-start): Run your first paper-trading session in a few minutes. - [Access & accounts](https://www.0dtespx.com/learn/access): What visitors and registered users can do. ## Core concepts - [0DTE & SPX options](https://www.0dtespx.com/learn/options-basics): A primer on zero-days-to-expiration SPX index options. - [Accounts & trading days](https://www.0dtespx.com/learn/accounts): Persistent accounts with a continuous balance, traded one day (session) at a time. - [Option chain & Greeks](https://www.0dtespx.com/learn/option-chain): Reading bids, asks, mid prices, and live delta across every strike. - [Orders & order types](https://www.0dtespx.com/learn/orders): Market, limit, stop, and multi-leg orders and how they fill. - [Positions & P&L](https://www.0dtespx.com/learn/positions): Tracking direction, cost basis, and realized vs unrealized P&L. - [Buying power & margin](https://www.0dtespx.com/learn/buying-power): FINRA maintenance-margin rules for shorts, spreads, and condors. - [Fees](https://www.0dtespx.com/learn/fees): The brokerage fee schedule applied to every order. - [End-of-day settlement](https://www.0dtespx.com/learn/settlement): What happens to open options at the 4:00 PM ET close. - [Market data & charts](https://www.0dtespx.com/learn/market-data): Live and historical SPX/VIX data, option chains, and overlays. ## Research - [How to research a strategy](https://www.0dtespx.com/learn/research-guide): Take an idea from backtest to live: the Research → Trade funnel. - [Strategy builder](https://www.0dtespx.com/learn/strategy-builder): Define a 0DTE strategy with a structured form. - [AI strategy assistant](https://www.0dtespx.com/learn/ai-assistant): Describe a strategy in plain English and let the AI author it. One trade a session is the default shape, but a strategy written by the assistant or in the code editor MAY open several — a later entry, a re-entry after the first stops out, a hedge — each tracked separately with its own exits and P&L, bounded at 50 per session and 12 not-yet-finished at once. - [Writing strategy code](https://www.0dtespx.com/learn/strategy-code): The authorable contract for hand-written strategies — four callbacks (on_session_start, on_tick, on_order_filled, on_session_end) plus the trade builders the author writes, the mandatory module-level PLATFORM_ENTRY_WINDOW declaration (ONE window per strategy, and every trade opens inside it), the whole ctx surface a strategy may call, and the rules a save enforces (a constant unique label per trade, defined risk per trade, limit orders only, every leg action placeable where it is used, 24 KB, and a run against one or more recent trading sessions). A trade is opened with ctx.open_trade(label, build_fn) — IDEMPOTENT PER LABEL, so the call may sit unguarded in on_tick — read back with ctx.trades() or the handle it returns (.label/.status/.legs/.entry_fill_price/.mark/.unrealized_pl/.realized_pl), and closed either by its ONE resting ctx.take_profit_order(trade, …)/ctx.stop_loss_order(trade, …) or by ctx.close_trade(trade, reason); ctx.liquidate(reason) closes everything and ends the session. Leg actions are ctx.leg(o, "buy"|"sell", qty) — the order the leg goes into supplies the open/close half (a builder's legs OPEN, an exit call's legs CLOSE); the four long spellings stay accepted, and what is READ BACK (order.legs[i].action, ctx.open_orders()) is always the long spelling. - [Strategy results](https://www.0dtespx.com/learn/backtesting): Every saved strategy owns backtest results over the full history; its orders are priced and re-priced by the platform exactly as a bot's are (same spread-aware start, same retry cadences at the platform defaults, same fill confirmation), so the strategy's own limit price is a trigger rather than the price its order rests at. - [Portfolios](https://www.0dtespx.com/learn/portfolios): Combine saved strategies into one aggregated results view with equal $100k sleeves. ## Practice - [Practice](https://www.0dtespx.com/learn/practice): Rehearse trades by hand on any past day, with a clock you control. ## Trade - [Live trading](https://www.0dtespx.com/learn/live-trading): Paper trade today's session against the real-time tick stream. ## Replay - [Replay](https://www.0dtespx.com/learn/replay): Scrub back through a settled live day — a read-only review of the day you traded. ## Automate - [Trading bots](https://www.0dtespx.com/learn/bots): Run a saved strategy hands-free against the live session. ## API reference - [Overview & authentication](https://www.0dtespx.com/learn/api/overview): Base URL, auth, access levels, and conventions. - [API walkthrough](https://www.0dtespx.com/learn/api/walkthrough): An end-to-end paper-trading tutorial with copy-pasteable curl examples. - [Rate limits](https://www.0dtespx.com/learn/api/rate-limits): The market-data credit budget and per-endpoint costs. - [Accounts & trading days API](https://www.0dtespx.com/learn/api/accounts): Create accounts, read trading days, run practice sessions, and read financials. - [Orders API](https://www.0dtespx.com/learn/api/orders): Place, preview, and cancel orders, including multi-leg, and liquidate every open position at once. - [Positions & transactions](https://www.0dtespx.com/learn/api/positions-transactions): Read computed positions with delta and P&L, and the transaction log. - [Market data API](https://www.0dtespx.com/learn/api/market-data): Sessions, strikes, history, and option-chain snapshots. - [Live trading API](https://www.0dtespx.com/learn/api/live-trading): Trade today's trading day: orders, positions, idempotency. - [Strategies API](https://www.0dtespx.com/learn/api/strategies): Preview, save, and read strategies and their backtest results. - [Portfolios API](https://www.0dtespx.com/learn/api/portfolios): Combine saved strategies into portfolios with read-time aggregated results. - [Bots API](https://www.0dtespx.com/learn/api/bots): Run a saved strategy as a live bot: sessions, lifecycle, and per-session data. - [WebSocket streams](https://www.0dtespx.com/learn/api/websocket): Real-time market data plus trading-day event channels, subscribed per session or per trading account. ## Reference - [Glossary](https://www.0dtespx.com/learn/glossary): Definitions for the terms used across the platform. - [FAQ](https://www.0dtespx.com/learn/faq): Answers to the questions we hear most often. ## API > The HTTP API exposes trading accounts (persistent, cash-only, continuous balance) for live/bot trading and their trading days (one day each, opened by the account's first order — there is no call that opens one), an account-less practice surface for replaying trading days (fixed $100k), orders, positions, transactions, market data, saved strategies (each owning its backtest results), auth flows, and a WebSocket channel for live data and trading-day event streams (subscribed per account, or per session for bots). Errors carry the appropriate HTTP status code; most bodies are a small JSON envelope (`{"message": "…"}`, plus a machine-readable `error` code on rejections worth branching on), while auth-gate 401s, login failures, and live-order rejections relayed from the exchange are short plain-text bodies. Authentication is a bearer session token from `POST /auth/sessions`, sent as the bare value of the `Authorization` header (no `Bearer ` prefix). Base URL: https://api.0dtespx.com. - [OpenAPI specification](https://www.0dtespx.com/openapi.yaml): Full HTTP and WebSocket reference — paths, parameters, request and response schemas, status codes, and security schemes (OpenAPI 3.0.3). - [API walkthrough](https://www.0dtespx.com/learn/api/walkthrough): End-to-end walkthrough with copy-pasteable `curl` examples — register, log in, list market sessions, create an account, open a practice day, advance the clock, place market/limit/stop orders, view positions/Greeks/P&L, end-of-day settlement, and the WebSocket live data feed. - [API overview](https://www.0dtespx.com/learn/api/overview): Base URL, authentication, access levels, conventions, and status codes for the HTTP + WebSocket API. ## API — core concepts - [Accounts & trading days](https://www.0dtespx.com/learn/accounts): An account is a persistent, cash-only container with a continuous balance that carries day to day, fixed at creation to the `live_sim` engine (today's real session; the only engine that ships); `broker_*` engines are reserved for upcoming broker integrations but not yet available (409); `bot`-type accounts are created via `POST /bots`. Accounts are for LIVE/BOT trading only. Replaying past trading days is ACCOUNT-LESS — the `/practice/sessions` surface, a one-click fixed-$100k sandbox with no account and no starting-capital prompt. A session is one trading day (under an account for live/bot; account-less for practice). `cash_balance` is the settled carry (never folds in unsettled intraday P&L); `current_value` is the live NLV while a day is open, else `cash_balance`. On a practice session, advancing time recomputes positions, delta, P&L, and buying power; per-second history is materialized on order create or delete; crossing the 16:00 ET close settles the day (practice days are independent — no carried balance, no re-thread — and a date can hold multiple independent simulations). 0DTE SPX-index only. - [Order lifecycle](https://www.0dtespx.com/learn/orders): Placement, validation, creation (with pre-computed `fill_datetime`, `fill_price`, `fill_price_effect`, and settlement transactions), resting on the book (`live`) → filled — a `limit` whose `price` is STRICTLY PAST the market at placement fills IN the POST and comes back `filled` instead of resting, while every other limit and every stop is accepted `live` first (see fill confirmation below) — optional deletion, 16:00 ET settlement. - [Order types](https://www.0dtespx.com/learn/orders): Market, limit, and stop. ONLY limit orders may be multi-leg — market and stop orders must be single-leg (a multi-leg market/stop is rejected with HTTP 400). Stop orders that would trigger immediately at placement are rejected. - [Fill confirmation — immediate when the price is past the market, else 3 consecutive seconds](https://www.0dtespx.com/learn/orders): a limit order's fill condition, and a stop's trigger, are evaluated ONCE PER SECOND. A limit whose `price` is STRICTLY PAST the market — better than the quoted market, not merely LEVEL with it — fills IMMEDIATELY on the second the crossing holds, with NO consecutive-seconds wait; which second that is decides the price (see fill price rules). At PLACEMENT it means the `POST` itself comes back `filled`, `fill_datetime` stamped at the placement instant, instead of `live`. While the order is WORKING it means the first second the market comes through it fills it, on that second. Everything else takes 3 consecutive true evaluations: a `price` LEVEL with the market is resting and fills on the 3rd consecutive second, so its `fill_datetime` is at least 2 seconds after placement (its placement second is evaluation #1). A one-second dislocation TO the level fills nothing — one THROUGH the price does, that is the crossing rule — and a second the market cannot be priced at counts as false and restarts the run. When the platform has no priceable market for the placement second itself, a crossing limit RESTS instead of filling, and the crossing then fills it at its own `price` on the first second it is evaluated: infra staleness can delay or downgrade a crossing fill, never manufacture one. Setting a NEW price restarts BOTH halves — the seconds count and the past-the-market test — at the second the new price takes effect, so a re-priced order that steps past the market fills at that step. Stop triggers and the `stop_loss`-class runaway exit keep the 3 consecutive seconds. Market orders fill on the next tick, unconfirmed. Single-leg EQUITY orders never cross-fill: they keep the 3-second rule and their trade-price fill. Identical in practice, live, and backtests. - [Fill price rules](https://www.0dtespx.com/learn/orders): Market orders fill at the natural price (ask for a buy, bid for a sell). A LIMIT order the market had to COME TO fills AT ITS OWN `price`, never the better price the mid transiently touched — that holds for both ways it can fill: after 3 consecutive seconds at the level, and the instant the market moves THROUGH it while it is working (which fills it on that second, still at its `price` — a resting order in a real book trades at its limit and the incoming side keeps the improvement). A limit whose `price` was ALREADY STRICTLY PAST the market at the second THAT PRICE WAS SET instead fills MARKETABLE, right there at that second, at the mid-based execution price ± `slippage` (a limit priced through the market is the only way to trade a spread aggressively, since market orders are single-leg) — strictly better than its own `price`. Setting a NEW price starts this over: a `PUT` replace, each step of a `smart` liquidation's re-pricing ladder, and each step of the platform's own automatic re-pricing of a strategy's orders (bot sessions AND backtests) count as a fresh placement, so a re-priced order whose new price steps through the market fills at that step with a `fill_price` better than its own `price`. A `price` merely LEVEL with the market is NOT crossing: that order is resting and fills at its `price`. EVERY marketable fill is BOUNDED AT THE NATURAL — a debit never books above the net ask, a credit never below the net bid — so slippage can never push a fill past the price the market was showing. The mid-based execution price is what decides WHEN either fills: the mid, unless the bid/ask is exactly one tick wide (then the natural price) or the spread is an odd number of ticks (then the mid rounded one tick toward the market maker — up for a debit, down for a credit); multi-leg limits use the combo's net price under the same even/odd-tick rule. A triggered STOP fills marketable at that mid-based price. Single-leg equities use a synthetic ±$0.01 spread (market → natural, limit/stop → trade price). A `stop_loss`-class limit — the closing order a strategy or the Strategy Builder generates to cap a loss, never something an API caller declares — that the market has run adversely THROUGH for 3 consecutive seconds also fills marketable, instead of resting forever; on a sustained adverse move this beats the bot repricer's own stop-loss cadence, whose floor is 4s. - [Slippage](https://www.0dtespx.com/learn/orders): a single per-account setting (Settings → Simulator costs), NOT a per-order field or query param — a multiple of $0.05 from $0.00 to $1.00 (new accounts default to $0.05) applied identically to backtest, live, and practice trading. It affects limit/stop orders only (market orders fill at the natural price), and for a RESTING limit it shifts the fill CONDITION rather than the realized price: the order fills once the mid reaches `price ∓ slippage` (debit −, credit +) and held there for 3 seconds, then fills at `price`. The stored `execution_price` is that threshold; the order's `slippage`/`execution_price` echo the applied value. Every MARKETABLE fill realizes `mid ± slippage` BOUNDED AT THE NATURAL — a triggered stop, a `stop_loss`-class runaway exit, and a limit priced strictly past the market at the second its price was set (its placement, or the `PUT` replace / `smart`-ladder step that re-priced it), which books THAT second's `mid ± slippage`. The bound is hard: a debit never books above the net ask and a credit never below the net bid, so on a market one tick wide — where the mid-based price already IS the natural — slippage costs nothing at all. The fill CONDITION is deliberately NOT bounded, so a bounded fill can never turn a non-fill into a fill. In PRACTICE and LIVE trading that is how it is applied; BACKTEST RESULTS are computed at ZERO slippage, so your setting is applied to them as a flat per-contract cost overlay at read time, not by re-simulating the fills. - [SPX option price ticks](https://www.0dtespx.com/learn/orders): Single-leg < $3.00 → $0.05; single-leg ≥ $3.00 → $0.10; multi-leg → $0.05 always. Limit and stop prices must already land on the tick or HTTP 400. - Max-profit cap: a `limit` order's `price` cannot exceed the structural maximum profit of the leg combination (e.g. a $5-wide vertical caps at $5.00). Skipped when max profit is unbounded — naked long calls, ratio backspreads, calendars, mixed underlyings. Applies to practice place and live place / dry-run / replace (practice has no dry-run or replace). - Defined-risk only — no naked shorts: an order whose resulting position would hold an uncovered short option is rejected with `400 naked short positions are not allowed`, independent of capital (checked before the buying-power gate on both engines). A short put must be covered by a long put and a short call by a long call — a long call does not cover a short put. The gate reads the resulting book (filled + working + the new order), so it also blocks stripping the protective long out of a held spread; an over-close of a long is separately rejected `400 insufficient quantity to close` before it can flip to a naked short. Same rule the strategy builder + AI assistant + backtest runtime enforce. A naked long option is fine (defined risk). Practice rejections are JSON `{"message":…}`; live rejections are plain text (same message). - 0DTE SPX index options only: every order leg (practice and live) must be a 0DTE cash-settled SPX index option; an equity, ETF, non-SPX, or non-0DTE leg is rejected `400 only 0DTE SPX index options can be traded` (enforced on the live path at the exchange, matching practice). - [Mid-price calculation](https://www.0dtespx.com/learn/orders): SPX options round to nickel (< $3.00) or dime (≥ $3.00); other options to penny. One-tick spread uses ask for buys, bid for sells; an odd-tick spread rounds the mid one tick toward the market maker. - [End-of-day settlement](https://www.0dtespx.com/learn/settlement): At 16:00 ET, OTM expires worthless; each ITM leg settles as an `exercise` (credit for a long leg, debit for a short — SPX is cash-settled, so never an `assignment`). SPX/NDX/VIX/XSP settle at `(underlying − strike) × 100` for calls and `(strike − underlying) × 100` for puts. Each ITM leg settles independently (its own exercise, its own $5.00 fee); a fully in-the-money spread books both exercises and their cash flows net to the spread width — its defined max loss. - [Portfolio and financials](https://www.0dtespx.com/learn/positions): Positions, delta, brokerage fee schedule, unrealized/realized/total P&L, net-liquidation value, and FINRA maintenance-margin formulas (naked short, vertical, iron condor, covered). `delta` is a signed decimal — the wire preserves the stored sign (a put delta may be negative), though current data emits magnitudes so values are typically 0..1 positive; position direction supplies the additional position-level sign. Other Greeks (gamma, theta, vega, theo price, IV) are no longer provided. - [Market data and rate limiting](https://www.0dtespx.com/learn/api/rate-limits): ACCEPTABLE USE — the platform is for trading simulation and strategy research; bulk extraction of the historical dataset is NOT permitted at any pace (systematically walking past sessions to build a local copy or derivative dataset violates the terms even when paced under the credit budget — the budget bounds burst load, it is not a data license; offending accounts may be suspended and their networks may lose access). Mechanics: one leaky bucket per account — 10,000-credit capacity, ~0.116 credits/second drain (a request 429s when its cost exceeds remaining headroom `cap − used`, so `credits_used` can be below `credits_cap` and still reject). Metered market-data endpoints (all authenticated users — per-request costs 0–150, full-range snapshot priced per snapshot returned) the per-session strategy drill-in `GET /strategies/{id}/results/days/{date}` (10 credits — it re-runs the session through the engine; the portfolio drill-in costs member_count × 10), and AI-assistant message turns (50 credits, refunded on a failed turn — plus the assistant's own trailing-24h abuse limits: `content` ≤ 4 KB, 2 in-flight turns per account (`429 too_many_active_turns`), daily turn/token/cost caps (`429 assistant_budget_exceeded`), a 15 active-draft ceiling (`409 too_many_drafts`), and a brief `429 assistant_cooldown` for repeated off-topic messages — each `429` carrying `Retry-After`; the budget refusal ALONE also carries, in its BODY, `retry_at` (the UTC instant a turn fits again — the window ROLLS, so this is when your oldest activity ages out, never a midnight reset) plus `retry_after_seconds`, and its `Retry-After` may be under 60 s; the same `limited` + `retry_at` ride `GET /user`'s `assistant_usage`, so a client can show the wait without spending a turn to discover it — both are exact only while every turn in the window has settled, so re-check when the instant passes instead of assuming admission) draw on the bucket; the assistant may additionally spend, occasionally and unprompted, one **automated results-review turn** on a conversation of yours once that strategy's historical results are complete (most often when the strategy never entered a trade in any session — it either corrects the strategy or explains what it found, streaming over `assistant_draft_events` like any other turn): such a turn takes NO 50-credit debit, but it does count toward the same daily turn/token/cost caps, and while it runs the conversation is busy like any other turn (`409 turn_in_flight` on a message, cancelable with `POST …/{id}/cancel`); successful metered responses carry `X-RateLimit-Used`/`X-RateLimit-Limit` headers and the 429 carries `Retry-After`; unauthenticated traffic isn't metered but can only reach the most recent completed session at 30-second resolution. Backtest RUNS cost no credits: the backtester is protected by a per-user cap of 2 concurrently-executing runs (`429 too_many_active_backtests`, retry with backoff) and a priority queue. Everything else (auth, accounts, sessions, orders, positions, transactions) is free; the unauthenticated `/auth/*` endpoints carry a separate small per-IP abuse throttle (burst 20, refill 10/min — `429` with `Retry-After`). - [WebSocket protocol](https://www.0dtespx.com/learn/api/websocket): `GET /__ws` upgrades to WebSocket; client has 5 seconds to send `{"auth": "public"}` or `{"token": ""}`. Every server-sent message is a JSON object with a top-level `channel` field (the routing key); `session_events` / `backtest_events` / `live_bot_events` carry top-level `id` and `data`/`type` siblings instead of a nested `payload`. Client-to-server: auth + `{action:"subscribe"|"unsubscribe", channels|channel}`; for `session_events` add EITHER `account_id` (a MANUAL trading account you own — you then receive every trading day of that account, including the one your first order opens later, with no re-subscribe; bot account ids are declined) OR `session_id` (one session — the form bot pages use), never both in one SUBSCRIBE frame (such a subscribe is ignored whole; an unsubscribe naming both is honored and removes both scopes), plus an optional `last_event_by_sim` map (session id → highest seen id, ≤128 entries per frame on both forms — an over-cap frame is answered `subscription_rejected` and ignored whole); for `backtest_events` add `source_hash` to scope to one strategy's results (authorized against your saved strategy or live preview interest); `live_bot_events` takes NO scoping key (it is user-scoped and covers all your bots) but accepts `last_event_id` (scalar) or `last_event_ids` (`{"live_bot_events": N}`) for gap replay. Channels: `live_aggregate_data` (opt-in, 1s), `live_option_chain` (opt-in, 1s, token-authenticated users only; each strike's `call`/`put` carries only `bid`/`ask`/`delta` — deltas signed; default frame is an array of `{strike, call, put}` objects, or add `"encoding":"columnar"` to the subscribe for the equivalent compact structure-of-arrays frame `{k, cb, ca, cd, pb, pa, pd}` — strikes whole dollars, prices integer cents ÷100, deltas signed ten-thousandths ÷10000, all arrays equal-length and index-aligned; both encodings supported indefinitely; AVAILABILITY — frames start at today's session `data-start-time` (the per-date value in `GET /market-data/sessions`), NOT at the open: a subscribe made earlier is accepted and simply yields no frames until that instant passes, nothing is rejected and there is nothing to re-subscribe. `live_aggregate_data` is unaffected and streams from the open; the REST snapshot endpoints enforce the same bound), `session_events` (opt-in per session id OR per manual trading account; each envelope carries top-level `session_id` and `account_id` — route on `session_id`; ≤64 sessions and ≤8 accounts per conn. The ACCOUNT form is the live-trading one: the platform opens your trading day on your first order or dry-run, so at subscribe time there may be no day to name — the server enrolls the account's open day now, any day that opens later, and anything a dropped notification hid, all without a re-subscribe. A cursor-carrying account subscribe replays each open day's gap from its own `last_event_by_sim` entry and head-seeds the days it has no entry for; a day the server enrolls on its own is always delivered from its first event; `type` ∈ order_update|financials_update|ended|settlement_failed. 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 history: admission (`status:"pending"`, `accepted_seq:null`, emitted BEFORE the POST response), acceptance (`status:"live"`, `accepted_seq` stamped), then the terminal frame; an order a strategy placed carries `trade_id`/`trade_label` from its FIRST frame; a cancel request adds its own frame with unchanged `status` and `cancel_requested:true`, and that flag rides every later frame. A REPLACE adds a whole order's worth of frames, because the revision is its own order: an admission frame for the REVISION (`status:"pending"`, `replaces_order_id` naming the order being revised) is written BEFORE the PUT response, ahead of the original's `canceled` frame and the revision's own acceptance; if the replace is never applied the revision's next frame is `rejected` instead and the original USUALLY gets no terminal frame at all (it keeps working) — usually, because the original can be what ended the revision, filling or being canceled while it was in flight, in which case it emits its own terminal frame; either way the FRAMES, not the PUT's status code, say which order is live. The payload is the same order JSON the REST reads return plus `accepted_seq`; the `filled` frame additionally carries `fill_quotes`, the market quotes at the time of the fill (shape + full semantics under Orders below), and NO other frame does — the key is absent, never null. The ladder only moves FORWARD, so an envelope reporting an earlier state than one already applied is stale — drop it), `live_bot_events` (opt-in, token-only, NO per-key scoping — one subscribe streams lifecycle events for EVERY bot on the authenticated user's account; anonymous conns receive nothing; envelope is `{channel:"live_bot_events", id:N, data:{type, account_id (== the bot id), session_id, …payload}}` with `id` the monotonic outbox row id; the outbox is DURABLE — never dropped for a slow client — so a reconnect passing `last_event_id`/`last_event_ids` replays the exact gap before live events resume; a cursor-less subscribe (an OMITTED `last_event_id`, or 0) does NOT replay everything: it delivers the newest 500 events and then live traffic — a deliberate short OVERLAP with whatever you already loaded over REST, not a history feed, so expect events you already hold and de-duplicate on the envelope `id` (and on `data.log_id` for `decision_logged`); `"last_event_id": 1` is the full-backfill request (strictly every row ABOVE id 1 — everything retained bar a row with that exact id); RETENTION is per event type — `tick_progress` heartbeats kept 7 DAYS, every other type indefinitely — so a cursor older than that horizon replays only what remains, with NO signal that expired heartbeats were skipped (no `stale_cursor` analogue on this channel); a pruned heartbeat DOES take its point-in-time `tick_index`/`open_positions`/`open_orders` with it (nothing else stores them), but no value a client acts on, since the current ones are on the session's REST reads and the P&L curve on `…/history`; treat the socket as live traffic plus that overlap, and the REST endpoints (`…/decision-log`, `…/orders`, `…/positions`, `…/transactions`, `…/financials`, `…/history`) as the durable record of what they cover — but they do NOT cover everything: `decision_logged` is mirrored into the decision log and `session_started`/`session_completed` into the session record, while `signal`, `halted`, `registering`, `waiting_for_data`, `session_resumed`, `missed_ticks` and `error` have NO REST surface at all (`error` carries the strategy traceback nothing else exposes), so a client that needs those past the 500-event overlap must hold a cursor or pass `"last_event_id": 1` — all seven are retained indefinitely; `data.type` is one of eleven: `session_started` (payload `session_date`/`opening_balance`/`starting_capital`; the `started_by` "user"|"auto" distinction rides the paired `decision_logged` entry), `registering`, `waiting_for_data` (both emitted only when the session actually waits), `tick_progress` (~10s heartbeat: `tick_index`/`tick_at`/`nlv`/`open_positions`/`open_orders`; KEEPS FLOWING while a stopped session sits dormant holding positions), `decision_logged` (one decision-log entry mirrored live: `datetime`/`level`/`event`/`payload`/`source` ("strategy"|"runner"|"api" — "api" is an entry recorded against a request the user made)/`log_id` — carries ctx.log(), re-pricing activity (order_repriced, incl. `legs_changed` when the step re-selected the entry's legs, and reprice_failed), the pricing rows (order_start_priced and order_repriced BOTH carry `quotes_at` — leg option symbol -> the timestamp of the quote used for that leg, RFC 3339 UTC — plus `net_bid`/`net_ask`, the net bid and ask their price was computed from), the order lifecycle (order_filled/order_canceled/order_rejected), the ENTRY-RECOVERY pair (entry_retried — the platform re-submitted an entry the exchange TEMPORARILY refused, inside the window; payload order_id (a NEW id naming that re-submission, which may itself be refused — it matches an order only once one is accepted)/attempt/max_attempts/reason — and entry_retry_abandoned, AT MOST ONE per session, payload attempts/reason, when the entry could not be recovered and the day ends without a trade — also, for a strategy whose entry the platform works across the window, the entry that was never established by the END of that window, which the platform then WITHDRAWS; an older strategy reports the window's end as entry_window_closed alone), the platform's own entry notes (entry_place_failed — a PLATFORM attempt refused by the exchange or by a platform check such as a trade budget or the order guardrail, once per kind of refusal; entry_rebuild_yielded_none — armed and inside the window, but the strategy has offered nothing to place on every attempt so far; entry_rebuild_raised — the same phase, with the trade's builder raising when the platform tried to rebuild it, reported once with the message; entry_window_wrong_day — the strategy asked to open a trade on a weekday its own window excludes, rare live since such a session is refused at the start), the liquidation handoff (strategy_liquidation_started, payload reason — the strategy handed the session to the platform to close; and the rare strategy_liquidation_unclaimed / liquidation_start_failed, the platform standing down without touching the book so the positions ride to the close), guardrail_tripped (an order-rate guardrail trip with its counts — the decision-log companion to `halt_reason` 'order_guardrail_tripped'), entry_window_closed, and the session_started/strategy_stopped/strategy_restarted/session_ended lifecycle entries), `signal` (ctx.signal(): `name`/`fields`), `session_resumed` (`status`/`tick_index` — mid-session bot picked back up: after a platform restart, OR after `POST /bots/{id}/sessions/{session_id}/restart` resumed a stopped session), `missed_ticks` (`tick_index` plus `age_ms` or `overload:true`; one per stale episode, NOT per skipped tick), `halted` (`reason`/`source` — a strategy halting itself, non-terminal by itself), `session_completed` (TERMINAL, written in the finalize tx: `session_id`/`status`/`halt_reason`/`final_nlv`/`total_pl`/`realized_pl`/`fees_total`, money fields JSON strings, `status` ∈ completed|halted|stopped|failed), `error` (`callback`/`tick_index`/`message` truncated to 500 chars with a trailing ellipsis; on_tick/on_order_filled throttled to one per 15s). TRAP: `DELETE /bots/{id}/sessions/{session_id}` stops the STRATEGY, not the session, and emits NO lifecycle event of its own — it surfaces as a `decision_logged` entry with `event:"strategy_stopped"` while the row stays `running`; `session_completed` follows later with `status:"stopped"` once the session is flattened via the liquidation endpoints, or `status:"completed"` if held to the 16:00 ET close. A RESTART in between (`POST …/sessions/{session_id}/restart`) is a `decision_logged` entry with `event:"strategy_restarted"` (source "api") followed by `session_resumed`, after which the stream continues as if the stop had not happened. EXCEPTION — a stop with NOTHING LEFT IN THE MARKET ends the session instead of holding it (a session still scheduled/registering whose strategy never ran, or one already flat), and so does a flat book with no stop at all (entered-and-exited, or the entry window closed unfilled — `halt_reason` "flat_book" vs "user_stop"): a final `decision_logged` (the `session_ended` entry) then `session_completed` with `status:"stopped"` is the session's last event, at whatever time of day that happens. There is NO `stopped` event type), `backtest_events` (per-source-hash strategy-results progress frames — each carries the full fee-overlaid results snapshot under `data.snapshot`, so render straight from it; no replay; the HTTP results endpoint backs the initial load + reconnect; a dropped socket never stops a run; ≤256 strategies per conn), `assistant_draft_events` (opt-in per `draft_id`, owner-authorized — the AI-assistant chat stream; `data.type` is `delta`|`thinking` (streamed model reasoning, display-only)|`tool_status`|`ask_user`|`message` (an assistant message; `interim: true` — carried alongside `message_id`, the transcript row id — marks a mid-turn progress message the assistant keeps working after, or the partial text of a turn that ended early, so one turn may carry several `message` events, the last one without `interim` being the closing reply when the turn completes with one — a turn that closes by asking a question emits its `ask_user` card instead of an unflagged `message`)|`preview` (carries the new `source_hash` plus the updated `description`, `risks`, and `params` — the standardized `{legs, entry, exit}` summary, omitted on a draft not yet edited under the field)|`turn_complete`|`error`; optional `last_event_by_draft` cursor replays the gap on reconnect; ≤16 drafts per conn), `market_data` (opt-in **per symbol**, 1s cadence, lossy latest-wins — subscribe with a `symbols` list; underlyings `SPX`/`VIX` are anon-ok and emit `type:"trade"` frames carrying `price`, while a 21-char OSI for one of today's SPX 0DTE contracts is token-only and emits `type:"quote"` frames carrying `bid`/`ask`/signed `delta`; `datetime` is offset-less UTC; a wrong-day or unknown symbol yields no frame; ≤64 symbols per subscribe frame, ≤256 per conn; unsubscribe with `symbols` drops those, with no `symbols` clears all), `account_financials` (opt-in **per trading account**, token-only, 1s cadence, lossy latest-wins — subscribe with an `account_ids` list of manual and/or bot accounts you own; one initial snapshot frame per account then a live frame on every per-second change; the payload is the account's live financials (NLV, realized/unrealized P&L split, credits/debits/fees, buying power, equity-options and equities breakdowns) with every money field a JSON string with trailing zeros stripped and `timestamp` RFC3339 with a `Z` — identical to `GET /accounts/{id}/history`; lossy and NOT authoritative on its own, so reseed from that history endpoint after a gap; ≤64 account ids per subscribe frame, ≤64 per conn; unsubscribe with `account_ids` drops those, with no `account_ids` clears all), `end_of_data` (terminal sentinel). For `session_events`, `assistant_draft_events`, and `backtest_events` an over-cap subscribe is refused (existing subs keep streaming) with a `{channel:"subscription_rejected", payload:{target, reason:"limit_exceeded", limit}}` envelope (`target` = `session_events`|`assistant_draft_events`|`backtest_events`; for `session_events` the `limit` says which bound was hit — 64 sessions, 8 accounts, or 128 cursors in one frame); `market_data` and `account_financials` instead silently truncate (keys past the cap dropped, the rest subscribed, no envelope); auth failures decline silently. A `session_events` cursor naming an ended-and-cleaned-up day gets a one-shot `{channel:"session_events", type:"stale_cursor", session_id}` signal and that day is not enrolled — on the session form (a cursor-carrying resubscribe) and on the account form (every `last_event_by_sim` entry naming a day of yours that has ended) alike; the envelope carries NO `account_id`, so route it by `session_id`. Refetch state over REST and drop the stale cursor. Server-initiated close codes: 1008 (inbound frame-rate budget exceeded, or token revoked/expired — reconnect + re-auth) and 1013 (subscribe/unsubscribe frame not admissible, control-frame queue saturated — reconnect + resubscribe); ordinary disconnects are code-less. Public connections have a 5-minute stream cap and may subscribe to `live_aggregate_data` and to `market_data` for the `SPX`/`VIX` underlyings, but not to `live_option_chain`, `market_data` option symbols, or `account_financials`. - [Live trading](https://www.0dtespx.com/learn/live-trading): Today's TRADING DAY on a `live_sim` account, registered (authenticated) users only. THERE IS NO CALL THAT OPENS A DAY: your day opens by itself on your first order — `POST /accounts/{id}/orders` — and A DRY-RUN (`POST /accounts/{id}/orders/dry-run`) OPENS IT TOO, so a preview is the one non-persisting call that mutates. Opening is allowed only once the current time has reached the session's `data-start-time` and ticks are fresh (else 400), and is refused `409 account_archived` on an archived account, `409 engine_not_available` on a broker engine, and `409 day_closed` when today's day already settled; the opening balance is the account's carried `cash_balance`. Read `GET /accounts/{id}/days/current` right after that first call to learn the day's `id` + `date` (`null` when no day is open). Each live account has at most one day per date, and today's day cannot be opened while an EARLIER day of that account is still open and unsettled (`409 previous_session_unsettled` — its result is not in the balance yet; retry later; that earlier day stays readable through its own `/accounts/{id}/days/{date}/…` routes and its CANCEL path stays available there, while a `PUT` revision on it answers `400 market_closed` because its close has passed — the day resolves whatever is still working on it at that close). Opening the day never waits on the exchange: the day is created by that call alone and the exchange is told about it in the background, so orders placed next queue behind that hand-off. Persisted order lifecycle (no time-travel) on the ladder `pending → routed → live → {filled,canceled,expired,rejected}` (see Orders below), `409` on cancel of an already-terminal order and on replace of one that is terminal, already being canceled, or already carrying a working revision — a replace is accepted on ANY working order and its revision is admitted as an order of its own, linked by `replaces_order_id` (see Orders below), post-close gate at 16:00 ET, automatic settlement once the close-time tick is published (closing balance carries into the account). QUEUED INTENT: a `limit` or `stop` placement, a replace and a cancel are valid until the day's close, so when the exchange has not confirmed one inside the ~5 s answer window the API returns `202` with the order row as it stands (`pending`, or `cancel_requested: true` for a cancel) and the request keeps riding — the order IS placed, the exchange confirms it when it processes it, and at the latest the close resolves it. For a PLACEMENT or a replace's revision — the rows that name an order the API admitted — that ending is `rejected` with `rejection_reason: not_processed_before_close`. A queued CANCEL names no row of its own, so the order it was chasing is resolved by the close on its own shape, with `cancel_requested` still true: an order that was RESTING on the book is `expired` like any other working order, while an order still `pending` because its own placement was never processed is `rejected` with `not_processed_before_close`. A `market` order keeps the 5 s contract: it is the placement that still returns `503` (as does any order write in the last 5 s before the close), and it is stamped `rejected` with `deadline_exceeded`. `503` returns are always safe to retry but mean "the exchange could not process this in time", NOT "nothing was applied" — the order list resolves it. A `504 outcome_unknown` on a mutating request (order place/cancel/replace) means the exchange could not confirm within the deadline whether it applied — resolve by retrying under the same `Idempotency-Key` (the retry returns the original outcome) or by re-reading the order/day over GET. Once started, a live day cannot be stopped or abandoned — it runs to the 16:00 ET close and settles there. - [User accounts](https://www.0dtespx.com/learn/api/overview): Email + password registration with 6-digit verification (locked after 5 failed submissions), token-based sessions; verification codes are single-use and `POST /auth/register` consumes the code and returns the first session's token directly (no follow-up login call). Registration is bounded: one account per email inbox (provider aliases of the same mailbox — e.g. Gmail dots/+suffix variants — count as the same inbox, rejected 400) and a small daily allowance of new accounts per network (429 when exhausted). A user account is free and all registered users have full access (distinct from a trading account, which is a per-user trading container). ## API — endpoints - [Authentication](https://www.0dtespx.com/openapi.yaml): `POST /auth/check-email`, `POST /auth/verify-email`, `POST /auth/register` (returns `{token}` — the first session), `POST /auth/forgot-password`, `POST /auth/reset-password`, `POST /auth/sessions` (login), `DELETE /auth/sessions` (logout). - [User account](https://www.0dtespx.com/openapi.yaml): `GET/PATCH /user` (`GET` returns `id`, `email`, `usage_percent`, `fee_schedule`; `PATCH` accepts `email`/`verification_code`, `password`/`verification_code`, `fee_schedule`; an email change to an inbox already in use returns `409`). Account deletion: `POST /user/delete-code` emails a 6-digit confirmation code (one per 60 s), then `POST /user/delete` with `{verification_code}` permanently deletes the account and all its data (unrecoverable), all sessions revoked (`Clear-Site-Data`). Requires no open live/bot sessions (`409 session_open`). - [Accounts](https://www.0dtespx.com/openapi.yaml): the `/accounts` surface is LIVE (manual) accounts only — `type` is always `manual`; bots are a separate surface (`/bots`) and never appear here or resolve through `/accounts/{id}` (`live_sim` engine; broker `broker_*` reserved). `GET /accounts` (live accounts only), `POST /accounts` (`{name, engine, description?, type?}`; every account starts with a fixed $100,000 — there is no starting-capital input; broker engines → `409 engine_not_available`; `type` defaults to `manual`, the only accepted value — a bot account is created via `POST /bots`, not here; duplicate active name → `409 name_taken`), `GET /accounts/{id}` (a bot id → `404`), `PATCH /accounts/{id}` (`name`/`description`/`status:archived`; archive blocked while a trading day (or bot session) is open → `409 day_open`), `DELETE /accounts/{id}` (soft-delete; blocked the same way → `409 day_open`), `GET /accounts/{id}/analytics` (round-trip trade log + aggregate trade metrics over settled days — folds settled-day transactions into round-trip trades: win rate per round trip (scratches excluded, null when undecided), profit factor, average/largest winner+loser, hold times, net-of-fees P&L; per-day trade counts; the trade log newest-first capped at the most recent 1000 with `trades_truncated`; read-only + unmetered; manual live accounts only). - [Trading days (account-scoped — LIVE/REPLAY only)](https://www.0dtespx.com/openapi.yaml): the public API is NOT session-keyed — you trade an ACCOUNT and the api resolves its day. `GET /accounts/{id}/days` (newest first: the settled archive plus the open day), `GET /accounts/{id}/days/current` (the OPEN day or `null` — the "tradeable right now" probe; it goes null AT SETTLEMENT, not at cleanup), `GET /accounts/{id}/days/{date}` (`YYYY-MM-DD` New York; 400 bad date, 404 `day_not_found`), `GET /accounts/{id}/days/{date}/history` and `GET /accounts/{id}/history` (per-second financial snapshots; `?interval=`; the bare form is the ACTIVE day and answers `[]` when there is none). THERE IS NO OPEN CALL and no past-date open on this surface: a day opens on the first order or dry-run (see Live trading above), the open NEVER WAITS ON THE EXCHANGE (the exchange is told about the day in the background, and the placement's own 201/202 is the answer), and once started a day cannot be stopped — it settles at the close. Today's day is NOT opened while an EARLIER day of the same account is still open and unsettled: the placement is refused `409 previous_session_unsettled` (retry later), while that earlier day stays readable through its own `/days/{date}/…` routes and CANCELLABLE there (a cancel is never clock-gated; it is a plain 5 s request past the close, so 204/503, never 202), a `PUT` revision on it answering `400 market_closed`. TWO RESOLUTION TERMS used everywhere: the OPEN DAY is the account's newest open row (after a delayed settlement that can be an EARLIER date than today, and the newest always wins) and is what a placement targets (opening today's day when there is none); the ACTIVE DAY is the open day, else today's settled day, and is what every bare read AND every bare cancel/replace/liquidation resolves (none → `[]`, or 404 for a single item; a write on a settled day → `409 day_closed`). While a day is `open` it carries `settles_at` (expected settlement time = `end_time` + a few minutes) — after the close, poll the list around that time for the settled day to appear. A settled day is read back (REPLAY) via `/days/{date}/…` with `?at=` (see positions/transactions below). There is no PATCH-clock on this surface — clock control moved to practice. - [Practice sessions (account-less, fixed $100k)](https://www.0dtespx.com/openapi.yaml): replaying a trading day needs NO account — a one-click $100k sandbox with no `account_id`. `POST /practice/sessions` (`{date}` ANY past trading date, started in ANY order — each POST opens a NEW independent session, so a date can hold multiple simulations; practice days are independent (no carried balance, no re-thread); to edit a settled day rewind its clock via PATCH; each opens at market open with a fixed $100,000, no deposit row; → 201 the session), `GET /practice/sessions` (list, newest first), `GET /practice/sessions/{sid}` (hydrate one), `PATCH /practice/sessions/{sid}` (`{time}` advance/rewind; crossing the 16:00 ET close settles), `DELETE /practice/sessions/{sid}` (soft-delete the day and its trades → 204), `GET /practice/sessions/{sid}/orders`, `POST /practice/sessions/{sid}/orders` (place — rejected `400` while the replay clock is before the day's `data-start-time` (the moment historical data begins); practice has NO `orders/dry-run` and NO `PUT .../orders/{orderId}` replace; those are live-only), `GET /practice/sessions/{sid}/orders/{orderId}`, `DELETE /practice/sessions/{sid}/orders/{orderId}` (cancel/delete a not-yet-filled order; practice is synchronous, so it applies at once), `POST /practice/sessions/{sid}/liquidations` (close every open position in one synchronous call — see Liquidations below), `GET /practice/sessions/{sid}/positions`, `GET /practice/sessions/{sid}/transactions`, `GET /practice/sessions/{sid}/history` (per-second snapshots; `?interval=`). The `/orders`, `/positions`, `/transactions`, `/history` sub-resource shapes are identical to their account-scoped counterparts; only the path prefix differs. - [Orders (practice `/practice/sessions/{sid}/…` and live `/accounts/{id}/…`, plus the date-keyed live form `/accounts/{id}/days/{date}/…`)](https://www.0dtespx.com/openapi.yaml): `GET …/orders`, `POST …/orders` (supports `Idempotency-Key`, scoped per trading day), `GET …/orders/{orderId}`, `DELETE …/orders/{orderId}` (cancel/delete; live cancel is a best-effort REQUEST accepted in EVERY non-terminal state, and after the close too — 204 means accepted and queued, not applied; a cancel of an order the exchange has NOT taken up yet resolves it `canceled` AT ONCE (the placement is never processed, and queued revisions of it are cancelled with it), a cancel still travelling when the answer window ends returns `202` with the order as it stands (`cancel_requested: true`), and 409 comes when the order is already terminal or when the `Idempotency-Key` was already used for a DIFFERENT order (`idempotency_key_reuse` — send a new key)). On the live surface the bare form resolves the account's ACTIVE day (the open day, else today's settled day — a write on it is `409 day_closed`) and the DATE-KEYED form (`GET|PUT|DELETE /accounts/{id}/days/{date}/orders/{orderId}`, `GET /accounts/{id}/days/{date}/orders`) names one — that is the escape for an OLDER OPEN DAY a delayed settlement left open beside today's, and the only way to cancel or revise on it; a write on a settled day is `409 day_closed`, on an unknown date `404 day_not_found`. There is deliberately NO date-keyed POST: a placement always opens/targets TODAY. LIVE-ONLY additions: `POST /accounts/{id}/orders/dry-run` (validate + preview, not persisted BUT IT OPENS TODAY'S DAY; a strictly-crossing limit and a market order preview `filled` with the fill price; any other limit previews `live`; a dry run never previews `pending`) and `PUT /accounts/{id}/orders/{orderId}` (revise a working limit order's price — `price`/`price_effect` only, type/legs/underlying carried from the order being revised, slippage re-resolved from the account setting as it stands NOW. THE REVISION IS ITS OWN ORDER: it is recorded `pending` the moment the call is accepted, carrying `replaces_order_id` = the id in the path, and the exchange then cancels the original and rests the revision in ONE transaction — so the book never holds two resting copies while the order list holds BOTH, linked; the 200 body is the revision under its NEW id. Accepted against ANY WORKING order (`pending`/`routed`/`live`) — one that has not reached the book still has an intent to revise; non-limit → 400; 409s: `order is no longer working` (terminal), `order is being canceled` (`cancel_requested` set), `a replace is already pending for this order` (one working revision per order — revising a revision, A→B→C, is fine; a SECOND revision of the same order is not), plus, on a reused `Idempotency-Key`, `idempotency_key_reuse` (that key belongs to a DIFFERENT order) and `replace_attempt_resolved` (the revision that key already minted has resolved — send the new price under a new key). A replace that is never applied leaves the revision in the order list with the original still working — `canceled` if you cancelled the revision, `rejected` (`not_processed_before_close`) if the close resolved it — which is what makes a `202` or a 503 on this call readable rather than ambiguous; a replace not confirmed inside the answer window returns `202` with the revision `pending`, and the in-transaction re-check of the target can also answer `409 order is no longer working` / `409 order is being canceled` / `404 order not found` after the pre-check passed) — practice has neither. Practice orders return the `Order` shape (status computed against the replay clock); live orders return the `LiveOrder` shape. Live `status` walks the ladder `pending → routed → live → {filled,canceled,expired,rejected}`, always FORWARD, may skip a rung, and the first terminal recorded is final: `pending` = we accepted it, nothing downstream has confirmed it — usually sub-second, but it lasts until the exchange processes the order (the order is PLACED throughout, and a `202` is how a queued placement/replace is answered); `routed` = confirmed by the next hop, not yet on a book — RESERVED, never emitted today, but code for it; `live` = resting on the book and fillable (this is the state old clients called `pending`). All three non-terminal states are WORKING: they count toward open orders and accept BOTH a cancel and a replace. Buying power is reserved once the order RESTS ON THE BOOK — a `pending` order has been accepted and not yet confirmed, so it reserves nothing until it is, and that includes a revision waiting to take an order's place. A separate boolean `cancel_requested` (present only when true, orthogonal to `status`, on both the REST reads and the `order_update` event) means a cancel was ACCEPTED but not necessarily applied — it can ride ANY status including `filled`, because a cancel that loses the race to a fill is irreducible; treat the terminal status, not the 204, as the outcome. `accepted_seq` (WebSocket `order_update` only, omitted from REST) is the per-session BOOK ordinal, `null` while the order is `pending`/`routed` and stamped the moment it is sequenced — normally the move to `live`, but an order refused in that same instant goes straight to `rejected` carrying its ordinal, so a terminal order with an `accepted_seq` did not necessarily rest on the book. An order a STRATEGY placed additionally carries `trade_id`/`trade_label` — the trade it belongs to, matching the session's `trades` rows — on the REST reads and from the FIRST `order_update` frame alike; both are ABSENT on every order you place yourself (group those by the trades read's `order_ids`). A FILLED live or bot order additionally carries `fill_quotes` — THE MARKET QUOTES AT THE TIME OF THE FILL, not the inputs to the fill price — shaped `{quotes: {: {bid, ask}}, quotes_at: {: RFC3339 UTC}, net_bid, net_ask}` with every price a decimal string. It is emitted ONLY on `status: "filled"` (REST reads, the POST/PUT/dry-run body, and the `order_update` event alike) and is otherwise ABSENT — never null — which covers every other status, every order filled before the field shipped, and EVERY PRACTICE ORDER (the practice engine prices against historical data and never captures it); a leg whose quote could not be read drops the WHOLE object rather than emitting a partial snapshot, and every leg of the order is present when it is there. `quotes_at` is each leg's OWN quote timestamp, not the fill second: on an order that rested and confirmed it can sit up to a second on EITHER side of `fill_datetime` (trailing OR leading), and on a `market` order — priced off the latest quotes but stamped with the placement moment — it can trail by a few seconds, so read it instead of assuming the fill second. `net_bid`/`net_ask` are the order's net bid/ask at that moment PER UNIT (the leg-quantity GCD divided out, so a 2x/2x vertical reports the same numbers as 1x/1x), direction carried by `price_effect`/`fill_price_effect` and never by a sign, so a ratio spread's net cannot be re-derived by summing `quotes`. THEY ARE NOT PROMISED POSITIVE: on a near-zero or very wide combo the natural bid side crosses through zero, so `net_bid` can be 0 or NEGATIVE (a wide debit vertical can report net_bid "-0.25" against net_ask "0.55"); the only ordering guarantee is net_bid <= net_ask. They do NOT bound `fill_price` (a resting limit that the market crossed books its own price even when the crossing market is better; slippage worsens a marketable fill, though never past the natural) and none of these values is an execution-quality or slippage readout; there is no midpoint field — the reference price a limit/stop is measured against comes from the tick rules, not from averaging the two bounds. Practice/backtest orders are born `live` — those engines are synchronous, so they never have an unconfirmed phase. `POST` also accepts an optional `client_order_id` UUID (live only) — when omitted the API derives one from the `Idempotency-Key` (or fresh UUID) so a replay-after-crash returns the existing terminal order. `replaces_order_id` (a UUID naming the order this one revised) is present ONLY on a revision — on the REST reads, the PUT body and the `order_update` frame alike, in EVERY status including the terminal one, since a refused revision's `rejected` row has to keep saying what it was revising; it is otherwise ABSENT, never null, and a practice order never carries one. A `503` on an order write means THE EXCHANGE COULD NOT PROCESS the request in time — it is a MARKET-order answer (plus any order write in the last 5 s before the close; every other type is queued and answered `202`), always safe to retry (under the same `Idempotency-Key`), but NOT proof that nothing was applied: the order list is the truth, a market order the exchange never confirmed is stamped `rejected` there, and an unconfirmed replace usually leaves the original working next to a `pending` or `rejected` revision (a request that applied without being confirmed instead shows the revision resolved and the original `canceled` — the list, not the status code, is the truth); a `504 outcome_unknown` (live place/cancel/replace) means the exchange could not confirm whether the request applied — retry under the same `Idempotency-Key` (the retry returns the original outcome; a `409 duplicate_idempotency_key` while a 5 s request is still in flight means keep retrying — a replay of a still-QUEUED write answers `202` with its row instead) or re-read the order over GET. `rejection_reason` values include `insufficient_buying_power`, `deadline_exceeded` (a market order not processed in its 5 s), `not_processed_before_close` (a queued order the close reached first) and `stop_already_triggered` (a QUEUED `stop` — one the exchange did not process inside the answer window — whose trigger the market had already reached by the second it was finally processed: refused, not fired late; a stop the exchange takes up promptly rests and triggers as usual). - [Liquidations (close every open position; per trading day)](https://www.0dtespx.com/openapi.yaml): a liquidation closes EVERY open position on a trading day under one of two methods — `smart` (positions grouped into favorable multi-leg LIMIT orders of up to 4 legs, each working order re-priced every 5 seconds, one tick at a time toward the market and never back away from it) or `aggressive` (one MARKET order per position in two rounds — every short bought back first, then the longs those closes release; a leg with no usable natural price falls back to a one-tick $0.05 limit order, which may rest). Grouping is naked-short-safe by construction: no subset of the closing fills can leave an uncovered short, so a liquidation is never refused by the defined-risk gate. STARTING ONE CANCELS EVERY WORKING ORDER ON THAT DAY, INCLUDING STOP-LOSSES AND TAKE-PROFITS, AND THEY ARE NOT RESTORED — not on completion, not on a partial end, not on cancel. Also available on a STOPPED BOT SESSION at `/bots/{id}/sessions/{session_id}/liquidations[/current]` (same shapes; gated on `strategy_stop_applied_at`, and STARTING ONE PERMANENTLY BLOCKS THAT SESSION'S RESTART via the never-cleared `liquidated_at` — see Bots). A BOT SESSION'S LIQUIDATION HAS AN OWNER, on the read-only `liquidated_by` (`user`|`strategy`, set with `liquidated_at`, absent otherwise): `strategy` means the STRATEGY handed the session to the platform to close and the platform is running that liquidation — `POST` and `DELETE …/liquidations[/current]` then both answer `409 liquidation_strategy_owned`, permanently, INCLUDING after it finishes (a session the platform flattened has nothing left to close; one it flattened only partially rides to the close like any other open position), while `GET …/liquidations/current` reports its progress in the identical shape. That GET is answered ACROSS PROCESSES — the platform runs the walk and mirrors its snapshot on its own cadence, so a strategy-owned snapshot is a progress report a few seconds behind at most, final once the liquidation ends. A PLATFORM RESTART ABANDONS A LIQUIDATION YOU STARTED (a strategy-owned one is re-adopted and carries on): it is not resumed, the orders it had placed stay resting as ORDINARY working orders under `GET …/orders` (cancelable like any other) and its unclosed positions are still open, and the document is rewritten `failed` with the reason appended to `errors[]` and its orders/position counts preserved — so the read says what it had done instead of going quiet. A fresh `POST` plans from whatever is still open and is how you finish the job; `liquidated_at` is already stamped and stays so. When the book goes flat the session ends `stopped` with `halt_reason` `strategy_liquidated`. LIVE (asynchronous): `POST /accounts/{id}/liquidations` `{method: smart|aggressive}` → `202` + the initial snapshot (`409 liquidation_in_progress` when one is already running, `409 no_open_positions` on a flat book, `409 day_not_open` when the account has NO open day — a liquidation never opens one — `409 day_closed` on a settled day, `400` on a bad method), `GET /accounts/{id}/liquidations/current` → the running snapshot or the last terminal one (retained ~15 minutes, or until the next start on that day; `404 no_liquidation` otherwise), `DELETE /accounts/{id}/liquidations/current` → stops the workflow, cancels every order it has working, and returns `200` + the final snapshot only once those cancellations are terminal. All three also take the date-keyed form (`POST /accounts/{id}/days/{date}/liquidations`, `GET|DELETE /accounts/{id}/days/{date}/liquidations/current`) so an older open day can still be flattened. While one runs, manual order `POST`/`PUT` on that day return `409 liquidation_in_progress`; order `DELETE` stays allowed. A liquidation is not durable state — an interrupted one leaves the orders it already placed resting as ordinary working orders (readable under `GET …/orders`, cancelable), and re-running it adopts whatever is still open. PRACTICE (synchronous): `POST /practice/sessions/{sid}/liquidations` `{method}` → `200` with the FINISHED summary, nothing to poll and nothing to cancel (`409 no_liquidatable_positions` when every open position is already covered by a working closing order); the same close plan is walked forward through the day's historical data from the replay clock, the caller's slippage setting governs how fast its limit orders fill, the cancellations and the closing orders are applied atomically (any failure changes nothing at all) with account statistics recomputed immediately afterwards, every timestamp is a SESSION time and the recorded orders can be dated AFTER the current clock (advance it to watch them fill), and a group that never filled before the close is not persisted (reported with status `unfilled` and no `order_id`, summary status `partial` — re-running is the retry). Snapshot shape (identical on both surfaces): `{method, status, started_at, ended_at?, orders: [{order_id?, status, legs_summary, current_price?}], positions_remaining, positions_unresolved?, errors: []}` with `status` ∈ `running|completed|partial|canceled|failed` (`running` live-only; `completed` = flat; `partial` = ended with positions still open, including a session that closed mid-liquidation) and each order's `status` the usual lifecycle value plus `stalled` (a live order parked after an exchange rejection — it lands in `errors[]` and the rest of the book keeps closing) or `unfilled` (an unpersisted practice group). `positions_unresolved` (omitted when zero) counts longs the aggressive method could not sell because the shorts covering them never bought back. - [Positions and transactions (same paths under `/practice/sessions/{sid}/…`, `/accounts/{id}/…` and the date-keyed `/accounts/{id}/days/{date}/…`)](https://www.0dtespx.com/openapi.yaml): `GET …/positions` (computed at the day's current time — replay clock for practice, latest tick for an open live day; includes delta and P&L; the bare live form is the ACTIVE day and answers `[]` when there is none), `GET …/transactions` (the session's WHOLE ledger, newest first, with NO time cutoff — on a practice session that includes rows dated AFTER its clock: the precomputed fills of orders the clock has not reached yet, plus the day's projected 16:00 ET settlement rows for the orders currently on the book, recomputed on every order change). Both accept an optional `?at=` cursor that overrides the clock for a single stateless read without advancing the stored time — on transactions it is the ONLY way to get an as-of-a-moment view. A SETTLED day is fully replayable — its positions/transactions are reconstructed server-side from the market data (settlement transactions always included), so `?at=` scrubs any moment of a live account's past day (name it with `/days/{date}/…`). Absent `?at=`: positions are computed at the stored day time (a settled day: its close) while transactions come back in full; on an open day `?at=` is ignored. - [Market data](https://www.0dtespx.com/openapi.yaml): `GET /market-data/sessions`, `GET /market-data/strikes/{date}`, `GET /market-data/historical/{date}` (selectable series via `?series=`), `GET /market-data/option-chain-snapshots/{timestamp}`, `GET /market-data/option-chain-snapshots/{startTime}/{endTime}` (both bounded below by the session's `data-start-time`, on EVERY date, historical included — usually a minute past the open but not always, so read it per date from `GET /market-data/sessions` instead of assuming an offset from `start-time`: a single timestamp below the bound is a 404; a range starting below it is clamped and returns only the snapshots at or after it; a range lying entirely below it is a 404, NOT an empty array. Clamping does NOT reduce the credit cost — the range charge is computed from the requested `endTime − startTime` before the response is built, so a clamped or 404 range still costs its requested span. Same bound as the `live_option_chain` WS channel), `GET /market-data/average-expected-move`, `POST /market-data/options-prices/{date}` (net mid price of a custom ≤4-leg SPX 0DTE order across a session — body `{items:[{direction,side,strike,quantity}]}`, returns `[{timestamp,datetimeUnix,price,effect}]`; public for the most recent completed session at 30s resolution, authed for all sessions at 1s; 10 credits). - [WebSocket](https://www.0dtespx.com/openapi.yaml): `GET /__ws` — live market data feed (`live_option_chain`, and the `market_data` option-contract `type:"quote"` frames derived from it, start at today's session `data-start-time` rather than the open; `live_aggregate_data` and the `SPX`/`VIX` `type:"trade"` frames stream from the open), plus `backtest_events`: per-source-hash strategy-results progress frames (subscribe with `source_hash`; each frame carries the fee-overlaid results snapshot under `data.snapshot`; the HTTP results endpoints back the initial load + reconnect; any authenticated user); `live_bot_events`: the durable per-user bot lifecycle outbox (subscribe with no scoping key — one subscription covers every bot on the account; envelope `{channel, id, data:{type, account_id, session_id, …}}`; eleven `data.type` values from `session_started` through the terminal `session_completed`; `last_event_id`/`last_event_ids` replays the exact gap on reconnect, while a cursor-less subscribe replays only the newest 500 events — a short overlap with your REST reads, with `"last_event_id": 1` the full-backfill request (every row ABOVE id 1); seven types (`signal`, `halted`, `registering`, `waiting_for_data`, `session_resumed`, `missed_ticks`, `error`) have no REST equivalent, so the cursor is the only way to recover them past the overlap; `tick_progress` heartbeats are retained 7 days and every other type indefinitely; token-only); `market_data`: opt-in per-symbol 1s market data (subscribe with `symbols`; `SPX`/`VIX` trades are anon-ok, today's SPX 0DTE option-contract quotes are token-only); `account_financials`: opt-in per-account live financials (token-only; subscribe with `account_ids`; initial snapshot then per-second frames; money fields JSON strings and `timestamp` RFC3339 `Z`, matching `GET /accounts/{id}/history`; lossy — reseed from that history endpoint after a gap). - [Saved strategies](https://www.0dtespx.com/openapi.yaml): strategies are immutable, and every distinct configuration compiles to a deterministic program keyed by its `source_hash` (the key the preview and WebSocket endpoints use); results always cover every trading session at a fixed $100k per-session capital, fees are applied at read time from the caller's fee schedule, and results always show the full history (the window is labeled explicitly and is always all trading sessions). Each saved link has a per-user `privacy` flag — `private` (default) or `public`; public means UNLISTED ("anyone with the link"), NOT discoverable — there is no directory of public strategies and the id is an unguessable UUID, so a public strategy is reachable only by someone given its link, and once they have it any user or unauthenticated visitor can view it — and any SIGNED-IN user (never an anonymous visitor) can additionally read its CODE via `GET /strategies/{id}/source` and fork it by re-saving through the source producer below, whatever wrote it originally — and clone a **Builder** strategy (read its `config`, re-save; no clone endpoint) — while a private one is owner-only; an **assistant** strategy has no `config`, so cloning it forks the owner's conversation (`…/{id}/fork`) into a separate thread and is OWNER-ONLY (a shared one can't be cloned by others — it would expose the owner's chat). A BUILDER strategy opens exactly ONE trade per session — `config.entry.time` is a single entry time (for several daily entries, either have the ASSISTANT or the CODE EDITOR write one strategy holding several trades, or make one strategy per time and combine them in a portfolio when each should be funded and attributed separately); a strategy may open SEVERAL trades in a session, in which case `trades` reports one row each and they may overlap in time — never assume a single trade owns a session. `config.entry.days_of_week` optionally restricts to specific weekdays. Every saved strategy also exposes a derived declarative `entry` window on `GET /strategies/{id}` — {start_time, end_time, days_of_week?} (ET; days Monday=0..Sunday=6, absent = every trading day): a builder strategy's is the form time + 1 minute; an assistant strategy declares either a specific time or a genuine range. Bots gate session starts on it (422 not_entry_day / entry_window_passed). `null` on strategies saved before entry windows existed. `POST /strategies/preview` (`{config | source, abandon_hash?}` — the per-change preview action of the builder and the code editor; every distinct config IS a persisted backtest keyed by its source hash; the `config` producer returns the current fee-overlaid snapshot plus the generated artifacts incl. `params` (the standardized `{legs, entry, exit}` summary), instant for a config that already has results, `400 invalid_config` with a `fields` map creates nothing, `429 too_many_active_backtests` at the 2-active-runs cap, retry with backoff; the `source` producer takes the same 24 KB Starlark `POST /strategies/validate` checks (`source` wins when both are sent, the save's precedence), answers `{snapshot}` alone (no generated artifacts), rejects bad code as `400 invalid_source` with ONE human-readable `message`, costs NO credits and runs no real-session check (both stay validate's alone), and NEVER 429s — at the cap the run parks and starts automatically, like a save; `408` = the request was canceled before its check finished, so nothing was created and nothing was judged about the config — resend it), `GET /strategies/preview/{source_hash}/results` (the pre-save read path; doubles as the interest heartbeat keeping an unsaved preview's run alive), `POST /strategies/preview/abandon` (`{source_hash}`; stops the run at the next session boundary if it was never saved (completed sessions are kept); interest also self-expires after 15 min), `POST /strategies` (`{config, privacy?}` — SAVE = publish: publishes the immutable strategy by hash and keeps the already-running/done backtest; `privacy` optional, defaults `private`; never 429s — at the cap the run is parked and auto-starts later; re-saving a config you removed resurrects the same id with its results; a NEW save while already holding 500 saved strategies returns `409 strategy_limit_reached` and creates nothing — re-saving one you already have doesn't count; `408` = the request was canceled before its check finished, nothing created and nothing judged, resend it), `POST /strategies` with `{source, privacy?}` (the THIRD producer — Starlark the caller wrote themselves, put through the same gate as `POST /strategies/validate` and published as `origin:"manual"`; the strategy's `entry` window is DERIVED from the source's mandatory module-level PLATFORM_ENTRY_WINDOW declaration, so there is no entry field to send; this is the ONLY `POST /strategies` branch that costs credits (10) and the only one that can 429 — config/draft_id saves are free; `400 invalid_source` with ONE human-readable first-failure `message`, `409 strategy_limit_reached` checked BEFORE any charge or run, `503 smoke_busy` (still charged — back off, do not spin) / `503 smoke_unavailable` (refunded). Producer precedence when more than one is sent is tolerant, never a rejection: `draft_id` > `source` > `config`, the rest ignored), `POST /strategies/validate` (`{source}` — runs the identical gate and creates NOTHING: no strategy, no link, no backtest, no queued run; `200 {ok:true, source_hash, warning?}` where `warning` is ADVISORY only (what the code did on a checked session, e.g. it never entered a trade) and never a reason the save would fail; a 200 is a promise the save will not reject the same bytes; 10 credits per call INCLUDING a repeat of just-checked code; same 400/503 shapes as the save), `GET /strategies/{id}/source` (`{source, contract_version}` — the strategy's program exactly as saved; AUTH REQUIRED, unlike every other strategy read: own or public only, everything else 404 (not an existence oracle), and an anonymous visitor gets 401; 2 credits. `contract_version` is the platform contract the code was written for — older code will NOT pass `POST /strategies/validate` unchanged, since a save always writes the current contract), `GET /strategies` (the leaderboard: each row carries full-history headline metrics net of the default fee schedule, coverage, staleness, `privacy`, a short `title` (auto-generated, owner-renamable via PATCH), and `params` — the standardized summary `{legs, entry, exit}`, three plain-text lines (no markdown) meant to render as fixed labeled rows: `legs` names every leg in one phrase separated by `; `, `entry` when the trade opens, `exit` how it closes (`hold to the 16:00 ET cash settlement` when nothing closes it early); OMITTED on a strategy whose summary has not been filled in yet), `GET /strategies/{id}` (immutable logic + full-history results snapshot + `new_sessions_available` + `privacy` + `is_owner` + `params` (the same standardized `{legs, entry, exit}` summary as the list — `null` here, rather than omitted, until it is filled in) + a short `title` (auto-generated, owner-renamable via PATCH once generation finishes) + `title_pending` (true while that title is still being generated — the window in which a `title` PATCH is refused; it tracks the generation, not `title`, which may already hold an earlier rename); AUTH OPTIONAL — public strategies readable by anyone incl. unauthenticated visitors, a private one you don't own returns 404), `PATCH /strategies/{id}` (owner only; per-user metadata: `title` (1–120 chars, 422 `invalid_title` otherwise; refused with `409 title_generating` while the strategy's short title is still being auto-generated — poll `title_pending` on the detail read and retry once it clears; description/privacy-only patches are never blocked), `description` override and `privacy` toggle — clone to change logic), `DELETE /strategies/{id}` (removes it from your list; re-saving the same configuration restores it with its results), `GET /strategies/{id}/results/days` (full day list: gross, fees under your schedule, net), `GET /strategies/{id}/results/days.csv` (the same day list as a CSV attachment, plus the engine's fee-free `intraday_max_dd` and `spx_close`), `GET /strategies/{id}/results/days/{date}` (session drill-in RECOMPUTED ON DEMAND by re-running that day through the engine — seconds, not ms; orders (each carrying the platform `class` it was priced under — `entry`|`take_profit`|`stop_loss`|`liquidation` (the last one placed by the platform when the strategy handed the session over to be closed), omitted on results computed before that was recorded; a day lists every re-pricing step an order took) + transactions with `overlay_fee` + decision log (incl. the platform pricing rows `order_start_priced`/`order_repriced`/`reprice_failed`; both pricing rows carry `quotes_at` (leg option symbol -> the timestamp of the quote used for it) + `net_bid`/`net_ask`, the net bid/ask the price came from; the platform's ENTRY narration `entry_place_failed` (a platform attempt refused, once per kind) / `entry_abandoned` (at most one — the entry window ended with the entry unfilled and the platform withdrew it; `warn` on the rare runaway) / `entry_window_wrong_day`; and, when the strategy hands the session over to be closed, the whole walk — `liquidation_started`/`liquidation_swept`/`liquidation_planned`/`liquidation_completed`, `liquidation_partial` (warn, the close reached the session close with positions still open, which then settle there) and the once-per-kind warns `liquidation_plan_failed`/`liquidation_stalled`/`liquidation_place_failed`/`liquidation_order_not_laddered`. THIS LIST IS THE COMPUTED-SESSION VOCABULARY and deliberately differs from a live bot's — e.g. `entry_abandoned` here vs `entry_retry_abandoned` on a bot session; read each surface's own) + the cost-adjusted (fees + slippage) intraday curve (always included); the headline `net_pnl` is gross minus both `fees` and `slippage`; the same orders are rolled up per TRADE in `trades` — one row per entry order and the exits that closed what it opened, carrying `trade_id` (OPAQUE — a session's own id per trade; a one-trade session takes the opening order's id), `label` (the structure, e.g. Put Credit Spread, or the strategy's own name for the trade), `status` (working|open|closing|closed|stopped|not_filled — `working`/`closing` describe a trade in flight, so a settled day reads terminal throughout, and a resting take-profit/stop leaves a trade `open`, NOT `closing`), `entry_fill_price`, `exit_price`, `gross_pnl`, `fees` (your schedule, on that trade's transactions) and `net_pnl` (gross − fees; day-level slippage is NOT split per trade), plus `exit_reason` on a terminal trade — FREE TEXT, the platform's own set being take_profit|stop_loss|liquidated|settled and a strategy that closes its own trade supplying its own reason, so match what you know and show the rest verbatim; each order carries the matching `trade_id`/`trade_label`, and ONE ROW PER TRADE (an entry that never filled still reports one, `not_filled`); `reconciliation_warning` flags engine drift; 10 rate-limit credits), `POST /strategies/{id}/results/update` (append newly added sessions — allowed whenever new sessions exist; `202` queued; `429` at the cap). There are NO `/backtests*` endpoints and no run credits — results are served through the strategy. AI ASSISTANT: the assistant authors strategies through a per-user draft instead of a form config — `POST /strategies/assistant/drafts` (opens an EMPTY conversation — no strategy is written yet, so the 201 carries `description:""` and `snapshot:null` alongside `{draft_id, source_hash}`, nothing is backtested, and the chat transcript starts empty. The assistant writes the strategy on the FIRST MESSAGE THAT DESCRIBES A TRADE — a direction, a structure fragment, a strategy name, or a risk/time preference each suffice, and it fills the rest with sane defaults; a message with no trading content (a greeting, a capability question) gets a clarifying reply and writes NOTHING. Until a strategy has been written there is no preview to read, `risks`/`params` are absent, and saving the conversation returns `400 draft_not_written`), `GET /strategies/assistant/drafts[/{id}]` (list (paginated via `limit`≤100/`offset`) / draft+transcript — the detail also carries, as top-level siblings, the strategy's current `risks` bundle (`{risks:[{title, severity, scenario, max_loss},…], overall?}`, absent until the first edit commits) and `params` (the same standardized `{legs, entry, exit}` summary a saved strategy carries, absent — like `risks` — until the first edit commits, and also absent on an older draft not yet edited under the field), plus `needs_refresh: true` when the conversation's strategy was written for an EARLIER PLATFORM RUNTIME and cannot be backtested as it stands (its `source_hash` has no results to read) — send any message and the assistant is instructed to refresh the strategy as part of that turn, minting a new `source_hash`; re-read the draft to confirm, since the flag is recomputed on every read and a turn that fails or answers without updating the strategy leaves it set, and results after a refresh can differ from the numbers discussed earlier. Absent in the ordinary case, never `false`; the transcript persists everything — assistant replies with their `thinking`/`thinking_ms` reasoning traces, `role:"ask_user"` clarifying-question rows, the `ui_choice` answers, the progress messages the assistant wrote while it kept working (flagged `interim: true`, so one turn can add several assistant rows), and on a turn that failed or was stopped an `error` marker carrying the user-safe reason it ended (a failure message, or "You stopped this response." on a user cancel — a failure also streams a live `error` event with the same text, while a cancel carries the marker with no live `error` event) — so a client can rebuild the full conversation, including failed and stopped turns), `POST …/{id}/messages` (`{content ≤ 4 KB, ui_choice?}` → `202 {turn_id}`; one in-flight turn per draft, `409 turn_in_flight`; abuse-limited — 2 concurrent turns/account + daily turn/token/cost caps → `429` with `Retry-After`; a later message clearly unrelated to the strategy is answered with a canned redirect and no model turn, still `202`; a turn need not edit the strategy — a question is answered with text and leaves it as it was, and on a conversation with no strategy yet an intent-free message still writes nothing; reply + new preview hash stream over `assistant_draft_events`; preview is read via the same `GET /strategies/preview/{source_hash}/results`), `POST …/{id}/cancel` (`404 no_active_turn` if none), `POST …/{id}/snapshots/{message_id}/view` (opens ONE version in full — `{message_id, source_hash, description, params, risks, snapshot}`; a POST because it is not a pure read: it re-registers interest in that version's results and restarts its backtest if that had been cleaned up, so the results can come back partial/queued — poll `GET /strategies/preview/{source_hash}/results` as they fill in; `snapshot: null` when the version was written under a contract that can no longer run, the rest of the bundle still returned; costs NO credits, throttled per IP on its own budget separate from the message one; `404` when the version, or the conversation, is not yours), `POST …/{id}/fork` (clones a draft — strategy bundle + chat transcript — into a new editable draft you own; backs Clone on an assistant strategy: the original is NOT frozen and keeps publishing, so a fork is a SEPARATE thread rather than a way past a freeze, and it carries one version of the copied bundle — when the original had written one — so it is publishable before its first edit; owner-only, `404` if not yours; a fork of a conversation that never wrote a strategy is itself strategy-less, `snapshot:null` and nothing kicked), `DELETE …/{id}`. ONE CONVERSATION PRODUCES SEVERAL STRATEGIES: every turn that writes code freezes an immutable version of it keyed by the message that turn ended on, listed on the detail read as `snapshots: [{message_id, turn_id, source_hash, saved_strategy_id?, outdated?, created_at}]` (oldest first, deliberately light — no source, no description, no results: the view endpoint above returns one version's detail at a time; `outdated: true` — permanent, since frozen versions never change — marks a version written for an earlier platform runtime whose backtest can no longer be shown, so treat it as read history rather than something to view or save). `saved_strategy_id` is present when that version's code is in the caller's library and is matched BY THE CODE — identical code the caller saved from any of their conversations (or the builder, or the code editor) reads as saved, and deleting the strategy makes it absent again; the same fact rides the list and detail as `saved_count`. SAVE reuses `POST /strategies` with `{draft_id, message_id?, privacy?}` (`400 draft_not_written` — and nothing created — while the conversation has produced no strategy; otherwise publishes ONE version as an `origin:"assistant"` strategy — `message_id` names it, omitted publishes the code the conversation holds right now, and sending it WITHOUT `draft_id` is `400 invalid_body` while an unknown/foreign one is `404`; UNDER THE CONTRACT THAT VERSION WAS WRITTEN FOR — this producer copies something the user already holds rather than authoring anything new, so an older version saves as it stands and keeps its own contract_version; the assistant rewrites a draft to the current contract on its next edit). PUBLISHING DOES NOT CLOSE THE CONVERSATION — it keeps taking messages and can publish again, from a later turn or an older version; a duplicate save is idempotent and returns the strategy already held, and the conversation credited as a strategy's origin stays the FIRST one that published it. An assistant strategy's `config` is absent (omitted) on `GET /strategies` and `null` on `GET /strategies/{id}` (written as source, not a form config — no "open in Builder"; instead its OWNER's `GET /strategies/{id}` carries owner-only `draft_id` + `draft_message_id` — the conversation that published it and the message whose version it was, both absent once that conversation is deleted — which back "go to conversation" (`…/assistant/{draft_id}?msg={draft_message_id}`) and Clone (fork via `…/{id}/fork`)), and it can run as a bot like any saved strategy, provided its exact source passes the current live-trading rules — `POST /bots` (and each session start) re-checks it and returns `403`/`422 strategy_not_bot_eligible` otherwise (update it in the AI assistant, or open its code in the editor and save a fixed version).- [Portfolios](https://www.0dtespx.com/openapi.yaml): a portfolio is a named collection of 1–20 of the caller's saved strategies whose COMBINED backtest results are computed at read time — nothing aggregated is stored. Model: every member is an equal fully-funded $100k sleeve (starting capital = N × $100k), daily portfolio P&L = Σ member net P&L under the viewer's fee schedule + slippage, and results cover only the INTERSECTION window (up to the least-updated member's coverage — one stale member truncates the whole portfolio). Per-session status combination: all completed (or completed+skipped — skipped sleeves add $0) ⇒ completed and counted; all skipped ⇒ skipped; any failed or not-yet-computed member session ⇒ failed; any halted ⇒ halted; only combined-completed sessions enter metrics/curve. Privacy mirrors strategies (private default; public = unlisted link-only) and is enforced BOTH ways: portfolio→public 409s (`private_members`) while any member is private, and member strategy→private 409s (`strategy_in_public_portfolio`) while inside a public portfolio; DELETE /strategies/{id} 409s (`strategy_in_portfolio`) while the strategy is in any portfolio (payload names them — remove the membership first). Endpoints: `GET /portfolios` (list, headline stats folded on read, updated_at desc), `POST /portfolios` (`{name?, strategy_ids[1..20]}`; 422 on dupes/foreign/count; `409 portfolio_limit_reached` when already owning 100 portfolios; duplicate = re-POST the member ids, no clone endpoint; → 201 detail payload), `POST /portfolios/preview` (`{strategy_ids[1..20]}`, validated like create; folds the ad-hoc set exactly like the detail read — same window/sleeves/fee+slippage overlay — with ZERO side effects: no portfolio row, no backtest jobs, nothing persisted; → 200 with the snapshot minus identity: member_count/folded_members, window, starting_capital = folded_members × $100k (a member with no results contributes no sleeve), the same results block, and a lean members[] with title/has_results/date_to/new_sessions/exec_status; a selection where nothing folds ⇒ 200 with empty results and no window; powers the create page's live preview), `GET /portfolios/{id}` (AUTH OPTIONAL, public-strategy access pattern; snapshot mirrors the strategy results snapshot: window, starting_capital as string, combined equity_curve/benchmark/metrics/days with per-day `member_slices` breakdown + `slippage`, members[] in the GET /strategies list-item shape (incl. each member's `params` summary) + `date_to`/`has_results`/`failed_days`; members with no results are excluded from the fold; a public portfolio drops no-longer-public members for non-owners), `PATCH /portfolios/{id}` (`{name?, privacy?, strategy_ids?}` — membership replace revalidated; returns the refolded snapshot), `DELETE /portfolios/{id}` (deletes only the portfolio; member strategies untouched), `POST /portfolios/{id}/results/update` (fan-out to every member with missing sessions / retryable failed sessions / reset backtest; NEVER 429s — over-cap members park as `deferred` and auto-promote; 202 with per-member outcomes `queued|deferred|already_running|up_to_date`; every started job counts against the CALLER's 3-run cap while it drains), `GET /portfolios/{id}/results/days.csv` (date, status, summed gross/fees/slippage/net + one net column per member; member cells EMPTY, not 0.00, on sessions excluded from metrics), `GET /portfolios/{id}/results/days/{date}` (aggregated drill-in: each traded member re-run through the engine — cold path tens of seconds, warm cached; skipped/zero-order member-days skip the engine; orders/transactions/decision-log merged in time order with `strategy_id`/`strategy` tags, plus `trades` — every member's per-trade rows, tagged the same way; combined intraday_curve step-summed from $100k-seeded member curves; per-member summary incl. reconciliation_warning; costs member_count × 10 rate-limit credits; date must lie inside the window). - [Bots](https://www.0dtespx.com/openapi.yaml): A bot runs a saved strategy live, hands-free; a bot IS a `bot`-type account on the `live_sim` engine — the bot id === the account id — with a pinned strategy, a risk policy, and an automatic re-pricing policy, and its carried `cash_balance` is continuous/compounding across sessions (NOT reset per day). The Bot payload exposes the pinned strategy's declarative `entry` window ({start_time, end_time, days_of_week?}, ET, Monday=0; absent for a strategy with no declared window) — session starts are gated on it (see the 422s below), and it is ENFORCED FOR THE WHOLE SESSION: the window is frozen into the session's `policy_snapshot.entry_window` at start, and once ET reaches `end_time` the platform cancels any still-unfilled entry order, stops re-pricing entries, and refuses further opening orders (recorded as an `entry_window_closed` decision-log entry) — an entry never fills after the window it was meant for. `strategy_id` everywhere is the caller's saved-strategy LINK id (route-compatible with `/strategies/{id}`); the bot pins the immutable strategy behind it (`shared_strategy_id`), and `strategy_id` is NULLABLE once the user unlinks the strategy (the bot keeps running). AUTOMATIC RE-PRICING (`reprice` object; the per-bot TUNING is live-only — a backtest runs the same mechanism at the platform defaults): the platform prices+re-prices the bot's unfilled LIMIT orders so resting orders fill instead of languishing. Each order is placed at a SPREAD-AWARE start price (how deep inside the spread depends on the spread width S in ticks and whether the order is passive (entries/take-profits) or aggressive (stop-losses): S=1 natural far touch (buy ask/sell bid), S=2 mid, S=3 one tick inside for passive / one tick toward the market for aggressive, S=4 one tick inside for passive / mid for aggressive, S≥5 a quarter-spread inside the mid for passive / mid rounded to the near side for aggressive), then walked one UNFAVORABLE tick at a time (a retry) toward a fill. Two blocks split by intent, BOTH ON BY DEFAULT: `entry` (opening orders) is patient+BOUNDED and RE-PRICED, NOT RE-PICKED — a strategy keeps the entry structure it chose and each retry re-prices those SAME legs from the current spread, never re-selecting strikes (strategies saved BEFORE platform-managed entry windows keep the older behavior, where every retry re-selected the legs from the current market and re-priced that fresh structure; `order_repriced`'s `legs_changed` is what distinguishes them and is permanently false for a strategy whose structure is fixed), conceding at most `max_retries` unfavorable ticks (a tick count, 0..50, default 4); at that cap the order stops conceding but keeps tracking the market, and the entry is given up only when the strategy's entry window ends (see the `entry` window above) — an unfilled entry is never left resting past it; `exit` (closing orders) is relentless+UNCAPPED — keep retrying until filled; the FIRST re-price waits the class interval after placement, then it follows an adverse market immediately (paced only by min_interval_seconds) and never relaxes on a favorable wiggle (guarantees the exit), with SEPARATE take-profit and stop-loss cadences so a stop-loss can chase faster than a take-profit; disabling exit re-pricing risks positions that never close and large losses. `entry`: `enabled` (default true), `interval_seconds` (4..600; default 10), `max_retries` (0..50; default 4), `min_interval_seconds` (4..120; default 4, must be ≤ interval_seconds). `exit`: `enabled` (default true), `take_profit_interval_seconds` (4..600; default 10), `stop_loss_interval_seconds` (4..600; default 5), `min_interval_seconds` (4..120; default 4, must be ≤ both intervals). EVERY interval floors at 4 SECONDS, sized for the resting rung: a rung the market has to come to needs its price to hold 3 consecutive seconds, so a rung replaced faster than that could never confirm one (a rung priced strictly past the market confirms in 2, but the floor still has to cover the resting one); stored policies below the floor were raised to 4. When a kind's re-pricing is ON the platform sets its start price and manages the retries; turning it OFF leaves that kind of order at the strategy's own price (manual-pricing mode). Frozen per session (takes effect on the NEXT session). AUTOMATIC MALFUNCTION HALT (order guardrail): each session's `policy_snapshot` carries a platform-derived, read-only `order_guardrail` block (`window_seconds`, `max_places_per_window`, `max_replaces_per_window`); a bot that submits abnormally many orders in a short rolling window is auto-halted with `halt_reason:"order_guardrail_tripped"` — working orders cancelled, the session ends `halted` and finalizes at current marks; the caps are sized from the platform order budget and the frozen re-pricing envelope so a normal strategy (including maximally aggressive exit re-pricing) cannot reach them, and are NOT user-settable. A SECOND, count-based brake sits alongside it: a fixed platform ceiling on how many orders one session may leave RESTING at once (a cancel-leak tripwire, not a risk control), breached → `halt_reason:"max_open_orders_exceeded"` down the same halt path. Also NOT user-settable — a bot has NO order- or position-count knobs. AUTOMATIC START (`auto_start` boolean on the Bot, always present, default false): when true the platform opens today's session for the bot at each trading session's open, under the same eligibility rules as a manual start (RTH, entry window, still bot-eligible). At most ONE automatic attempt per bot per trading day; ANY persisted session today (manual or automatic, active OR terminal incl. `failed`) consumes the day and suppresses the automatic one; enabling mid-session (via PATCH, creating the bot after the open, or un-archiving a bot that has it on) takes effect at the NEXT session open (never a surprise start seconds later); a failed automatic registration leaves a visible `failed` session (not retried); deterministic skips (not an entry day, window passed, ineligible) are silent; the manual `POST /bots/{id}/sessions` contract is unchanged — a same-day re-run after a terminal session is still allowed. `POST /bots` (`{strategy_id (REQ), name (REQ), description?, engine? (defaults live_sim; broker_* → 409 engine_not_available), data_staleness_threshold_ms? (500..10000), auto_pause_on_error?, error_budget? (0..100), reprice? (entry/exit blocks; omit for defaults-on), auto_start? (default false)}`; every bot starts with a fixed $100,000 — there is no starting-capital input → 201 Bot; errors 400 invalid_input/invalid_strategy_id/invalid_reprice, 403 strategy_not_found_or_not_owned, 403 strategy_not_bot_eligible (pinned source fails the current live-trading rules), 409 engine_not_available, 409 name_taken), `GET /bots?strategy_id={link id}` (→ array of Bot; optional filter by link id, unknown link → empty array), `GET /bots/{id}` (→ `{bot, sessions[]}`; 404 not_found), `PATCH /bots/{id}` (`name?`/`description?`/`status? ("idle"|"archived")`/`auto_start?`/the risk-policy fields (`data_staleness_threshold_ms?`, `auto_pause_on_error?`, `error_budget?` — no count knobs exist)/`reprice?` — A RUNNING BOT IS FROZEN: the WHOLE patch is refused with 409 bot_has_active_session while the bot has a live session (name, description, status, auto_start and risk policy alike — stop the live session first), so every edit lands between sessions and takes effect on the NEXT one (each session keeps the `policy_snapshot` it froze at start); `auto_start` enabled after today's open (by PATCH once the day's session has ended, or by un-archiving) defers to the next session open; 404 not_found, 409 name_taken (duplicate active name)), `DELETE /bots/{id}` (→ 204; 409 bot_has_active_session — stop the live session first; 404 not_found), `GET /bots/{id}/summary` (lifetime roll-up: total_sessions, win/loss counts, win_rate_pct, total_pl, best/worst day, average_day_pl, sparkline[]), `GET /bots/{id}/analytics` (round-trip trade log + aggregate trade metrics over the bot's FINISHED, FLAT sessions — `completed` (settled at the close) AND `stopped` (the book went flat during the day: entered-and-exited, entry window closed unfilled, or stopped then flattened); `halted`/`failed` are excluded, which is why this can differ from /summary (every finalized session) — plus `day_series` (one entry per folded session for the equity curve/calendar; a date REPEATS when the bot ran twice that day); A DAY JOINS THE FOLD ONLY ONCE IT HAS CLOSED — a session is analyzed after its own `end_time` has passed, NOT the moment it finalizes, so a run that went flat mid-day appears here after that day's close (the same boundary that turns it from the bot's current day into a past one); this is the second reason the fold can differ from /summary, which has no timing rule and carries today's result immediately; same body as `GET /accounts/{id}/analytics` plus `day_series`; read-only). Sessions: `POST /bots/{id}/sessions` (start today's session, no body; opening balance is the carried `cash_balance`; → 201 `{session_id}`; errors 404 not_found, 409 bot_not_idle, 409 bot_session_exists, 422 outside_rth, 422 not_entry_day (the strategy's entry.days_of_week excludes today), 422 entry_window_passed (the strategy's entry window already passed for today, or opens after an early close — starting BEFORE the window is allowed, the bot idles until it opens), 422 strategy_not_bot_eligible (the pinned strategy's source no longer meets the current live-trading rules — re-checked at each session start; update it in the AI assistant, or open its code in the editor and save a fixed version, then create a bot from the saved strategy), 503 live_trading_unavailable, 408 (the request was canceled before that re-check finished — nothing started, nothing decided about the strategy; send it again), register_sim_failed (relayed status)), `GET /bots/{id}/sessions?limit={n}` (→ BotSession[]; ordered date DESC, then ended_at DESC NULLS FIRST, then id — deterministic when a date holds one completed + several halted/stopped/failed reruns; default 90, `?limit=` 1..1000, non-positive/non-numeric → default), `GET /bots/{id}/sessions/{session_id}` (→ BotSession; 404), `DELETE /bots/{id}/sessions/{session_id}` (STOP THE STRATEGY, NOT THE TRADE — the bot makes no further decisions and its working orders are cancelled, but the session STAYS LIVE AND KEEPS ITS POSITIONS; nothing is closed for you and nothing is cashed out at a theoretical price. `status` does NOT change (stays 'running'); the stop rides two read-only session timestamps instead — `strategy_stopped_at` (+ `strategy_stopped_by`: 'user'|'admin') is the request, `strategy_stop_applied_at` is when it took effect (orders cancelled, bot provably out of the market). Re-issuing in between is a no-op. EXCEPTION — a stop with NOTHING LEFT IN THE MARKET does not stay live: a session still 'scheduled'/'registering' (the strategy never ran) or one whose strategy had already exited everything holds nothing and finalizes as 'stopped' (`halt_reason` 'user_stop') within a tick, with nothing to liquidate and the (bot, date) slot freed for a fresh session. So a stopped session that IS still live is by definition one HOLDING POSITIONS, and it ends one of three ways: you RESTART it (`POST …/sessions/{session_id}/restart`, below) and the strategy resumes on the same session; you flatten it (via the bot-session LIQUIDATION endpoints below — that is how closing orders are placed on a bot session, whose own /orders route is read-only) and it finalizes as 'stopped' the moment it is flat, balance carried; or you hold to the 16:00 ET close and it settles as 'completed' exactly as it would have. The bot stays frozen for PATCH/DELETE (409 bot_has_active_session) until the SESSION is over — flat or settled — not merely stopped. A malfunction HALT is unchanged and still terminal at current marks. → 202; 404), `POST /bots/{id}/sessions/{session_id}/restart` (UNDO A STOP on a session that is still holding positions: the strategy resumes on the SAME session, keeping its positions and the ctx.state it had built up, and all three stop fields (`strategy_stopped_at`/`_by`, `strategy_stop_applied_at`) are cleared so the session reads like one never stopped; logged as a `strategy_restarted` decision entry (source "api") + a `session_resumed` live_bot_event. NEVER grants a second entry — the one-entry budget is reconstructed from the orders already filled. → 202; 409 bot_not_stopped (no ACKNOWLEDGED stop to undo: never stopped, the ack has not landed yet, or a previous restart already took effect), 409 bot_liquidated (`liquidated_at` is stamped — PERMANENT, including after the liquidation was cancelled), 409 bot_session_not_restartable (the session has finished — its terminal record is untouched — or the stop was placed by the platform operator (`strategy_stopped_by` 'admin'), which the user cannot undo), 404. Restart is only ever about resuming the live session — to trade the bot again after a session finished, START A NEW SESSION), `POST /bots/{id}/sessions/{session_id}/liquidations` `{method: smart|aggressive}` + `GET|DELETE /bots/{id}/sessions/{session_id}/liquidations/current` (close every open position on a STOPPED bot session — identical contract, snapshot and methods to the accounts liquidation below, priced under the fee schedule that session was opened with; ADMITTED ONLY once `strategy_stop_applied_at` is present AND the session is still live, else `409 bot_not_stopped`; otherwise the same admission as accounts: 409 no_open_positions, 409 liquidation_in_progress, 409 liquidation_strategy_owned (the STRATEGY owns this session's liquidation — refused permanently, on POST and DELETE alike), 400 on a bad method. THE POST PERMANENTLY BLOCKS RESTART: it stamps a read-only `liquidated_at` on the session before placing anything, never cleared, so `POST …/restart` returns `409 bot_liquidated` from then on — even after a `DELETE …/liquidations/current`. Cancelling the liquidation does NOT end the session and does NOT restart the strategy: the session stays live and holding, and the remaining exits are another liquidation or the 16:00 ET close settling it 'completed'. Once the closing fills land and the book is flat the session finalizes itself 'stopped', balance carried. `liquidated_at` records the DECISION to close the book, not that it is closed — read the liquidation snapshot for progress and the session `status` for whether the day is over), `GET /bots/{id}/sessions/{session_id}/orders | /positions | /transactions | /trades | /financials | /history | /decision-log` (live data scoped to the session — same shapes the live-trading endpoints return; `/trades` is the session rolled up per TRADE — one row per entry order and the exit orders that closed what it opened, its money FOLDED from the session's own orders + transactions, carrying `trade_id` (OPAQUE — a one-trade session takes the opening order's id), `label` (the structure, e.g. Put Credit Spread, or the strategy's own name for the trade), `status` (working|open|closing|closed|stopped|not_filled — a resting take-profit/stop leaves a trade `open`, NOT `closing`), `entry_fill_price`, `exit_price`, `gross_pnl`, `fees` (charged, not overlaid), `net_pnl` (gross − fees; day-level slippage is NOT split per trade), optional `exit_reason` — FREE TEXT, the platform's own set being take_profit|stop_loss|liquidated|settled, a strategy closing its own trade supplying its own reason, absent when an order closed the trade without a recorded rule — plus `order_ids` and `instruments` to group the /orders and /positions reads by. ONE ROW PER TRADE, so a session that opened several reports several, overlapping in time (an entry that never filled still reports one, `not_filled`); `?at=` reconstructs each trade's state from the fills that had happened by that second and NEVER shows a later state — TRAP: a trade that placed no order at all appears only from the moment it resolves, and `closing` shows only for a close still in flight, not one that started and finished between two cursors; `/financials` returns the session object hydrated with its financials, same shape as `GET /bots/{id}/sessions/{session_id}` EXCEPT its `status` is the FULL BOT lifecycle value (any of scheduled/registering/waiting_for_data/running/settling/settled/completed/halted/stopped/failed/abandoned — polled in every state, not manual open/settled/abandoned), `starting_capital` mirrors opening_balance, and its `settled_at` is the FINALIZATION timestamp present for ANY finalized session (completed/halted/stopped/failed/abandoned), NOT only a natural settlement (diverges from the manual TradingDay.settled_at meaning); `/history` is the per-second P&L-curve series, same shape + `?interval=` as `GET /accounts/{id}/history`; decision-log is the session's decision log in ASCENDING emission order (oldest first), the NEWEST 500 on a plain read and the COMPLETE log with `?full=true`: the strategy's ctx.log() entries (source "strategy") plus platform lifecycle entries (source "runner") and entries recorded against a user request (source "api" — `strategy_restarted`) — session_started (payload started_by: "user"|"auto"), session_ended (payload status + halt_reason when present), order re-pricing activity (order_repriced, whose payload carries `legs_changed` when that step also re-selected the entry's legs, and reprice_failed), the pricing rows order_start_priced (`class`/`start`/`tier`) and order_repriced, which BOTH also carry `quotes_at` (leg option symbol -> the timestamp of the quote used for that leg, RFC 3339 UTC) + `net_bid`/`net_ask` (the order's net bid and ask at that moment, decimal strings), entry_window_closed (the entry window ended with an order still unfilled; payload carries the window and the cancelled order ids), and the order lifecycle: order_filled (payload order_id/class/fill_price/price_effect), order_canceled, order_rejected (payload includes rejection_reason; ALSO emitted for a placement the exchange REFUSED OUTRIGHT — not enough buying power, an invalid structure, a temporary refusal — which additionally carries `order_created:false` (the exchange never ACCEPTED it — the order does exist under /orders reading `rejected`, with no accepted_seq, because it never reached the book) and an optional `message` with the rejection detail), and the ENTRY-RECOVERY pair: when the refused placement was the session's ENTRY and the refusal was TEMPORARY, the platform rebuilds the entry from the CURRENT market and re-submits it, on a paced cadence and only while the entry window is still open — one entry_retried entry per attempt (payload order_id/attempt/max_attempts/reason; the order_id is a NEW one naming that re-submission — the refused submission is a `rejected` order that never reached the book, and the re-submission may itself be refused, so it becomes a WORKING order only once one is accepted), while a refusal that would fail identically however often it were retried (buying power, an invalid structure) is not retried at all; either way, an entry that cannot be recovered ends the day with AT MOST ONE entry_retry_abandoned entry (payload attempts/reason) rather than in silence — an entry that was never established BY THE END OF THE ENTRY WINDOW ends the same way, that entry then meaning THE WINDOW ENDED and the platform WITHDRAWS the still-unfilled entry — a strategy saved BEFORE platform-managed entry windows says it the older way instead, reporting the window's end as entry_window_closed alone and keeping entry_retry_abandoned for a refusal it could not recover from; `reason` on entry_retried also says which loop is running (`platform_entry_window` = the platform working a strategy's chosen entry across its whole window, whose `max_attempts` is that window's allowance rather than the smaller post-refusal recovery budget) — plus the platform's own entry notes entry_place_failed (a PLATFORM attempt that was refused — by the exchange, or by a platform check such as a trade budget or the order guardrail; reported once per kind of refusal, not per attempt), entry_rebuild_yielded_none (payload attempts — the entry is armed and inside its window but the strategy has offered nothing to place on every attempt so far, so nothing has been placed), entry_rebuild_raised (payload message — the same phase, but the trade's builder raised when the platform tried to rebuild the entry; reported once per session) and entry_window_wrong_day (the strategy asked to open a trade on a weekday its own entry window excludes; nothing was attempted — rare live, since a session on such a weekday is refused at the start, and mostly met in a strategy's computed sessions), and the liquidation handoff strategy_liquidation_started (payload reason — the strategy handed the session to the platform to close) with the rare strategy_liquidation_unclaimed and liquidation_start_failed (the platform could not take ownership, or could not read the positions to plan the close, and stood down without touching the book — the positions then ride to the close), and guardrail_tripped (an order-rate guardrail trip with its counts; the decision-log companion to the session's `halt_reason` 'order_guardrail_tripped')). REPLAY a terminal session: `/positions` and `/transactions` take `?at=` (reconstruct the day at an arbitrary moment; `at` past the terminal clock is clamped; malformed `at` → 400). The DEFAULT READ (no `at`) differs per endpoint: `/positions` reads at the terminal clock — a settled natural close (`completed`, or the transient `settled` the exchange stamps before the runner finalizes it) reads at its settled close WITH end-of-day settlement applied, a halted/stopped/failed/abandoned day reads at its final stopped clock with NO synthetic settlement — while `/transactions` has NO cutoff at all and returns the session's whole ledger (a completed day's settlement rows included). `/decision-log` takes the same `?at=` as a shifted window (newest ≤500 with datetime <= at; malformed → 400), and `?full=true` — alone or with `?at=` — lifts that window and returns the COMPLETE log. The session lifecycle is `scheduled → registering → waiting_for_data → running → settling → completed` (or `halted`/`stopped`/`failed`) — a strategy stop is NOT a lifecycle step (see DELETE above: the row stays `running` while it holds). A SESSION ENDS THE MOMENT IT HAS NOTHING LEFT IN THE MARKET and can open nothing further: `stopped` with `halt_reason` 'flat_book' (it entered and fully exited, or its entry window closed with no fill), 'user_stop' (a stop was what ended it) or 'strategy_liquidated' (the STRATEGY handed the session to the platform to close and the platform closed it — see `liquidated_by` on the liquidation endpoints; all three are ordinary CLEAN ends, not faults) — no idling to the bell, so a bot's day is routinely over well before 16:00 ET, and the freed (bot, date) slot allows a NEW session the same date; only a session still holding something (or still working an entry inside its window) rides to the close and ends `completed`. BUT FINISHING EARLY ENDS THE SESSION, NOT THE DAY: BotSession carries a REQUIRED `end_time` — that session's own trading-day close (16:00 ET, earlier on a half day), frozen at session start — and a session finalized before it is still the bot's CURRENT day; the day becomes a past one to review only once `end_time` has passed, which is the same boundary `GET /bots/{id}/analytics` folds on. BotSession also carries the read-only `liquidated_at` and `liquidated_by` (`user`|`strategy` — who owns the liquidation; a `strategy`-owned one is not yours to start or cancel; see the liquidation endpoints). The bot's `runtime_status` is `idle`/`scheduled`/`running`/`halted`/`stopped`/`completed`/`failed`/`archived`; all session P&L/rollup fields are absent (omitted) until the session finalizes. ## API — health - [Health probe](https://www.0dtespx.com/openapi.yaml): `GET /health` — liveness check, no auth.