TypeScript SDK
What the TypeScript SDK does with each order, what it hands back to your agent, and what it needs from you.
In development. Not released yet; names and formats may change.Built to a shared specification
The TypeScript SDK is in development. It follows the same specification as the Python SDK, and shares its wire format and its claim kit. It has no runtime dependencies beyond Node itself. Its names below are the ones in its source today, and may change before release.
Install
npm install retrade
The package is retrade on npm. Its package file asks for Node 22.18 or later.
Keys
The SDK signs every request with your executor key: an Ed25519 key in an unencrypted PKCS8 PEM file, readable only by its owner. generateKey writes one, never over an existing file, and returns its public half; the package’s command, retrade keygen PATH, does the same. It’s the same file the Python SDK writes.
import { generateKey } from "retrade";
const publicKey = generateKey("executor.pem"); // give this to an admin
# with the admin's --server, --key-id, --key and --root
# (or the RETRADE_* environment variables)
retrade-admin keys add-executor --mode shadow <public key>
An admin registers the public half for one mode and gets back the key id, exk_<mode>_<ULID>; it works at once. The admin command line is in development.
The SDK also needs reTrade’s root public key, <reTrade root public key>, which it pins to check the daily decision keys; it isn’t published yet. reTrade’s address defaults to its hosted service, https://api.retrade.app. See keys and signatures.
Connect
import { Client } from "retrade";
const rt = await Client.open({
keyId, // "exk_<mode>_<ULID>", from registration
keyPath: "executor.pem",
rootKey: "<reTrade root public key>",
stateDir: "retrade-state", // where the claim kit lives
});
rootKey is reTrade’s root public key. The SDK pins it and never fetches it; it checks the list of daily decision keys against it. The SDK talks to reTrade’s hosted address, https://api.retrade.app, by default; pass baseUrl (or set RETRADE_BASE_URL) to use another.
Client.open fetches the key list and the signed status, which holds the stops in force and each account’s settings, and checks both before using them. If reTrade can’t be reached then, the client still opens, and orders fail closed until reTrade answers. rt.refresh() fetches them again. Call rt.close() when you’re done.
Ask for a decision
const order = {
instrument: "XNAS:AAPL", side: "buy", quantity: 40,
type: "limit", tif: "day", limitPrice: "187.25",
} as const;
const result = await rt.place(order, {
account: "A1", agent: "agt_momentum", via: broker,
});
The instrument is an exact listing id; side is buy or sell; quantity is a whole number; type is limit or market; time in force is day, ioc or gtc. Prices are decimal strings (or bigint), never a JavaScript number. An optional stopPrice, the exit level a written-exit rule reads, must be above 0: the SDK refuses 0.
One call does the whole round: reads the account and a quote through your adapter, sends the decision request, checks the signed answer, places what was approved, reports it and keeps the evidence. The SDK works out the order’s intent from the account data, so an agent can’t mislabel an opening as a reduction.
Act on the answer
place hands back this Result, fixed by the specification. An answer is never an exception: a BLOCK, and reTrade not answering, both come back as a result.
type LineReason =
"hard_cap_day" | "per_order" | "per_agent_day" | "per_day" | "openings_pct";
type Ref = { code: string } | { code: "approval_line"; reason: LineReason };
type Result = {
outcome: "ALLOW" | "CUT" | "BLOCK";
placement: "placed" | "not_placed" | "unknown";
// The approved quantity, or what an exits-only sell placed.
quantity: string | null;
requestedQuantity: string;
// reTrade's, verbatim; null for an ALLOW and for every local result.
deciding: Ref | null;
// The largest quantity that would pass, on a BLOCK over a line
// or in an agent conflict; null otherwise.
fits: string | null;
// Always []: over a line the answer is BLOCK, not a flag.
flags: Ref[];
// The decision's alerts, passed through.
alerts: unknown[];
// The SDK's own reason.
reason: string | null;
decisionId: string | null;
brokerOrderId: string | null;
// The SDK decided without a verified answer.
local: boolean;
// An enforce decision, and the order was placed: evidence
// an insurer can review. It promises no payment.
claimable: boolean;
};
Quantities are strings, as on the wire. Handing a result back to your agent, checking placement first, because in shadow mode the agent’s order is placed whatever the answer:
function reportToAgent(result: Result) {
if (result.placement === "unknown") {
// The broker may have the order: never place it again.
return { status: "unknown", why: result.reason };
}
if (result.placement === "placed") {
return { status: "placed", quantity: result.quantity };
}
if (result.outcome === "BLOCK") {
// Nothing was placed. Over a line, fits is the largest quantity
// that would pass: the agent can resize, re-plan or ask its user.
const why = result.deciding ?? result.reason;
return { status: "blocked", why, fits: result.fits };
}
// An ALLOW or CUT that wasn't placed: expired, stopped, rejected.
return { status: "not placed", why: result.reason };
}
To resize, send the order again at fits. It’s a new decision, so it counts toward the agent’s orders-per-minute limit, as the BLOCK before it did:
if (result.outcome === "BLOCK" && result.fits !== null && result.fits !== "0") {
const resized = await rt.place({ ...order, quantity: Number(result.fits) }, {
account: "A1", agent: "agt_momentum", via: broker,
});
}
deciding names the rule, as in the reason codes; reason holds the SDK’s own reasons, such as expired or broker-rejected.
When reTrade doesn’t answer
reTrade has 2.5 seconds in total, including one retry with the same bytes and idempotency key. After that, or when an answer fails a check, the SDK blocks the order itself in enforce and test (in shadow it places the original order):
result.outcome // "BLOCK"
result.placement // "not_placed"
result.reason // "unreachable", or "unverified" when a check failed
result.deciding // null: no answer from reTrade decided this
result.local // true
result.claimable // false
An answer whose order fields or quantity differ from the request you sent is unverified too: nothing is placed and nothing is reported. When reTrade is unreachable and exits-only is on for the account, as the signed status says, a qualifying sell is placed instead, with reason exit; a refused one gives a reason starting exits-only:. See the 2.5-second rule and the checks on every answer.
Connect your broker
The SDK reaches your broker only through an adapter. GenericAdapter takes four functions, sync or async. Here myBroker stands for your own broker client.
import { BrokerRejected, GenericAdapter } from "retrade";
const broker = new GenericAdapter({
// Read the account now, from the broker, never from the agent.
// Amounts and prices are decimal strings (or bigint), never number.
async snapshot(instrument, adjustmentsSince) {
const account = await myBroker.account();
const snapshot = {
asOf: new Date(),
cash: { USD: account.cash },
settledCash: { USD: account.settledCash },
positions: account.positions.map((p) => ({
instrument: p.instrument, quantity: p.quantity, mark: p.mark,
})),
openOrders: account.openOrders.map((o) => ({
brokerOrderId: o.id, tag: o.clientOrderId || null, instrument: o.instrument,
side: o.side, remainingQty: o.remaining, type: o.type,
tif: o.tif, limitPrice: o.limitPrice,
})),
};
if (instrument === null) return { snapshot, quote: null };
const q = await myBroker.quote(instrument);
const quote = { instrument, last: q.last, bid: q.bid, ask: q.ask, asOf: q.time };
return { snapshot, quote };
},
// Place exactly these fields, with the tag as the client order id.
async submit(order, tag) {
try {
return await myBroker.place(order, { clientOrderId: tag });
} catch (error) {
if (error instanceof myBroker.Refused) throw new BrokerRejected(String(error));
throw error; // anything else: the outcome is unknown
}
},
async find(tag, order, since) {
const found = await myBroker.orderByClientId(tag);
return found ? found.id : null;
},
async cancel(brokerOrderId) {
await myBroker.cancel(brokerOrderId);
},
gSeconds: 60,
convertsCash: false,
});
| Function | Rules |
|---|---|
snapshot | Return { snapshot, quote }: the account with settled cash, and a quote when instrument isn’t null. Include the USDCAD rate (fx) when the broker has one, and the deposits, withdrawals, dividends, fees, transfers and splits since adjustmentsSince. |
submit | Place the ApprovedOrder and return the broker order id. Throw BrokerRejected only when the broker definitely refused it; anything else you throw means the outcome is unknown. The SDK never retries a submit. |
find | Look the order up by tag, or, where the broker doesn’t keep tags, match the order’s fields among orders placed since since. null never means “not placed”: the broker may not show it yet. |
cancel | Return once the broker has accepted the cancel; throw if it refused or the outcome is unknown. |
gSeconds (60 by default) is how long the broker takes to show a new order: a whole number of seconds from 1 to 3,600 (MAX_G_SECONDS), or GenericAdapter throws InvalidInput. convertsCash (false by default) says whether the broker converts currency on its own. Both must match the account’s settings at reTrade, or place throws ConfigError.
After the order
The SDK sends the submission report itself, once per ALLOW or CUT. One that doesn’t reach reTrade is kept and sent again by rt.sendPendingReports(), which returns how many went. A placement of unknown means the broker call failed in a way that doesn’t say whether the order went through, and the adapter couldn’t find it. Don’t place it again: the decision keeps counting until the account data shows the order or its absence.
// There's no modify: replace cancels, then asks for a new decision.
const replaced = await rt.replace(brokerOrderId, newOrder, {
account: "A1", agent: "agt_momentum", via: broker,
});
// Anyone may stop: an executor key can engage the kill switch.
// Only an admin key releases it (retrade-admin release).
const stopId = await rt.stop("tenant", { reason: "agent misbehaving" });
rt.stop takes "tenant", "account" (with account) or "agent" (with agent), and returns the stop’s id. The client refuses its own unused answers in the stop’s scope at once.
Errors
Every error the SDK throws is a ReTradeError: InvalidInput (a value it can’t send), ConfigError (a malformed key id or address, or an adapter whose settings differ from the account’s), RequestRefused (a 4xx other than 429), Unreachable, VerificationFailed (something claiming to be from reTrade, such as the key list or the signed status, failed a check; the SDK never acts on it), BrokerRejected (thrown by your adapter) and KeyFileError. They’re for your code to fix, never an answer about an order.
The claim kit
const evidence = rt.claimKit(result.decisionId); // an object, or null
retrade claim-kit <decision id> --state-dir retrade-state
The TypeScript SDK keeps the same claim kit as the Python SDK: the same SQLite file, claim-kit.sqlite3, with the same layout, and the same export format, retrade-claim-kit/1, so one checker reads both. It’s the evidence an insurer can review. Keep it for at least 90 days.
Values highlighted in angle brackets aren’t decided yet. Everything else on this page comes from the SDK’s source and the specification it’s built to, and may still change before release.