API reference

Strategies API

Preview, save, and read strategies and their backtest results.

A saved strategy is an immutable trading program authored with the Strategy Builder, with the AI assistant, or as code you write yourself, that owns its backtest results — there is no separate backtest object and there are no /backtests* endpoints. See Strategy results for the concepts. Most endpoints require authentication; reading a public strategy (and its results) does not — but reading its code always does.

The model

  • Three producers, one strategy. POST /strategies accepts a builder config, an assistant draft_id, or a source you wrote yourself. The saved strategy's originbuilder, assistant or manual — records how its code was produced, and nothing else about your copy of it. Whichever producer you use, what is stored is the same immutable program with the same results pipeline behind it.
  • Keyed by source hash. A form config compiles to a deterministic program, and every distinct configuration is identified by its source_hash — the key the preview and WebSocket endpoints use. Strategies are immutable — to change one, save a new configuration (a different hash).
  • Short title, generated automatically. Each saved strategy carries a short title for display. It is generated automatically a moment after you save, so it may come back as an empty string ("") right after the save returns — poll GET /strategies/{id} and it fills in shortly. You can rename it with PATCH /strategies/{id} once generation has finished: while it is still running the detail response reports title_pending: true and a title patch returns 409 title_generating.
  • Private by default, public means "anyone with the link". Each saved strategy carries a privacy flag — private (the default) or public. Public is unlisted, not discoverable: there is no directory of public strategies, nothing crawls or lists them, and the id is a random UUID that can't be guessed — so a public strategy is reachable only by someone you hand the link to. Once they have it, any user (or an unauthenticated visitor) can view it, and any signed-in user can read its code and fork it; a private strategy is visible only to you. Flip it any time with PATCH /strategies/{id}.
  • Results are computed the same way for every strategy: every trading session, run independently at a fixed $100,000 per-session capital, fee-free. Fees are applied at read time from the viewer's fee schedule (the default schedule for unauthenticated visitors), so results always render with your costs.
  • Full history. Results cover every trading session, and newly added sessions can be appended at any time. Every results response labels the window (window_label — always all trading sessions — plus window_from, window_to).
  • No run credits. The backtester is protected by a per-user cap of 3 concurrently-executing runs (429 too_many_active_backtests — retry with backoff) and a priority queue. Only the session drill-in, AI-assistant turns, and the code endpoints (validating, saving from source, reading source) draw rate-limit credits.

Endpoints

Method Path Purpose
POST /strategies/preview Preview a config (a persisted backtest)
GET /strategies/preview/{source_hash}/results Read preview results (pre-save, heartbeat)
POST /strategies/preview/abandon Abandon a preview
POST /strategies/validate Check code you wrote, saving nothing
GET /strategies List your saved strategies (the leaderboard)
POST /strategies Save (publish) a strategy
GET /strategies/{id} Get the logic + results snapshot
GET /strategies/{id}/source Read a strategy's code
PATCH /strategies/{id} Update per-user metadata
DELETE /strategies/{id} Remove the strategy from your list
GET /strategies/{id}/results/days Per-session day list
GET /strategies/{id}/results/days.csv The day list as a CSV attachment
GET /strategies/{id}/results/days/{date} Session drill-in (recomputed on demand)
POST /strategies/{id}/results/update Append newly added sessions
POST /strategies/assistant/drafts Create an AI-assistant draft
GET /strategies/assistant/drafts List your drafts
GET /strategies/assistant/drafts/{id} Get a draft + chat transcript
POST /strategies/assistant/drafts/{id}/messages Send a message (run one turn)
POST /strategies/assistant/drafts/{id}/cancel Cancel the in-flight turn
POST /strategies/assistant/drafts/{id}/snapshots/{message_id}/view Open one strategy version of a draft
POST /strategies/assistant/drafts/{id}/fork Clone a draft into a new editable one
DELETE /strategies/assistant/drafts/{id} Discard a draft

Preview a configuration

POST /strategies/preview is the per-change preview action of the builder and the code editor. There is no ephemeral dry-run: every distinct configuration is a real, persisted backtest. The call validates the config, ensures the backtest for its hash exists, starts (or resumes) the coverage run when sessions are missing, registers your interest, and returns the current fee-overlaid snapshot — instant for a configuration that already has results (one you previewed or saved before).

