backtrade.
Backtrade?

Connect an AI agent

Use your Backtrade account to replay markets and review every run.

  1. Create an API key in Settings.
  2. Add https://api.backtrade.dev/mcp to your MCP client with the key.
  3. Ask your agent to start a replay and return its watch URL. Find completed runs in Results.

Connection templates ↗ · Raw agent instructions ↗

Full MCP & API reference

backtrade.dev — give your agent a paper broker

Backtrade replays historical minute bars and certified realtime news through a causal paper broker. Each session resolves the feed that actually covers the day you asked for and reports it back on the replay frame as feed — IEX where it is complete, and a consolidated feed otherwise. The IEX tape stops on 2026-05-03, so anything later is served from the consolidated one; you do not choose, and the frame always says which you got.

Use any model, prompt, strategy, or harness—Claude Code, Codex, your own loop, or no LLM at all.

You bring the strategy. We provide the tape and simulator. We do not provide signals or claim that a strategy will be profitable. See Terms.

Fastest path: one URL, no download

Point any MCP client at the hosted endpoint and pass the key you already have. Nothing to download, no checksum to verify, no Node runtime to install.

{
  "mcpServers": {
    "backtrade": {
      "url": "https://api.backtrade.dev/mcp",
      "headers": { "Authorization": "Bearer <key-id>:<secret>" }
    }
  }
}

The APCA-API-KEY-ID / APCA-API-SECRET-KEY pair every other route accepts works here too, if your client sends header pairs more easily than a bearer. It is the same credential either way — one key, one place to revoke it.

Two things worth knowing, because they are enforced rather than advisory:

  • The secret is checked on every request, not once at connect. An MCP session id is a routing value that travels in headers and logs; it is not a password, and it is not treated as one here.
  • A session belongs to the credential that opened it. Presenting another valid key with someone else's session id is refused, not merged.

Get the key first, with one curl

This is the whole of the hosted path — there is nothing to download at any point. signup_trial does not work here and will tell you so: it manages a credential file on the machine it runs on, and on the hosted endpoint that machine is our server, not yours. You would never receive the secret.

SECRET=$(head -c 32 /dev/urandom | basenc --base64url | tr -d '=')
VERIFIER=$(printf '%s' "$SECRET" | openssl dgst -sha256 -binary | basenc --base64url | tr -d '=')

curl -sS -X POST https://api.backtrade.dev/v1/agent-trials \
  -H 'content-type: application/json' \
  -H "idempotency-key: $(head -c 24 /dev/urandom | basenc --base64url | tr -d '=')" \
  -d "{\"secretVerifier\":\"$VERIFIER\",\"label\":\"my-agent\"}"

Only the SHA-256 of your secret is sent. The secret itself never leaves your machine and cannot be recovered from us — keep $SECRET, take keyId from the response, and the bearer above is <keyId>:<SECRET>.

Tier-0 trials last 30 days and permit 100 sessions on up to 5 symbols, blind. The signup reference further down covers the error codes and the per-day caps.

A trial credential is not the same thing as a free account, and the free account is larger: signing up at backtrade.dev raises the limit to 600 sessions a month and does not expire. You do not need one to start — the trial above needs no sign-up at all — but if you run out of trial sessions, that is where they come back.

Alternative: install the 17-tool MCP adapter locally

The adapter runs locally over stdio, talks only to api.backtrade.dev, and keeps its client-generated secret in ~/.config/backtrade/agent-credential.json with mode 0600. The secret is never returned to the model, including when an API response or WebSocket diagnostic tries to echo it.

If that directory already exists and is group- or world-writable, signup refuses rather than writing a secret somewhere other people can read. It tells you the fix:

chmod 700 ~/.config/backtrade

The adapter creates the directory itself at mode 0700 when it is absent, whatever your umask. You only see the refusal if an older directory is already there — a umask of 002, the Ubuntu and Debian default, produces 0775. The refusal is deliberate: a credential is not worth leaking to save you one command.

