The reTrade SDK
Before your AI agent places an order, the SDK asks reTrade. It checks the signed answer, places only what the answer allows through your own broker code, and keeps the evidence.
In development. Not released yet; names and formats may change.Two SDKs, both in development
The Python SDK and the TypeScript SDK follow everything on this page. Both are published as retrade, on PyPI and on npm, and talk to reTrade’s hosted address, https://api.retrade.app, which is live. They’re pre-release: names, formats and defaults may change between versions. Live trading isn’t switched on yet.
pip install retrade
npm install retrade
Ids, prices and orders in the examples are illustrations. Values highlighted in angle brackets, such as reTrade’s root public key, aren’t published yet.
One order, step by step
- Your agent hands the SDK an order: instrument, side, quantity, type, time in force and, for a limit order, its price.
- The SDK reads the account and a quote from your broker through your broker adapter. Account data always comes from the broker, never from the agent.
- It signs the request with your executor key and sends it to reTrade.
- reTrade checks the order against the account’s limits, signs its answer and appends it to its log.
- The SDK checks the answer. On ALLOW or CUT it places exactly the approved order through your adapter, once, and reports to reTrade what happened.
- Your agent gets the result. The SDK keeps the request, the answer and the report in the claim kit.
reTrade never connects to your broker, never holds your broker login and never moves money. Only your adapter talks to the broker.
Keys and signatures
| Key | Id | Who holds it | What it signs |
|---|---|---|---|
| Executor key, your bot’s key | exk_<mode>_<ULID> | Your agent’s machine. reTrade only ever sees its public half; an admin registers it, and it works at once. | Its requests to reTrade: decisions, submission reports, status and log reads, and stops, the kill switch included. It can’t release a stop or change limits, modes or keys. |
| Admin keys, two or more | adm_<ULID> | Your people. A new admin key works 24 hours after it’s added, or at once during first setup. | Changes to limits, modes and keys, stops and their release, through the admin API and command line. |
| Daily decision keys | dk_<env>_<YYYYMMDD> | reTrade, one per day (UTC) | Every answer, receipt and log head. |
| Root key | root | reTrade, kept offline | The published list of decision keys. Your SDK pins its public half. |
The mode is in an executor key’s id (enforce, shadow or test). A decision key’s env is live for enforce and nonlive for shadow and test. Each one is valid from 00:00 UTC on its day to 00:05 the next day, and stays on the list forever, so an old answer can always be checked. The SDK fetches the list from GET /v1/keys and checks it against the pinned root key. Key files are Ed25519 keys in unencrypted PKCS8 PEM, readable only by their owner. The admin API and its command line, retrade-admin, are in development.
A tenant has at least two admin keys. If your people lose them, only reTrade’s operator can help: after checking who is asking, outside reTrade, it adds one new admin key with retrade-server admin-key-recover. That key works only 24 hours later, and the change is written to your log at once, where any admin who still holds a key sees it and can revoke the new key before it works. retrade-admin keys list warns when you have exactly two admin keys.
First setup is the time before any account enforces. During it, changes apply at once; after it, anything that loosens waits 24 hours. Whether to keep this setup window is still being decided.
Request signing
Every request except GET /v1/keys carries Retrade-Key-Id, Retrade-Timestamp, an Idempotency-Key on a POST, and Retrade-Signature: base64url, without padding, of an Ed25519 signature over five lines. reTrade accepts a timestamp within 60 seconds of its own clock.
GET
/v1/log?from=1&to=2
2026-10-22T14:03:09Z
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
In order: the method, the full path with its query, the timestamp, the idempotency key (empty for a GET) and the lowercase hex SHA-256 of the body (here of an empty body). The same idempotency key with the same body gets the stored answer back, byte for byte; with a different body it’s refused.
The decision request
POST /v1/decisions, with the order, a quote and the account data the adapter read, in one JSON body. Numbers are strings, so nothing is rounded on the way. The SDK keeps these exact bytes, because reTrade’s answer is bound to their SHA-256.
{"account_ref": "A1",
"agent_id": "agt_m",
"order": {"instrument": "XNAS:AAPL", "side": "buy", "quantity": "40",
"type": "limit", "limit_price": "187.25", "tif": "day",
"intent": "increase"},
"quote": {"instrument": "XNAS:AAPL", "last": "187.10", "bid": "187.08",
"ask": "187.12", "as_of": "2026-10-22T14:03:06.415Z"},
"snapshot": {"as_of": "2026-10-22T14:03:05.415Z",
"cash": {"USD": "38380.90"},
"settled_cash": {"USD": "38380.90"},
"positions": [
{"instrument": "XNAS:AAPL", "quantity": "81", "mark": "187.10"},
{"instrument": "ARCX:VTI", "quantity": "160", "mark": "290.40"}],
"open_orders": []}}
The SDK works out intent itself: a buy is open when the account holds none of the stock and increase otherwise, and a sell is reduce. There’s no approval field: nothing is ever approved by a person. An order may also carry a stop price, the exit level a written-exit rule reads; it must be above 0, and the SDK refuses a stop price of 0 before sending anything.
Three answers
| Answer | Meaning | What the SDK does |
|---|---|---|
| ALLOW | The order fits every limit. | Places it as asked. |
| CUT | It fits only at a smaller quantity. | Places the smaller quantity. |
| BLOCK | It breaks a rule, or no room is left. | Places nothing. |
Nothing waits for a person. There are no holds and no approval step. An order that would once have waited for someone’s approval, because it’s over one of the account’s dollar lines or another agent is trading the same stock the other way, comes straight back to your agent in the same response: a BLOCK with a reason code and fits, the largest quantity that would pass. Your agent can resize the order, re-plan, or ask its own user. reTrade asks no one.
For example, a buy of 40 at a 187.25 limit against the 5,000 per-order line comes back as BLOCK, approval_line per_order, with fits of 26: 26 shares at 187.25 is 4,868.50.
Sending the order again at fits is a new decision, with a new idempotency key. It counts toward the agent’s orders-per-minute limit (runaway_throttle), and so does the BLOCK before it.
The dollar lines are your own. A new tenant starts at 5,000 per order and 10,000 per day; your admins set their own lines when reTrade makes the tenant, or later with retrade-admin policy set. They aren’t limits of reTrade’s.
Over a line or in an agent conflict, the answer is BLOCK with fits. That’s decided. Both results have a flags list, which stays empty.
An order placed after a BLOCK, by anyone, has no decision behind it, so there’s no evidence for it.
Reason codes
A CUT or BLOCK names the rule that decided it in deciding, as {"code": …}, or {"code": "approval_line", "reason": …} for a dollar line. An ALLOW has none.
| Code | What happened | What the agent can do |
|---|---|---|
ACCOUNT-UNKNOWN | The account isn’t registered, or its household doesn’t resolve. | Ask its user to register the account. |
STOPPED | A stop is engaged for the tenant, the account or the agent. | Wait for an admin to release it. |
AGENT-STOPPED | The runaway check switched this agent off. | Ask its user to have an admin re-enable it. |
runaway_throttle | Too many orders a minute, or a duplicate. | Slow down; don’t repeat the order. |
INVALID-ORDER, UNSUPPORTED, SYMBOL-UNKNOWN, INTENT-MISMATCH | The order is malformed, of a kind reTrade doesn’t support, for an unknown listing, or mislabelled. | Fix the order. |
QUOTE-MISSING, QUOTE-STALE, QUOTE-INVALID, STATE-MISSING, STATE-INCOMPLETE, STATE-STALE, STATE-INCONSISTENT, FX-MISSING, FX-STALE, FX-INVALID | The quote, the account data or the exchange rate is missing, old or doesn’t add up. | Retry with fresh broker data. |
PRICE-MOVE, PRICE-COLLAR, MARK-MISMATCH | A price moved too far, the limit is too far from the last price, or the account’s mark doesn’t match the quote. | Check prices and the limit. |
STATE-JUMP | The account’s value jumped since its last accepted data. | Declare deposits and other changes, or have an admin confirm the data. |
HOUSEHOLD-STALE | A household limit needs fresh data from the household’s other accounts. | Send fresh data for those accounts. |
agent_permission, order_form, restricted_list, new_position_pace, written_exit | A rule forbids this order. | Re-plan. |
single_name_cap, group_cap, order_size_cap, cash_funding, no_short | A limit leaves room for fewer shares: CUT, or BLOCK when there’s no room. | Accept the cut, or re-plan. |
AG-RESERVED | Orders still working use up the room for now. | Retry once those orders show at the broker. |
approval_line with per_order, per_agent_day, per_day, openings_pct, hard_cap_day | The order is over one of the account’s dollar lines. fits is set. | Resize to fits and send it again, or ask its user. |
agent_conflict | Another agent is trading the same stock the other way. fits is 0. | Re-plan with the other agent’s order in mind, or ask its user. |
REF-DISAGREE | reTrade’s two independent checkers disagreed. | Nothing; reTrade’s staff are alerted. |
The SDK’s own reasons
When the SDK acts without an answer, or doesn’t place an approved order, it says why in the result’s reason, never in deciding.
| Reason | When | Result |
|---|---|---|
unreachable | No usable answer within 2.5 seconds. | Local BLOCK, not placed. |
unverified | The answer failed a check, or its order fields differ from your request. | Local BLOCK, not placed, not reported. |
exits-only:<why> | Exits-only refused the order, and says why. | Local BLOCK, not placed. |
exit | Exits-only placed a sell while reTrade was unreachable. | Local ALLOW, placed. |
shadow-unreachable, shadow-unverified | In shadow, the original order was placed without a usable answer. | Local BLOCK, placed. |
expired, stopped | An ALLOW or CUT withdrawn before the broker call: time ran out, or a stop is engaged. | reTrade’s answer, not placed. |
already-used | The claim kit shows the decision was already used. | Not placed. |
broker-rejected | The broker definitely refused the order. | Not placed. |
broker-outcome-unknown | The submit’s outcome is unknown and find found nothing. | Placement unknown. |
The signed decision
reTrade answers {"decision": "<compact JWS>"}, signed with Ed25519 by the day’s decision key. The payload records the answer, the order as approved, the SHA-256 of your request, the rules in force, reTrade’s clock and the decision’s place in the log:
{"decision_id": "01JB8Z6Q4M3T9V2K7X5R1C0N8D", "tenant_id": "T1",
"mode": "enforce", "account_ref": "A1", "agent_id": "agt_m",
"outcome": "BLOCK", "quantity": null,
"deciding": {"code": "approval_line", "reason": "per_order"},
"fired": [{"code": "approval_line", "reason": "per_order"},
{"code": "approval_line", "reason": "per_day"}],
"rooms": {"cash_funding": "204"}, "fits": "26",
"opening": false, "p": "187.25", "line_value": "7490", "expires": null,
"accepts": {"snapshot": true, "mark": true, "rate": false},
"switch_off_agent": false, "links": [], "alerts": [], "value": "100000",
"requested_quantity": "40",
"order": {"instrument": "XNAS:AAPL", "side": "buy", "type": "limit",
"tif": "day", "limit_price": "187.25"},
"request_sha256":
"a7d194e7a02eebf02e40d2eaaeb6c5d4b9619cab31913abbb2b765ecf2303fbc",
"policy_hash":
"5639cfbad8cb84092c15c01832cd535606538cde4f9c798ca1da43df4f4adf07",
"policy_epoch": "1",
"master_sha256":
"a58c17ac155efdc751b4f028db283d14e889a67425ed21ebd2b15c7e25078f32",
"engine_version": "retrade-engine/0.2.0",
"spec_version": "retrade-arith/2.0-draft",
"evaluated_at": "2026-10-22T14:03:07.415Z", "seq": "1",
"prev_hash":
"0000000000000000000000000000000000000000000000000000000000000000",
"claimable": true}
An ALLOW or CUT carries quantity and expires, the end of its life: 30 seconds by default, and an admin can set it from 5 to 60. A BLOCK has neither. The full payload also carries context, the data reTrade decided on. claimable is true for an enforce decision: it says the decision is evidence an insurer can review, and promises no payment. The SDK ignores members it doesn’t know.
Checking a signed answer
The SDK runs every one of these checks before anything reaches your broker:
- The body is a JSON object holding a string
decision. If not, the answer counts as unreachable. - The JWS is well formed: its header is exactly
algEdDSA, akidandtypretrade-decision, and its signature is 64 bytes. JSON with a number where a string belongs, or a repeated key, is refused. - The
kidis on the root-signed list, for your key’s environment, valid atevaluated_at. An unknownkidmakes the SDK fetch the list again, at most once a second. - The Ed25519 signature verifies.
- The mode is your key’s, the account and agent are your request’s, and
request_sha256is the SHA-256 of the bytes you sent. evaluated_atis within 60 seconds of your clock.- The answer has the right shape: an ALLOW approves the requested quantity; a CUT approves at least 1 and fewer than requested; either lives 5 to 60 seconds; a BLOCK has no quantity and no expiry.
- The requested quantity and the order’s fields are your order’s.
A failure of checks 2 to 8 is a local BLOCK marked unverified: nothing is placed and nothing is reported. That includes an answer whose order fields or quantity differ from the request you sent. Then, in enforce and test, the SDK places an ALLOW or CUT only while time is left (counted from when it sent the request, on a clock that can’t jump), no known stop covers the account and agent, and it has marked the decision used in the claim kit. It submits exactly the approved fields at the approved quantity, with the tag rt1-<decision id> in the broker’s client order id (rs1- in shadow, rx1- in test). A decision is placed at most once.
The signed status
GET /v1/status answers with two signed documents: the log’s latest head and the status, of type retrade-status, which holds the stops in force and each account’s settings, exits-only and the listings exits-only reads. The SDK uses none of it until the status passes the same key and signature checks, is for your key’s mode, matches the head’s tenant and log position, and was signed within 60 seconds of your clock. A status that fails is refused, and the SDK keeps the last one that passed.
When reTrade doesn’t answer
The SDK gives each decision 2.5 seconds in total. A timeout, a failed connection, a 429 or any 5xx status, or an unreadable answer counts as no answer. Within that time it retries once, at once, with the same bytes and the same idempotency key, signed again, so reTrade can never decide one order twice. If there’s still no answer, the SDK blocks the order itself: a local BLOCK marked unreachable. In enforce and test nothing is placed (in shadow the original order is), and a local BLOCK is never evidence for an insurer. At start-up the SDK opens the connection and fetches the key list early, so the first order doesn’t pay for them.
The one exception is exits-only, an account setting an admin turns on (a loosening, so it takes effect 24 hours later, or at once during first setup), and the SDK reads only from the signed status. While reTrade can’t be reached, it lets the SDK place a limit sell of shares the broker holds now, less the sells reTrade already approved that the broker doesn’t show yet, no more than 10% below the last price, of no company on the won’t-sell list, within the per-order line (the latest rules’ line, or 5,000 when the SDK holds no rules with one), with no stop engaged and by an agent reTrade hasn’t stopped or switched off. Everything else is still blocked. Such a sell is a local ALLOW with reason exit, and isn’t evidence for an insurer either.
Any other refusal, a 4xx status other than 429, comes back to your code as an error, never as an answer: a request reTrade couldn’t accept, to fix rather than retry.
The broker adapter
The adapter is your own broker code, wrapped in four functions. It’s the SDK’s only contact with a broker. The generic adapter takes four plain functions, so you can connect a broker yourself with no broker-specific code from reTrade.
| Function | What it does |
|---|---|
| Read account | Reads cash, settled cash, positions, open orders and a quote from the broker, for every decision. |
| Submit | Places the approved order with the tag in the broker’s client order id, and returns the broker order id. It reports a rejection only when the broker definitely refused the order. A submit is never retried. |
| Find | Looks up an order whose submit outcome is unknown: by tag, or, where the broker doesn’t keep tags, by matching the approved fields among recent orders. |
| Cancel | Cancels a working order. To change an order, cancel it and ask for a new decision: there’s no modify. |
Two settings go with it: how many seconds the broker takes to show a new order (a whole number from 1 to 3,600, and 60 by default), and whether the broker converts currency on its own (off by default). Both must match the account’s settings at reTrade, or the SDK won’t use the adapter.
The submission report
After the broker call, the SDK sends POST /v1/decisions/<decision id>/submission with one of two bodies, and the decision id as its idempotency key. reTrade answers with a signed receipt.
{"kind": "placed", "broker_order_id": "...",
"submitted_at": "<time of the broker call>"}
{"kind": "not_submitted", "reason": "rejected" | "withdrawn"}
placed: the broker took the order. Its id links the order to its decision where the broker drops the tag.rejected: the broker definitely refused it.withdrawn: the SDK didn’t place it, because time ran out or a stop is engaged.
A report goes only for a verified ALLOW or CUT, once. A timeout or an unknown outcome is never reported as not placed: the adapter looks the order up instead, and if it still can’t tell, the decision keeps counting against the limits until the account data shows the order or its absence.
The claim kit
A local SQLite file the SDK writes as it goes, claim-kit.sqlite3 in its state directory. It holds the exact request bytes, reTrade’s signed decisions, every rules document decided under (by its hash), the signed log heads and receipts, the broker order id and the local submit time. reTrade can’t alter any of it. Each record is saved to disk before the SDK goes on, and a decision is marked used before the broker call. Both SDKs use the same file layout.
It’s the evidence that makes a mistake insurable: what the agent asked, what reTrade answered and what reached the broker, which an insurer can review to underwrite the risk and assess a claim. Keep it for at least 90 days. To hand one decision’s evidence to an insurer, export it as JSON, in the format retrade-claim-kit/1. Only enforce-mode decisions are that evidence: shadow and test decisions, and the SDK’s own local results, aren’t. reTrade is not an insurer and pays no losses: whether a loss is covered is up to the insurer.
Modes
| Mode | What happens |
|---|---|
| enforce | reTrade’s answer decides what reaches the broker. Its decisions are evidence an insurer can review. |
| shadow | Your agent’s orders are placed unchanged, whatever the answer; reTrade only records what it would have done. |
| test | As enforce, for testing. Its decisions aren’t evidence for an insurer. |