It takes ONE producer: config (below) or source — the same 24 KB of Starlark POST /strategies/validate checks, sent as {"source": "…"} (when both are sent, source wins, the same precedence as the save). The source producer answers {snapshot} alone — hand-written code has no generated artifacts — and rejects invalid code as 400 invalid_source with one human-readable message. Unlike validate it costs no credits and runs no real-session check, and unlike the config producer it never returns 429 at the 3-active-runs cap: the run parks and starts automatically when a slot frees, exactly as a save does.

curl -s https://api.0dtespx.com/strategies/preview \
  -X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "config": {
      "legs": [
        {"direction":"sell","type":"put","qty":1,"strike":{"method":"delta","value":0.3}},
        {"direction":"buy","type":"put","qty":1,"strike":{"method":"delta","value":0.2}}
      ],
      "entry": {"time":"10:00"},
      "exit": {"profit_target_pct":50,"time":"15:55"}
    }
  }'

POST /strategies/preview

Validates this config and returns its current results snapshot, starting a coverage run when sessions are still missing. Not a dry run: every distinct config IS a persisted backtest, and a configuration you haven't previewed before counts against your 3-run cap. Re-run this example as authored rather than tweaking a parameter on each send.

Interactive — run this request from the docs

entry.time is a single entry time, and a builder strategy opens one trade per session. For several entries in a day, describe them to the AI assistant or write them in the code editor — a strategy either one produces can hold several trades in one session — or save one strategy per entry time and combine them in a portfolio when you want each one funded and attributed separately. entry.days_of_week optionally restricts to specific weekdays.

Every saved strategy also exposes a derived, declarative entry windowentry: {start_time, end_time, days_of_week?} (ET; days_of_week uses Monday=0 … Sunday=6, absent = every trading day) — on GET /strategies/{id}. For a builder strategy it is the form's entry time plus one minute (10:00{"start_time":"10:00","end_time":"10:01"}); an AI-assistant strategy can declare either a specific time or a genuine range ("try to enter between 09:35 and 10:00"). There is one window per strategy however many trades it opens: it bounds every entry the session makes, not one of them. Bots use it twice: to refuse session starts after the window has passed, and as the live give-up — at end_time a bot cancels any entry order that has not filled and places no further opening orders for that session.

Several trades in one session

A strategy the assistant writes — or one you write yourself in the code editor — can open more than one trade in a session — a second entry later in the day, a re-entry after the first one stops out, a hedge opened on a signal. Each trade is tracked on its own: its own entry fill, its own exits, its own profit and loss, and its own row in the trades read. They may overlap, and one may hold the other side of a contract another holds — in which case closing the first of them can WAIT: with nothing left to close at the account level the platform holds that close until the book changes, and the trade reads closing in the meantime. The ceilings are 50 trades per session and 12 that are not yet finished at any one time; past either, the strategy's request to open another is refused and the session's decision log records it.

Builder strategies open one trade — the same contract, so their code reads like any other. Nothing a client reads changes either way: a session that holds one trade returns one trade row.

The response carries the generated artifacts — the description, summary, risks, and params (the standardized {legs, entry, exit} summary described under List and read) — and a snapshot with source_hash, exec_status, coverage counters (covered_days, total_sessions), the labeled window, and the folded metrics so far. When you change a parameter, send the new config with abandon_hash set to the previous source_hash — the superseded run stops at the next session boundary (completed sessions are kept; returning to that configuration resumes where it left off).