Node 18 or newer. The adapter is a single ES module, so it is named .mjs — that extension is what makes it load on every Node version rather than only on 22.7+. If you have an older config pointing at backtrade-mcp.js it still works, but only on Node 20.19+ or 22.7+.

mkdir -p "$HOME/.local/share/backtrade"
curl -fsSLo "$HOME/.local/share/backtrade/backtrade-mcp.mjs" \
  https://backtrade.dev/backtrade-mcp.mjs
curl -fsSLo "$HOME/.local/share/backtrade/backtrade-mcp.mjs.sha256" \
  https://backtrade.dev/backtrade-mcp.mjs.sha256
(cd "$HOME/.local/share/backtrade" && sha256sum -c backtrade-mcp.mjs.sha256)

Add it to Codex CLI, the Codex IDE extension, or the ChatGPT desktop app's shared local MCP configuration:

codex mcp add backtrade -- node "$HOME/.local/share/backtrade/backtrade-mcp.mjs"

That command shape follows the official Codex MCP documentation.

For Claude Code:

claude mcp add --scope user backtrade -- node "$HOME/.local/share/backtrade/backtrade-mcp.mjs"

Then ask your agent:

Use Backtrade. Call signup_trial, list recent SPY days for my model, start a pull session, inspect events without future data, paper-trade my strategy, and finish with the scorecard. Do not invent a strategy or claim live profitability.

signup_trial creates a Tier-0 file automatically.

Economic releases and earnings through the MCP adapter. The adapter subscribes to the events channel for you — you do not send a subscribe frame, and typed econ and earnings events arrive alongside bars and news. The published bundle now delivers them: measured 2026-09-12 against backtrade-mcp.mjs sha256 57d0e030…, one SPY session returned {"econ": 21, "news": 75, "bar": 896}, with econ frames arriving as typed {"T":"ev","kind":"econ",…} alongside the bars. An earlier note here said the published bundle returned 0 econ, measured 2026-09-04; that was true of the bundle of the day and is no longer true. The raw WebSocket path below is one way to read events, not the only one.

Account linking is LIVE. GET https://api.backtrade.dev/v1/status is authoritative and reports "accounts" and "device_linking" as enabled. https://backtrade.dev/app.html serves a real dashboard with sign-in, Keys and saved runs, and link_account returns a short code you approve there.

Measured end to end against the running service on 2026-09-10: register, sign in, link_account, approve in the dashboard, replay on the minted key, and find the run saved under the account. The key it mints is tier 1.

Paid tiers are not purchasable yet, so a free account is blind-only (base-100 prices) and up to 5 symbols per session.

Verified 2026-09-15 against 6d1003d5.

To attach this MCP to your Backtrade account without putting a key in chat, call link_account. It stages a new credential secret and a separate polling token locally before networking, in a second mode-0600 file. The tool returns only a short code, the fixed https://backtrade.dev/app.html dashboard URL, and its ten-minute expiry.

Sign in at that URL, open Keys, enter the code, inspect the device label and fixed scopes, then approve with your current password. Approval never subscribes or charges you. Call link_account_status afterward. It polls with the retained token internally, atomically installs the Tier-1 account key, acknowledges receipt, and only then removes the pending file. A working Tier-0 key is preserved until that install succeeds; a lost response can be retried without minting another key. Denied or expired requests require link_account {"restart":true} so replacement authority is always explicit.

For an existing account or paid Tier-1 key issued through another client, you may still put its ID, secret, scopes, and cached expiry in the same secure file manually (do this outside model/chat context):

{
  "version": 1,
  "state": "active",
  "keyId": "YOUR_16_CHARACTER_KEY_ID",
  "secret": "YOUR_43_CHARACTER_SECRET",
  "tier": 1,
  "scopes": ["replay:read", "orders:simulate"],
  "expiresAt": "2026-10-09T00:00:00.000Z"
}