Errors: a malformed config returns 400 invalid_config with a fields map keyed by dotted config path — nothing is created. The same 400 also refuses two exit settings that are well-formed but cannot do what they say, whenever the structure is provably one-sided: exit.stop_loss_pct above 100 on a structure that provably pays a debit (the loss cannot exceed the premium paid, so the stop could never trigger — use 100 or less), and exit.profit_target_pct of 100 or more on a structure that provably collects a credit (buying it back for nothing is not achievable, and every value from 100 up produces the same $0.05 buy-back — use a value below 100). A structure that cannot be classified from the config alone keeps any value above 0 up to 1000 on both fields. The time exits are held to the same standard by plain arithmetic, for every structure: exit.time must be later than entry.time (the trade opens during the entry minute, so an exit at or before it would close the trade the moment it opens — or never fire at all), and exit.minutes_before_close / exit.minutes_in_trade must fit the session that remains at the entry — a 10:00 entry leaves 360 minutes to the close, so closing 359 or more minutes before the close would close the trade the moment it opens, and a hold of 360 minutes or more could never complete. 429 too_many_active_backtests means you already have 3 runs executing; retry with backoff (a steady builder session never trips this, since each change abandons the previous run first). 408 is not about the config at all: the request was canceled before its check finished, so nothing was created and nothing was judged — send it again.

Read preview results

GET /strategies/preview/{source_hash}/results is the pre-save read path: the full day list and folded summary, net of your fee schedule. Calling it also refreshes your interest heartbeat — an unsaved preview keeps running only while you're watching (interest expires 15 minutes after the last fetch; saved strategies always run to completion).

curl -s https://api.0dtespx.com/strategies/preview/$HASH/results -H "Authorization: $TOKEN"

GET /strategies/preview/{{source_hash}}/results

Reads the hash captured by the preview above, and refreshes your interest heartbeat with every send. Send it again while a run is in flight to watch covered_days climb.

Interactive — run this request from the docs

Poll until exec_status is idle and covered_days == total_sessions — or subscribe to the backtest_events WebSocket channel with the source_hash and read each frame's data.snapshot (the full fee-overlaid results snapshot; the HTTP endpoint backs the initial load + reconnect). Access requires live interest in the hash (from a recent preview) or a saved strategy of yours with this source — otherwise 404.

When you leave the builder without saving, POST /strategies/preview/abandon with {"source_hash": "…"} drops your interest explicitly (best-effort, always 204 — the 15-minute TTL backstops it).

Save (publish)

POST /strategies publishes the immutable strategy for the hash and keeps the backtest already running or done for it — a config you just previewed publishes with its results intact, no recompute.

curl -s https://api.0dtespx.com/strategies \
  -X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"config": { … }, "privacy": "public"}'

POST /strategies

Publishes the config above into your library (private by default) and keeps the backtest it already has. Saving the same configuration twice is idempotent — the same id comes back.

Interactive — run this request from the docs

Returns 201 with your saved-strategy id, the source_hash, the saved privacy, and the current results snapshot. Saving never trips the run cap — at the cap the coverage run is parked and starts automatically when one of your runs finishes. Re-saving a configuration you previously removed resurrects the same id with its results.

You can hold at most 500 saved strategies. Saving a new one while at the cap returns 409 strategy_limit_reached and creates nothing — delete one first. Re-saving a strategy you already have doesn't count against the cap.

A 408 says the request itself was canceled before its check finished — nothing was created, and nothing was decided about what you sent. Send it again.

privacy is optional and defaults to private. Pass "public" to publish on save, or leave it off and publish later from the strategy page (or via PATCH).

Save from code

Instead of a config, send source — the strategy written as code. It is put through the same checks as POST /strategies/validate and, if it passes, published as an origin: "manual" strategy.

curl -s https://api.0dtespx.com/strategies \
  -X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"source": "PLATFORM_ENTRY_WINDOW = {\"start\": \"09:45\", \"end\": \"09:50\"}\n\ndef build_spread(ctx):\n    ...\n\ndef on_tick(ctx):\n    ctx.open_trade(\"spread\", build_spread)\n", "privacy": "private"}'
{
	"id": "6f0a…",
	"source_hash": "9c1b…",
	"privacy": "private",
	"snapshot": { "exec_status": "running", "covered_days": 0, "…": "…" }
}