Run chmod 600 ~/.config/backtrade/agent-credential.json after writing it. You can select another file with BACKTRADE_CREDENTIAL_PATH. The cached Tier-1 expiresAt is metadata only because renewals happen on the server; authentication there is authoritative on every request. Tier-0 files remain locally fail-closed at their non-renewable expiry.

The adapter exposes:

Tool Purpose
signup_trial Create/recover a bounded 24-hour local credential
link_account Stage account-key authority locally and show its short dashboard approval code
link_account_status Poll internally, atomically install the approved Tier-1 key, then acknowledge it
list_days Find replayable sessions and leakage metadata, on whichever feed covers each day
start_session Open a pull replay; Tier-0 is one indexed symbol, Tier-1 may request its plan's multi-symbol/unblinded limits
advance Reveal the next events or advance to a UTC time
session_status Read clock, remaining events, account, and capacity state
place_order Submit market, limit, or stop paper orders
cancel_order Cancel a resting order
get_account Read cash, positions, P&L, fills, and costs
get_orders Read consistently serialized order state
end_session Reveal truth and the causal buy-and-hold scorecard
resume_session Continue a SUSPENDED replay from the event it stopped at, after a server restart
run_campaign Pooled benchmark across a span of days — opens no session and spends no quota
arm_gate Stop the tape the moment a rule matches, and keep it stopped until you acknowledge it
ack_event Acknowledge the held event by its seq and let the tape run on
disarm_gate Stop the rule firing again. It does NOT release a hold already in effect — ack that one first

Both surfaces serve all seventeen, gate tools included. [Measured 2026-09-12: tools/list returns 17 names against the hosted MCP at https://api.backtrade.dev/mcp and against the published bundle, and arm_gate on a hosted session holds the tape exactly as it does locally.] An earlier draft of this note claimed the three were local-only; that was read off a test fixture instead of measured against the live endpoint, and it was wrong.

Arguments

? marks optional. model is required on list_days and start_session. For start_session, pass exactly one of the backward-compatible singular symbol or the symbols array (1–50 transport bound); the server applies the credential's smaller plan limit. blind defaults to true.

Tool Arguments
signup_trial label?
link_account label?, restart? (restart is accepted only after denial or expiry)
link_account_status none; code and polling token are loaded from the pending file
list_days symbol, model, limit?, from?, to?
start_session exactly one of symbol or symbols, plus date, model, blind?, cash?, cost_bps?, to?, allow_incomplete?, success_threshold?, control_arm?
advance session_id, at most one of events? or until? (neither defaults to 30 events); not_after? bounds events mode
session_status session_id
place_order session_id, symbol, side, qty, type?, limit_price?, stop_price?, client_order_id?
cancel_order session_id, order_id
get_account session_id
get_orders session_id
end_session session_id, force?
resume_session session_id, plus the same symbol/symbols, date, model, blind?, to? the session was opened with
run_campaign symbols, from, to, strategy (buy_and_hold or do_nothing), cash?, cost_bps?
arm_gate session_id, match, gate? (hold default, or notify)
ack_event session_id, seq — required while the tape is held; the advance that reported the hold carried it as gate.held_on.seq
disarm_gate session_id

Two controls that turn a backtest into a prediction

These are the difference between a number you found and a number you predicted. Both are optional arguments to start_session; neither changes the tape.

success_threshold — pre-registration. State what would count as success before the run, as { metric, op, value }. The server records it at session open and grades against it at end_session. It refuses a threshold supplied after the run, which is the whole point: a criterion chosen once the result is known is not a criterion. The scorecard reports three states and keeps them apart — met, not met, and ungradeable when the metric is unavailable. An unmet threshold is a normal outcome and is reported as one.

control_arm — permutation control. Re-executes the same trades at randomly drawn instants on the identical tape, so only the timing moves. If the strategy's result survives that, the timing carried information; if the control scores the same, the result was the tape and not the strategy. Its absence leaves no timing field on the scorecard, so a run without a control cannot be mistaken for one that passed it.

allow_incomplete serves a day whose regular session is missing bars, rather than refusing it. The scorecard still reports what was missing — the flag changes what is refused, never what is disclosed.

One measured production run from this server reached a scorecard in about 8 seconds; network, model and database latency will vary:

signup_trial   { }                                          -> keyId
list_days      { symbol: "TSLA", model: "claude-opus-5" }   -> replayable dates
start_session  { symbol: "TSLA", date: "2026-03-23",
                 model: "claude-opus-5" }                   -> session_id, leakage
advance        { session_id, events: 60 }                   -> bars and headlines
place_order    { session_id, symbol: "TSLA",
                 qty: 10, side: "buy" }                     -> order id
advance        { session_id, events: 500 }   repeat until finished
end_session    { session_id }                               -> scorecard

The model argument is evaluation metadata used for a leakage disclosure. It does not restrict which LLM or harness can use Backtrade. Unmeasured models are labelled contaminated by default; the replay still belongs to you.

Direct API: any harness

1. Create a trial credential

Your client creates the secret and sends only its SHA-256 verifier. The API returns a bare 16-character keyId; WebSocket and REST authentication always use that key ID and the locally retained secret as separate fields.

SECRET=$(head -c 32 /dev/urandom | basenc --base64url | tr -d '=')
VERIFIER=$(printf '%s' "$SECRET" | openssl dgst -sha256 -binary | basenc --base64url | tr -d '=')
IDEM=$(head -c 24 /dev/urandom | basenc --base64url | tr -d '=')

curl -sS -X POST https://api.backtrade.dev/v1/agent-trials \
  -H 'content-type: application/json' \
  -H "idempotency-key: $IDEM" \
  -d "{\"secretVerifier\":\"$VERIFIER\",\"label\":\"my-agent\"}"

Save keyId from the response and keep SECRET locally:

KEY_ID=...                 # exact bare 16-character keyId
KEY_SECRET="$SECRET"      # never sent during signup; cannot be recovered

Tier-0 trials last 30 days, permit 100 sessions, require blind=1, and allow up to 5 symbols per session. A signed-in free account is a different allowance and a larger one — 600 sessions a month, no expiry — so the 100 here is the no-sign-up number, not the ceiling on what free gets you. Here blind=1 means prices/fills share a base-100 scale; the agent still knows the symbol and date it selected, and headlines retain identifying prose. It is price-indexed, not anonymous. No email, card, or account is required.

Signup is rate-limited per day, so a shared IP, a CI runner, or a NAT'd office can exhaust it without the individual agent having signed up before. Both caps reset on the next UTC issuance day; neither is a ban, and neither consumes your local secret.

Response error Meaning What to do
source_trial_limit Your source reached its trials for today (currently 5) Reuse the credential you already hold, or retry after the UTC day rolls over
global_trial_limit Site-wide daily trial capacity is spent (currently 200) Retry after the UTC day rolls over
idempotency_conflict The same idempotency-key was reused with different credential material Generate a fresh SECRET/VERIFIER/IDEM triple

Both limits are deployment policy and can change; the error codes are the stable contract. A failed signup leaves a reusable state: "pending" credential on disk — calling signup_trial again resumes that same request rather than starting over, so retrying after the reset costs you nothing.

2. Discover sessions

curl -sS 'https://api.backtrade.dev/v2/replay/days?symbol=SPY&model=your-model&limit=20' \
  -H "APCA-API-KEY-ID: $KEY_ID" \
  -H "APCA-API-SECRET-KEY: $KEY_SECRET"

3. Open a pull replay

Connect to:

wss://api.backtrade.dev/v2/iex?date=2026-04-30&symbols=SPY&blind=1&mode=pull&model=your-model

The socket sends two different control frames with T:"success". First it says connected; that means only that the WebSocket opened. Authentication is complete only after the later, exact {"T":"success","msg":"authenticated"} frame. Do not match on T:"success" alone, and do not subscribe before that second frame.

← {"T":"success","msg":"connected"}
→ {"action":"auth","key":"<KEY_ID>","secret":"<KEY_SECRET>"}
← {"T":"success","msg":"authenticated"}  # auth is complete here
→ {"action":"subscribe","bars":["SPY"],"news":["SPY"],"events":["SPY"]}
← {"T":"subscription", ...}

Subscribe to events or you will not receive any. Scheduled economic releases and earnings are delivered only to that channel, and a socket subscribed to bars and news alone receives none of them, silently and for the whole session — advance still reports the events it popped from the tape, so delivered and frames_sent diverge with nothing saying why. The channels are bars, news, events, trades and quotes; the acknowledgement echoes the complete current state of all of them, so check it rather than assuming.

Queue unmatched frames: T:"replay" may arrive while your client is handling the subscription acknowledgement. It carries far more than session_id — it is the only place the session tells you what kind of day you just opened, and four of its fields change how you should read your own result. Captured from a live server, not written from memory:

{"T":"replay","feed":"iex","timeframe":"1Min","mode":"pull","extended_hours":false,
 "date":"1970-01-01","symbols":["SPY"],"speed":0,"blind":true,
 "volume_redacted":true,
 "total_events":476,
 "total_by_kind":{"bar":404,"econ":60,"news":12},
 "fill_latency":{"SPY":{"median_min":1,"p90_min":1,"max_min":1,"rth_bars":390}},
 "completeness":{"measured":true,"rth_expected":391,"rth_complete_floor":390,
                 "min_rth_bars_to_serve":195,"rth_bars":{"SPY":390},
                 "complete":true,"acknowledged_incomplete":false},
 "leakage":{"verdict":"contaminated","note":"...","news_redaction":{...}},
 "session_id":"s_...","cash":100000,"cost_bps":1}
field why you care
volume_redacted on a blind session every bar's v is null, not zero. Zero would assert no trades occurred; null says not disclosed. If you size orders by observed volume, that signal is absent here and this flag is how you find out before your strategy silently does nothing
fill_latency a market order fills at the first open strictly after the submission clock (13:40:00 → 13:41 open on a contiguous minute tape). This reports tape-spacing statistics; use submitted/filled timestamps for actual per-order latency. median_min is 1 on a full tape. It is the p90_min and max_min you want: a day can median 1 minute and still contain a 28-minute hole, and your 09:45 entry becomes a 10:13 entry inside it
completeness rth_bars against rth_expected, plus the floor. A day under min_rth_bars_to_serve is refused outright unless you pass allow_incomplete=1, so if you opted in, this is the day you opted into
total_by_kind total_events counts what the tape yields; a subscription filter decides what you receive. Here 476 tape events become 404 bar frames for a bars-only subscriber. Do not compare a received count against total_events — they are different quantities
leakage contaminated is the default for any model with no measured memory horizon on this box. It is not an accusation, it is an absence of measurement

Your harness owns the clock:

{"action":"advance","events":30}
{"action":"advance","until":"15:30:00"}
{"action":"status"}
{"action":"end"}

For bar replay, until also accepts YYYY-MM-DDTHH:MM[:SS]Z in the session's displayed wire clock. Bare times stay anchored to the start date. With events, an optional full-instant not_after stops at that availability cutoff, including when using a trigger. It cannot be combined with until. Tick replay continues to use milliseconds.

Responses are T:"replay_advance", replay_status, and replay_result. advance includes fills caused by that batch. end refuses before completion; {"action":"end","force":true} drains the tape and marks the result forced.

THE DATA DOES NOT COME BACK INSIDE THE replay_advance REPLY. Read the frames that arrive BEFORE it. On this WebSocket the events are pushed as their own frames — T:"b" bars, T:"n" news, T:"ev" calendar events — and the replay_advance that follows is an ACKNOWLEDGEMENT carrying a delivered COUNT, not a payload. A client that reads only the reply to its own advance sees delivered: 30 and no data, and no error, which looks exactly like an empty tape.

Measured on 2026-04-30/SPY: {"action":"advance","events":5} produced 30 data frames, then {"T":"replay_advance","delivered":30} with no frames array at all.

Accumulate frames from the socket as they arrive and treat replay_advance as the end-of-batch marker. The MCP bundle differs here — it does that accumulation for you and returns the events inside the tool result — so code written against the MCP shape will not port to the raw socket unchanged.

If you want a wall-clock push stream instead, omit mode=pull and set speed. Remember that your network/model latency is multiplied by replay speed; pull mode is the safer default for turn-based agents.

4. Paper orders and results

session_id is a query parameter. Credentials use Alpaca's separate headers:

curl -sS -X POST "https://api.backtrade.dev/v2/orders?session_id=$SESSION_ID" \
  -H "APCA-API-KEY-ID: $KEY_ID" \
  -H "APCA-API-SECRET-KEY: $KEY_SECRET" \
  -H 'content-type: application/json' \
  -d '{"symbol":"SPY","qty":10,"side":"buy","type":"market","client_order_id":"my-order-1"}'

Useful endpoints:

GET    /v2/orders?session_id=...
GET    /v2/orders/<order-or-client-id>?session_id=...
DELETE /v2/orders/<order-or-client-id>?session_id=...
DELETE /v2/orders?session_id=...                 # cancel all resting
GET    /v2/account?session_id=...
GET    /v2/positions?session_id=...
GET    /v2/replay/result?session_id=...          # after completion
GET    /v1/status                                # build + capabilities

Retries with the same client_order_id and identical terms return the original order. Reusing it with changed terms returns 409, preventing duplicate trades. Resting orders expire when the tape completes; new orders are then rejected.

5. Paid plans

GET /v1/billing/plans is the catalog; when purchasable is true the paid plans can be bought (not yet — billing_checkout is false; see the notice above). Indie and Pro unblind prices, allow several symbols per session, and meter sessions per calendar month (UTC) instead of per 24-hour key. Buying is three calls:

# 1. Start checkout with the key you already have (trial keys work).
curl -sS -X POST https://api.backtrade.dev/v1/billing/checkout \
  -H "APCA-API-KEY-ID: $KEY_ID" -H "APCA-API-SECRET-KEY: $KEY_SECRET" \
  -H 'content-type: application/json' -d '{"plan":"indie","cadence":"monthly"}'
# -> {"checkout_url": "https://checkout.stripe.com/..."}   pay there

Stripe returns you to https://backtrade.dev/billing-ok.html?session=cs_..., which makes your paid key in the browser. From your own code, do the same thing with the session value from that URL and a fresh secret:

# 2. Claim the durable key. New secret, only its hash is sent.
PAID_SECRET=$(head -c 32 /dev/urandom | basenc --base64url | tr -d '=')
PAID_VERIFIER=$(printf '%s' "$PAID_SECRET" | openssl dgst -sha256 -binary | basenc --base64url | tr -d '=')
curl -sS -X POST https://api.backtrade.dev/v1/billing/claim \
  -H 'content-type: application/json' \
  -d "{\"checkout_session\":\"cs_...\",\"secretVerifier\":\"$PAID_VERIFIER\",\"label\":\"my-agent\"}"
# -> {"keyId": "...", "tier": 1, "plan": "indie", "expiresAt": "..."}

One claim per checkout; the secret is never shown again. The trial key that started checkout is also entitled until it expires, so an agent mid-session is not cut off.

# 3. Use the paid key exactly like the trial key.
KEY_ID=<paid keyId>; KEY_SECRET="$PAID_SECRET"
# blind=0 and symbols=SPY,QQQ,IWM are now allowed up to the plan's limits.

GET /v1/billing/subscription returns the plan, status, limits and sessions used this month. POST /v1/billing/portal returns a Stripe portal URL to change the card, switch cadence, or cancel. A canceled or unpaid subscription drops the key to free limits immediately; the key itself stays valid for 7 days past the paid period so a late renewal webhook cannot lock you out. Sessions beyond the plan's monthly allowance are refused with 429 at open; symbols beyond the plan with 403; unblinded prices without a plan with 402.

Causal fill model

  • Completed OHLCV is unavailable until the interval closes.
  • A market order fills at the first interval open strictly after submission time: at 13:40:00, the earliest on a contiguous minute tape is the 13:41 open. The 13:39 bar became visible at 13:40; the 13:40 open has already passed. Gaps can delay a fill.
  • Limits and stop activation use intervals starting strictly after submission. Limits are reported at interval completion; stops arm from a completed range before a later eligible execution. Historical results retain the engine behavior that produced them.
  • A coherent bar with moving prices and zero reported volume has an unknown liquidity budget; no volume-derived cap applies, but buying power does. Flat-zero and positive volumes retain their caps. Unknown volume does not establish real liquidity, and positive reported volume can also be undercounted.
  • A limit uses minute high/low and has no queue-position model.
  • A stop arms when touched and fills causally afterward.
  • One-way cost_bps is charged on every actual fill.
  • Blinded trial prices and fills share the same base-100 scale.

What the news stream actually contains

Headlines are real and causally timestamped, but volume and composition vary enormously by symbol, and that changes which strategies are testable. Measured across 7 symbols over the week ending 2026-09-02:

symbol news frames % retail social
NVDA 1,257 39.5%
SPY 596 86.7%
AAPL 586 44.2%
AMD 442 57.7%
TSLA 426 39.4%
QQQ 350 85.4%
AMC 15 86.7%

Two things follow, and neither is obvious from a day's bar count:

  • Index ETFs are mostly social posts. SPY and QQQ cannot have earnings, insider filings or press releases, so the publisher sources that cover single names have nothing to emit about them. Social posts carry a self-assigned BULLISH / BEARISH / neutral tag from their author, at post time. That tag is causal — it is not a later sentiment score — but it is one retail user's opinion, not a wire signal.
  • Some symbols receive almost no news. AMC produced 15 frames across seven trading days. An event-driven strategy on such a symbol has nothing to trigger on, and nothing in the bar count reveals this in advance.

Sources present for single names include benzinga, yahoo-finance, fmp-news, fmp-press, fmp-insider, sec-form4 and sec-form144.

Scheduled earnings and economic releases ARE delivered as typed events, on the events channel, as T:"ev" frames. This document previously said they were not; that was wrong. Subscribe to events (above) or you receive none of them.

Each release arrives twice, and the split is the point: a phase:"schedule" frame when the calendar entry becomes known, carrying estimate and previous and no actual, then a phase:"print" frame at release time carrying actual — and surprise only when a consensus estimate existed. That lets a strategy position ahead of a print it can see coming without ever seeing the number early.

A real pair from one session, the same release seen twice:

{"T":"ev","kind":"econ","S":"NVDA","phase":"schedule","event":"MBA Mortgage Refinance Index",
 "country":"US","importance":"low","scope":"market","release_at":"...",
 "estimate":null,"previous":740.8}

{"T":"ev","kind":"econ","S":"NVDA","phase":"print","event":"MBA Mortgage Refinance Index",
 "country":"US","importance":"low","scope":"market","release_at":"...",
 "estimate":null,"previous":740.8,"actual":732.6}

Most releases have no consensus estimate, and without one there is no surprise. estimate is null on the majority of frames, and surprise is omitted entirely rather than sent as zero — a strategy that keys on surprise must treat the field as absent, not as no-change. Measured across 90 econ frames from two sessions on one date: 29% carried a non-null estimate and 13% carried surprise. That is a single date and the mix varies by day, so treat the proportions as an order of magnitude rather than a rate, but do not design as though every release is a surprise trade.

kind:"econ" is macro and carries scope:"market" — it is fanned out per symbol, so a two-symbol session sees one release twice and must not count it as two signals. kind:"earnings" belongs to one ticker and adds eps_actual, revenue_actual and eps_surprise on the print. An index ETF has no earnings, so SPY and QQQ receive econ frames only.

Two limits worth knowing before you build on them. Earnings carry a report_date and no release time, so before-open versus after-close is not distinguishable from the frame — the session's own print may therefore be unusable for a same-day trade. And a session covers one trading day, so the common "buy the day before, hold through the print" structure cannot be expressed in a single session.

Each news frame carries a timing field — instant, minute, or grid_15m — saying how precisely its source's publication time is known. Treat grid_15m timestamps as accurate to the quarter hour, not the second, before building a latency-sensitive strategy on them.

Roughly 11% of publisher headlines are republished, sometimes hours apart and sometimes across two sources. If your strategy keys on headline arrival rate, deduplicate on headline text first.

Limits and responsibility

This is historical simulation, not live execution or investment advice. Bar replay fills an order in full or rejects it under its prior-minute liquidity budget; it does not model partial fills or observe intraminute spread. Supported sip-partial-v2 tick sessions model partial fills using recorded liquidity and quote spreads. Legacy sip-full-v1 tick sessions wait for a single quote with enough displayed size to fill the entire order; they do not fill it partially. Neither mode models order-book queue position, market impact or auctions. Limit/high-low ordering inside a minute is modelled, not observed. Configured basis-point costs are already reflected in cash and net P&L. They are embedded in market-order fill prices; any portion constrained by a limit price is charged separately to cash. Do not subtract the reported total_cost again from net P&L. Results are simulations, not promises.

Backtrade supplies infrastructure. You own your model, strategy, prompts, risk, and outcomes. See the full Terms and Privacy notice.

The product is backtrade.dev. backtest.dev is an unrelated parked domain.

Optional model decisions in code strategies

Python strategies can use a controlled ctx.infer connection with server-recorded requests, responses and usage. Fresh calls require your own Anthropic account and a separately approved run allowance; Backtrade provides no pooled inference. Recorded reruns make no new model calls and fail on a cache miss. Ordinary backtesting needs no provider or broker account. See Model decisions for limits, research-clock behavior and API/MCP tools.

News clock incident: FMP editorial news is temporarily withheld because its publication times were hours early. Prior runs with unknown or FMP exposure may be affected; results have not been recalculated. Incident details. Corrected FMP replay availability is publisher time plus a 1-minute rounding allowance; FMP delivery latency is unmeasured.

Covered-call scenario lab

Open Options to compare a hypothetical single-cycle payoff with holding the same shares or cash. This is not historical options replay, a quote feed or an order ticket. The current options history is not validated for performance comparisons.

The read-only calculate_covered_call MCP tool uses the same function as the browser (local Node, Bun remote and local-engine adapters; hosted MCP retains normal authentication). No provider call, account mutation or new REST endpoint is involved. Pass all fields under scenario:

{"scenario":{"stock_price":100,"shares":100,"strike":105,"premium":2,"expiry_price":110,"option_fee_per_contract":0.65,"stock_buy_fee":1,"assignment_fee":5,"assignment":"automatic"}}

Dollar inputs have at most two decimals. Shares must be covered standard 100-share lots. Use the bid for premium received per share, not the midpoint. Option fees must fit premium proceeds; this model does not borrow to pay fees. automatic models exercise if at least $0.01 in the money at expiry; assigned and not_assigned are labeled counterfactuals. Actual exercise may differ. There are no dividends, taxes, cash interest, early assignment, rolls or reinvestment. Fees and short-option liability are accounted for; premium alone is not profit. Export includes inputs, assumptions and model version. Existing replay order tools remain equity tools.