Four things are worth knowing before you automate it:

  • There is no entry field to send. The strategy's entry window is derived from the PLATFORM_ENTRY_WINDOW declaration in your own code, so the two can never disagree.
  • It costs credits, and it can 429. Saving from source runs your code against a trading session, and that check is metered exactly like POST /strategies/validate. The config and draft_id producers cost nothing. (The backtest concurrency cap still never blocks a save — at the cap the coverage run is parked and starts later.)
  • The 500-strategy cap is checked first. A save that would exceed it returns 409 strategy_limit_reached before anything is charged or run.
  • A conversation with no strategy can't be saved. A draft_id whose assistant has not written a strategy yet returns 400 draft_not_written and creates nothing — send a message describing the trade you want, and save once the assistant has written it.

The 400 codes, by producer: invalid_config (a malformed builder config, with a per-field fields map), invalid_source (code that fails validation, with one human-readable message), draft_not_written (a conversation that has not produced a strategy), and invalid_body (a body that is not valid JSON, or a privacy outside private/public — refused before any producer is chosen, so nothing is checked and nothing is charged). Nothing is created in any of them.

Send at most one producer. If you send more, they resolve by precedence — draft_id, then source, then config — and the others are ignored.

Validate code

POST /strategies/validate runs the same gate the source producer runs and creates nothing: no strategy, no link, no backtest, no queued run. A 200 here is a promise that the save will not reject the same bytes.

curl -s https://api.0dtespx.com/strategies/validate \
  -X POST -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"source": "def build_spread(ctx):\n    return None\n"}'

That one is missing its entry-window declaration, so it comes back 400:

{
	"error": "invalid_source",
	"message": "The code failed validation: every strategy must declare its entry window at module level so the platform can own the entry: PLATFORM_ENTRY_WINDOW = {\"start\": \"09:31\", \"end\": \"09:45\", \"days\": [0, 1, 2, 3, 4]} — times are ET, \"end\" is EXCLUSIVE (for a single entry minute use start + 1 minute), and \"days\" is optional (Monday=0; omit it for every trading day). The platform places the entry inside that window and withdraws it at the end, so the strategy does not check the clock itself"
}

A source that passes answers:

{ "ok": true, "source_hash": "9c1b…", "warning": "This strategy never entered a trade on the checked session." }

source_hash is the content address those exact bytes will publish under — the same value a save returns. warning is advisory only: it reports what your code did on a session that was checked, never a reason the save would fail. Absent when there is nothing to report.

Checked, first failure only: the 24 KB size cap; that the code compiles and initializes; that it declares PLATFORM_ENTRY_WINDOW; that every trade it opens carries a constant, unique label; the authoring rules (no naked short legs, no unavailable ctx members, resting limit exits only, and every leg action placeable where it is used); and finally a run against one or more recent real trading sessions — a genuine runtime error there is a 400 too, because a strategy that crashes on real data is not saveable.

Costs 10 credits per call, including a repeat of code that was just checked. Two other outcomes are worth branching on: 503 smoke_busy means too many checks are in flight (retry shortly; the call is still charged, so back off rather than spinning), and 503 smoke_unavailable means the check itself could not run — that one is refunded.

AI assistant drafts

The AI assistant authors strategies through a per-user draft instead of a form config. Create one with POST /strategies/assistant/drafts — it opens an empty conversation and returns the draft id + its source_hash, with an empty description and snapshot: null, because no strategy has been written yet. Then converse with POST /strategies/assistant/drafts/{id}/messages (returns 202 {turn_id}; the reply, the updated preview hash, and the refreshed description + risk scenarios + params stream over the assistant_draft_events WebSocket channel). The live preview is read through the same GET /strategies/preview/{source_hash}/results endpoint as the Builder, keyed by the draft's current source_hash.

The assistant writes the strategy on the first message that describes a trade — a direction ("something bullish for today"), a structure ("buy a call"), a strategy name ("iron condor"), or a risk/time preference ("something safe that's done by noon") is enough, and it fills the rest in with sane defaults. A message with no trading content — a greeting, a question about what it can do — is answered with a clarifying question and writes nothing. Until a strategy has been written there is no preview to read, and saving the draft returns 400 draft_not_written.

GET /strategies/assistant/drafts/{id} returns the draft and its chat transcript, plus risks and params — the draft strategy's standardized {legs, entry, exit} summary, the same shape a saved strategy carries (see List and read), so a client can show the same three rows before and after saving. Both are absent until the assistant's first strategy edit commits — their presence is how a client knows a strategy exists — and params is also absent on an older draft that hasn't been edited since the field was introduced; the next edit supplies it.

The same read carries needs_refresh: true when this conversation's strategy was written for an earlier platform runtime: it cannot be backtested as it stands, so there is nothing for the results endpoint to serve under its source_hash. Send a message — any message — and the assistant is instructed to refresh the strategy as part of that turn, which writes a new source_hash and starts its backtest. Re-read the draft afterwards to confirm: the field is recomputed on every read, so a turn that fails, or that answers without updating the strategy, leaves it set. The refreshed strategy's results can differ from the numbers the conversation discussed earlier. Like risks and params, it is simply absent in the ordinary case — never sent as false. The versions frozen before such a refresh stay in the conversation's snapshots list marked outdated: true (permanently — frozen versions never change): their backtests can no longer be shown and saving one would publish pre-update code, so treat them as read history.

The transcript holds more than one row per turn. A turn that keeps working after it has written something persists that text too, as a progress message flagged interim: true, so one turn can add several assistant rows — the last one, without the flag, is the closing reply when the turn completes. A turn that was cut off mid-answer gets one automatic retry and usually still ends in a normal closing reply, with whatever it had already written left standing as an interim row ahead of it; only a turn that fails again — or one you canceled — ends with that partial next to a content-less row carrying the error marker.

One conversation, several strategies. A conversation is not a single strategy. Every turn that writes code freezes an immutable version of it, keyed by the message that turn ended on, and GET /strategies/assistant/drafts/{id} lists them in snapshots: [{message_id, turn_id, source_hash, saved_strategy_id?, created_at}], oldest first. The list is deliberately light — no source, no description, no results — so a long conversation's transcript read stays small.

saved_strategy_id is present when that version's code is in your library, and it is matched by the code itself: identical code you saved from any of your conversations — or from the Builder, or from the code editor — reads as saved here, and removing the strategy makes the field absent again. The drafts list and detail carry the same fact as a count, saved_count — how many of a conversation's versions are in your library right now.

POST /strategies/assistant/drafts/{id}/snapshots/{message_id}/view returns one version in full: {message_id, source_hash, description, params, risks, snapshot}, where snapshot is its results. It is a POST because it is not a pure read — it re-registers your interest in that version's results and restarts its backtest if it had been cleaned up, which the results endpoints never do. It costs no credits and is throttled per IP on its own budget, separate from the one your messages spend from. The results may come back partial or queued when a backtest had to be restarted; poll GET /strategies/preview/{source_hash}/results as it fills in. A version written under a strategy contract that can no longer run answers 200 with snapshot: null — its description, parameters and risks are still returned.

To save a version, call POST /strategies with { "draft_id": "<uuid>", "message_id": 4213, "privacy": "private" } instead of {config} — it publishes that version as an origin: "assistant" strategy and returns the same {id, source_hash, privacy, snapshot} envelope. Omit message_id and the code the conversation is holding right now is published instead; sending it without draft_id is 400 invalid_body, and a message_id that isn't a version of a conversation you own is 404. This producer publishes the source under the contract it was written for: it copies something you already hold 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 it can publish again — a later turn's version, or an older one you skipped past. Saving code that is already in your library is idempotent and hands back the strategy you already have; the conversation recorded as that strategy's origin stays the first one that published it.

To clone an assistant strategy, POST /strategies/assistant/drafts/{id}/fork: it duplicates that conversation (its strategy bundle and chat transcript) into a new editable draft you own, so you can take the idea somewhere else while the original keeps its own thread. The fork carries one version of the bundle it copied, so it is publishable straight away rather than only after its first edit. It returns the same envelope as create; it's owner-only (a draft you don't own returns 404).

The three calls that start a turn — draft create, …/{id}/messages, and …/{id}/fork — return 503 {"error":"assistant_unavailable"} while the assistant is temporarily offline platform-wide; nothing is charged, reading, discarding, and canceling existing drafts keep working, and the calls succeed again once it is back. Retry later.

List and read

curl -s https://api.0dtespx.com/strategies -H "Authorization: $TOKEN"
curl -s https://api.0dtespx.com/strategies/$STRAT_ID -H "Authorization: $TOKEN"

GET /strategies

Your leaderboard, most recently updated first — the first row's id lands in {{strategy_id}} for the reads below.

Interactive — run this request from the docs

GET /strategies/{{strategy_id}}

The immutable logic plus the results snapshot, net of your fee schedule. Right after a save, `title` may still be an empty string and `title_pending` true (a rename is refused until it clears) — send again in a moment.

Interactive — run this request from the docs

GET /strategies is the leaderboard: every saved strategy with its short title (rename it via PATCH once its generation has finished), full-history headline metrics (return, Sharpe, max drawdown, win rate — net of your fee schedule and slippage), coverage counters, and staleness (new_sessions_available), ordered by updated_at descending. The generated source is never included.

Both the list and the detail carry params — the strategy's standardized summary, the same three plain-text fields whatever authored it:

{
	"legs": "sell 1 put at 30 Δ; buy 1 put at 20 Δ",
	"entry": "enter at 09:45 ET on Mon/Wed/Fri",
	"exit": "take profit at 50% of premium, stop at a loss of 200% of premium, close by 15:50 ET"
}

legs names every leg in one phrase (separated by ; ), entry says when the strategy opens its trade, and exit says how the position closes — hold to the 16:00 ET cash settlement when nothing closes it early. Each is one line of plain text with no markdown, so a client can render the three as fixed labeled rows without parsing the description. Treat it as optional: on the list it is omitted, and on the detail it is null, for an older strategy whose summary hasn't been filled in yet — render the rest and re-read later.

GET /strategies/{id} returns the immutable logic (config, generated description, summary, risks, params) plus the short title (rename it via PATCH once its generation has finished) and the results snapshot over all trading sessions, net of the viewer's fee schedule, with the labeled window. Both endpoints always include title; it is an empty string until a title is generated shortly after a save — or you set one yourself (poll the detail endpoint if you need it). The detail response also carries title_pending: true while a title is still being generated for the strategy, which is when a title patch is refused with 409 title_generating. It clears when generation concludes (or shortly after, if generation keeps failing) — note it is about the generation, not about title itself, which can already be non-empty from a rename you made earlier. origin is "builder", "assistant" or "manual"; for an assistant-authored or hand-written strategy config is null (it was written as code, not a form config, so it can't be opened in the Builder) — read its code instead. The payload also carries privacy and is_owner (whether the caller owns this link) so a client can show owner-only controls. For your own assistant strategy it additionally carries draft_id — the originating conversation, used to route the Go to conversation link back to it (owner-only; never returned to others). Auth is optional: a public strategy is readable by anyone holding the link (including unauthenticated visitors) — but it's unlisted, so they have to be given the link; a private strategy you don't own returns 404 — its existence is never revealed. Viewing as an authenticated user keeps an in-flight run's interest fresh.

Read the code

GET /strategies/{id}/source returns the strategy's program, exactly as saved.

curl -s https://api.0dtespx.com/strategies/$STRAT_ID/source -H "Authorization: $TOKEN"

GET /strategies/{{strategy_id}}/source

The code behind the strategy captured above, whichever producer wrote it. Authenticated only — unlike the other strategy reads, this one is never public. Costs 2 credits.

Interactive — run this request from the docs

{ "source": "PLATFORM_ENTRY_WINDOW = {\"start\": \"09:45\", \"end\": \"09:50\"}\n…", "contract_version": "v7" }

This is the one strategy read that is authenticated only: a signed-in user can read the code of their own strategies and of any strategy whose link is public, and an anonymous visitor never can. Anything else answers the same 404 a missing strategy does, so it is not an existence oracle.

contract_version is the platform contract the code was written for. An older strategy may carry an older one, and such code will not pass POST /strategies/validate unchanged — a save always writes the current contract, so forking older code means repairing it first. The messages tell you what to change.

Per-session results

curl -s https://api.0dtespx.com/strategies/$STRAT_ID/results/days -H "Authorization: $TOKEN"
curl -sOJ https://api.0dtespx.com/strategies/$STRAT_ID/results/days.csv -H "Authorization: $TOKEN"

GET /strategies/{{strategy_id}}/results/days

One row per trading session, plus the window bounds. Long response — the viewer caps what it draws and the Copy button still gives you all of it.

Interactive — run this request from the docs

/results/days returns the full day list — one row per trading session: date, status (completed | skipped | failed | halted), gross_pnl, fees (your schedule), net_pnl (gross minus fees and your slippage setting), and order counts — plus the window bounds. The .csv variant renders the same list as an attachment with two extra engine columns: intraday_max_dd (fee-free intraday max drawdown) and spx_close, plus error_message and halt_reason.

Drill into one session

curl -s https://api.0dtespx.com/strategies/$STRAT_ID/results/days/2025-01-15 -H "Authorization: $TOKEN"

The heavy per-session detail — orders, transactions (each with an overlay_fee from your schedule), decision log, and the intraday equity curve — is not stored. It is recomputed by re-running that single session through the engine. Expect a response time of seconds, not milliseconds; the result is cached server-side for a few minutes and costs 10 rate-limit credits.

Each order carries the class the platform priced it under — entry, take_profit, stop_loss, or liquidation for an order the platform placed itself when the strategy handed the session over to be closed (absent on a session computed before that was recorded). A strategy's orders are priced and worked toward a fill exactly as a bot's are, so a session lists every step an order took toward its fill: the step that was replaced and the replacement that followed it, each carrying the same class. The decision log carries the matching rows — order_start_priced (the price an order went in at, and how wide the spread was), order_repriced (one step: both order ids, both prices, and whether the step also re-selected the legs) and, at warn, reprice_failed (a step the platform declined, leaving the order resting where it was). Both pricing rows also report the market they were computed from: quotes_at maps each leg's option symbol to the timestamp of the quote used for that leg, and net_bid / net_ask are the order's net bid and ask at that moment — the same three keys a bot writes, so a strategy's prices and its bot's line up key for key.

Those orders are also rolled up one level, as trades: one row per trade — an entry and the exit orders that closed what it opened — with its label (the structure it opened, e.g. Put Credit Spread, or the strategy's own name for it), its status, entry_fill_price and exit_price, and its own gross_pnl, fees (your schedule, on that trade's transactions) and net_pnl — gross minus those fees; slippage is a day-level charge and is not split per trade. status is one of working, open, closing, closed, stopped and not_filled; the two in-flight ones describe a session still running, so a settled day reads terminal throughout. A trade can sit in closing for a while — a close whose contracts another trade holds the other side of waits for the book to change before it can be placed. exit_reason says how a terminal trade ended and is free text: take_profit or stop_loss for the resting close that filled, liquidated when the strategy handed the whole session to the platform to close, settled when nothing closed it and it expired or was exercised at the close, and a reason of the strategy's own when it closed the trade itself — match the ones you know and show the rest verbatim. How many rows there are is the strategy's business: most open a single trade per session, one that opens several reports a row each, and a session whose entry never filled still reports it, as not_filled. Every order carries the matching trade_id and trade_label, so the two lists group onto each other; an order that opened nothing and followed no entry carries neither and stands on its own.

The rest of the decision log is the platform narrating its own work — what it did on the strategy's behalf, which the strategy itself cannot see. Two groups, plus the strategy's own ctx.log() lines:

  • The entry. entry_place_failed (an attempt of the platform's own was refused — by the market rules, or by a platform check such as a trade budget — reported once per kind of refusal, not once per attempt) and entry_abandoned (at most one per session: the entry window ended, the platform withdrew the still-unfilled entry, and the session will not enter that day; reason says why, and a warn level marks the rare runaway case where the attempt budget ran out inside the window). entry_window_wrong_day records a strategy asking to open a trade on a weekday its own entry window excludes — nothing is attempted. A live bot writes the same three facts under slightly different names (entry_retry_abandoned there), so read each surface's own vocabulary rather than expecting one list.
  • A strategy closing itself out. When a strategy hands the session to the platform to close, the log carries the whole walk: liquidation_started (reason), liquidation_swept (how many of the strategy's working orders were cancelled first), liquidation_planned (the closing orders the platform placed for that round) and then liquidation_completed — or, at warn, liquidation_partial when the session reached the close with positions still open, which then settle there. Four warn rows report a walk that could not proceed, each reported once per kind: liquidation_plan_failed (no close plan could be built), liquidation_stalled (a planning round placed nothing and the walk backed off), liquidation_place_failed (a closing order was refused) and liquidation_order_not_laddered (a closing order could not be re-priced and rests where it is). A liquidated session is still a completed one — the day runs on to the close, and the liquidation shows up here and in the orders' liquidation class rather than in the day's status.

The drill-in's headline net_pnl is gross minus both fees (your schedule) and slippage (your per-contract slippage applied to the day's option fills). The response always includes the intraday_curve (downsampled to at most 600 points across the session), cost-adjusted for fees + slippage. reconciliation_warning: true means the recomputed gross P&L no longer matches the stored result — the engine changed since the session was computed, and the stored results will be recomputed.

Update with new sessions

curl -s -X POST https://api.0dtespx.com/strategies/$STRAT_ID/results/update -H "Authorization: $TOKEN"

As new market days become available, results go stale (new_sessions_available > 0). An update runs only the missing sessions and refolds the summaries — nothing already computed is re-run. Returns 202 {"status":"queued"}. The snapshot's update_allowed simply flags whether new sessions exist — the call is accepted either way. Subject to the 3-active-runs cap (429). Failed sessions are retried by an explicit update.

Manage

curl -s -X PATCH https://api.0dtespx.com/strategies/$STRAT_ID \
  -H "Authorization: $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"My put credit spread","description":"my spread","privacy":"public"}'
curl -s -X DELETE https://api.0dtespx.com/strategies/$STRAT_ID -H "Authorization: $TOKEN"

PATCH (owner only) touches per-user metadata — a title, a description override and the privacy flag ("public" publishes, "private" unpublishes); each field is applied only when present, and the logic itself is immutable (clone to change it). A title is trimmed and must be 1–120 characters (422 invalid_title otherwise), and can only be set once the strategy's automatic title generation has finished: sent during that brief post-save window it returns 409 title_generating (watch title_pending on GET /strategies/{id}, and retry when it clears — a description- or privacy-only patch is never blocked by it). Publishing only makes the strategy reachable by link — it's never listed or discoverable — so to actually share it, send someone its URL (https://www.0dtespx.com/research/strategies/{id}); the strategy page has a share button that copies the link for you. DELETE removes the strategy from your list; re-saving the exact same configuration restores it with the same id and its results. Portfolio membership blocks both: PATCH with privacy: "private" returns 409 strategy_in_public_portfolio while a public portfolio of yours contains the strategy, and DELETE returns 409 strategy_in_portfolio while any of your portfolios does — both payloads name the blocking portfolios.

Clone someone else's strategy

For a Builder strategy there is no clone endpoint — cloning is just saving a configuration you read from a public strategy. Fetch a public strategy with GET /strategies/{id}, take its config, and POST /strategies it (optionally tweaked). You get your own private link and your own results overlay; the original owner is unaffected. Because config is only returned for strategies you can read, you can only clone public strategies (or your own).

An assistant strategy has no config (it was written as code), so its conversation is what gets cloned: POST /strategies/assistant/drafts/{id}/fork forks that conversation into a new editable draft (see AI assistant drafts above), giving you a separate thread while the original conversation carries on. This is owner-only — it copies your private chat, so it's never available to someone viewing a strategy you shared. Your own copy of an assistant strategy also carries draft_id and draft_message_id: the conversation that published it, and the message whose version it was — both owner-only, and both absent once that conversation is deleted.

Forking the code works for every producer, and needs no clone endpoint either: read the code with GET /strategies/{id}/source, change what you want, and save it as your own. You get a new private strategy of your own with its own results; the original is untouched, and no link is recorded between the two. Code saved under an older contract may need a repair on the way through — POST /strategies/validate names what to fix.

Next: track run progress over the WebSocket backtest_events channel.