SKILL.md for AI coding agents
One file that teaches an AI coding agent to add reTrade’s pre-trade check to a trading bot you already have, in Python or TypeScript.
Use it
- Save it as
SKILL.mdin your coding agent’s skills folder, if it reads skills: for example.claude/skills/retrade-integration/SKILL.mdin your project. - Or point the agent at
https://retrade.app/SKILL.md, or paste the file into the chat. - Then ask it to add reTrade pre-trade checks to your bot. Have your bot key id, account ref and reTrade’s root public key ready: the file tells the agent to ask for them, never to invent them.
What it tells the agent
- Find every place the bot sends orders, and route each one through the SDK’s
place, with no other path to the broker left. - Install the pinned SDK, read the keys from environment variables and write the four broker functions.
- Handle the answers: never send a BLOCK, never top up a CUT, resend at most once at
fits, never resend an unknown placement. - Store each decision id, the receipt id, with the bot’s order record.
- Fail closed, check that with a test that needs no network, then send one order to the live API in test mode.
Its code is the same as on these pages, checked against retrade 0.1.2 from PyPI and npm.
The file
---
name: retrade-integration
description: Add reTrade pre-trade checks to an existing trading bot in Python or TypeScript, so every order the bot or its AI agent sends is checked first and is placed in full, cut to what fits, or blocked, with a signed receipt. Use when asked to integrate reTrade, add pre-trade checks, risk checks or guardrails to a trading bot or AI trading agent, or route orders through the `retrade` package from PyPI or npm.
---
# Add reTrade pre-trade checks to a trading bot
reTrade is an independent pre-trade check for AI trading. Before an order reaches the broker, the `retrade` SDK sends it to reTrade, which checks it against rules the user's admins set (position limits, order size, cash, restricted lists, dollar lines) and answers in the same request:
- **ALLOW**: the SDK places the order as sent.
- **CUT**: the SDK places the smaller quantity reTrade approved.
- **BLOCK**: nothing is placed. Over a dollar line or in an agent conflict, `fits` is the largest quantity that would pass.
There are no holds and no approval step. Every answer is signed and chained into a tamper-evident log, and the SDK keeps the evidence locally. No AI can change the rules.
The SDK places orders itself, through four broker functions you write (the adapter). So the integration is: route every order through `place`, and remove every other path to the broker.
## Rules for this task
- Every order the bot sends goes through `place`. Leave no other call to the broker's order API anywhere: not in retries, fallbacks, error handlers or scripts.
- Never send a BLOCK. Never top up a CUT: the SDK already placed the approved quantity.
- Fail closed. When reTrade can't answer, `place` returns a BLOCK; never catch that and place the order another way. If the client can't start, the bot must not trade.
- Never send an order again when its `placement` is `unknown`: the broker may have it.
- Don't invent keys, key ids, account refs or the root key. Ask the user for them.
- Don't change how the bot or its AI decides what to trade. Change only how orders are sent.
- Amounts are exact: `Decimal` in Python, decimal strings in TypeScript. Never floats or JS numbers.
## 1. Find every order path
Search the bot for every call that places, modifies or cancels an order at the broker (names like `place_order`, `submit_order`, `create_order`, `placeOrder`, `newOrder`), including retries, fallbacks, scripts and tests. List them for the user. Each new order must go through the gate in step 5. There is no modify: a change is a cancel, then a new decision (`replace` does both).
## 2. Install
- Python 3.12 or later: `pip install retrade==0.1.2`, and add `retrade==0.1.2` to the project's dependencies.
- Node 22.18 or later: `npm install retrade@0.1.2` (adds it to package.json).
Pin the version: 0.x versions may change the API between minor releases.
## 3. Configure keys with environment variables
| Variable | What it is | Where it comes from |
|---|---|---|
| `RETRADE_EXECUTOR_KEY_ID` | The bot key id, `exk_<mode>_<26 characters>` | The user's reTrade admin, after registering the key |
| `RETRADE_EXECUTOR_KEY_PATH` | Path to the executor key file, mode 0600 | Made with `retrade keygen` (below) |
| `RETRADE_ROOT_KEY` | reTrade's root public key, 43 characters of base64url | reTrade, when the tenant is set up |
| `RETRADE_ACCOUNT` | The account's ref in the tenant's register | The admin |
| `RETRADE_AGENT` | The id of the agent sending orders | The admin |
| `RETRADE_STATE_DIR` | Optional: where the claim kit lives (`retrade-state`) | You |
| `RETRADE_BASE_URL` | Optional: reTrade's address. The SDK reads this itself; default `https://api.retrade.app` | You |
Only `RETRADE_BASE_URL` is read by the SDK; the code below reads the others. Make the key on the machine that runs the bot:
```shell
retrade keygen executor.pem # Python
npx retrade keygen executor.pem # TypeScript
```
It prints the public half: give only that to the user for their admin, who registers it (`retrade-admin keys add-executor --mode test <public key>`) and returns the key id. Start with a `test` key. Add `executor.pem`, `retrade-state/` and `retrade-receipts.jsonl` to `.gitignore`. The SDK refuses a key file that others can read (`chmod 600 executor.pem`) and reads the key from a path, so mount it as a file in containers.
## 4. Write the broker adapter
Four functions over the bot's existing broker client (`my_broker` / `myBroker` below: map each call to the real client's API):
- `snapshot(instrument, adjustments_since)`: read the account now, from the broker, never from the AI. Return cash, settled cash, positions and open orders, and a fresh quote when `instrument` is given (a price older than 30 seconds is a BLOCK).
- `submit(order, tag)`: place exactly the `ApprovedOrder` given, with `tag` as the broker's client order id, and return the broker order id. Raise `BrokerRejected` only when the broker definitely refused it; let any other error through (the outcome is unknown). Never retry inside it.
- `find(tag, order, since)`: look the order up by tag; return its id, or `None`/`null` if not visible.
- `cancel(broker_order_id)`: cancel; return once the broker accepted it.
- `g_seconds` / `gSeconds` and `converts_cash` / `convertsCash` must equal the account's settings at reTrade (60 and false by default): ask the user.
- For a market buy of a stock the account doesn't hold, include a position of 0 shares with its current mark.
Python:
```python
from datetime import UTC, datetime
from decimal import Decimal
import retrade
def read_account(
instrument: str | None, adjustments_since: datetime | None
) -> tuple[retrade.Snapshot, retrade.Quote | None]:
# Read the account now, from the broker, never from the agent.
# Amounts and prices are Decimal (or int), never float:
# Decimal(str(x)) converts one.
account = my_broker.account()
snapshot = retrade.Snapshot(
as_of=datetime.now(UTC),
cash={"USD": Decimal(str(account.cash))},
settled_cash={"USD": Decimal(str(account.settled_cash))},
positions=tuple(
retrade.Position(p.symbol, p.quantity, Decimal(str(p.mark)))
for p in account.positions
),
open_orders=tuple(
retrade.OpenOrder(
o.id, o.client_order_id or None, o.symbol, o.side,
o.remaining, o.type, o.tif,
None if o.limit_price is None else Decimal(str(o.limit_price)),
)
for o in account.open_orders
),
)
if instrument is None:
return snapshot, None
q = my_broker.quote(instrument)
quote = retrade.Quote(instrument, Decimal(str(q.last)), Decimal(str(q.bid)),
Decimal(str(q.ask)), q.time)
return snapshot, quote
def submit(order: retrade.ApprovedOrder, tag: str) -> str:
# Place exactly these fields, with the tag as the client order id.
try:
return my_broker.place(order, client_order_id=tag)
except my_broker.Refused as error: # the broker definitely said no
raise retrade.BrokerRejected(str(error)) from error
# Any other error means the outcome is unknown: let it through.
def find(tag: str, order: retrade.ApprovedOrder, since: datetime) -> str | None:
found = my_broker.order_by_client_id(tag)
return found.id if found else None
def cancel(broker_order_id: str) -> None:
my_broker.cancel(broker_order_id)
broker = retrade.GenericAdapter(
snapshot=read_account, submit=submit, find=find, cancel=cancel,
g_seconds=60, # must equal the account's setting at reTrade
converts_cash=False, # likewise
)
```
TypeScript:
```typescript
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:
// String(x) converts one.
async snapshot(instrument, adjustmentsSince) {
const account = await myBroker.account();
const snapshot = {
asOf: new Date(),
cash: { USD: String(account.cash) },
settledCash: { USD: String(account.settledCash) },
positions: account.positions.map((p) => ({
instrument: p.symbol, quantity: p.quantity, mark: String(p.mark),
})),
openOrders: account.openOrders.map((o) => ({
brokerOrderId: o.id, tag: o.clientOrderId || null, instrument: o.symbol,
side: o.side, remainingQty: o.remaining, type: o.type, tif: o.tif,
limitPrice: o.limitPrice === null ? null : String(o.limitPrice),
})),
};
if (instrument === null) return { snapshot, quote: null };
const q = await myBroker.quote(instrument);
const quote = {
instrument, asOf: q.time,
last: String(q.last), bid: String(q.bid), ask: String(q.ask),
};
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) {
const found = await myBroker.orderByClientId(tag);
return found ? found.id : null;
},
async cancel(brokerOrderId) {
await myBroker.cancel(brokerOrderId);
},
gSeconds: 60, // must equal the account's setting at reTrade
convertsCash: false, // likewise
});
```
## 5. Add the order gate
Put this in its own module (`retrade_gate.py` or `retrade-gate.ts`). Every order path from step 1 calls `place_order` / `placeOrder` instead of the broker.
Python:
```python
"""reTrade's pre-trade check. Every order this bot sends goes through place_order()."""
import json
import os
from dataclasses import asdict, replace
from datetime import UTC, datetime
import retrade
RECEIPTS_FILE = "retrade-receipts.jsonl"
def open_retrade() -> retrade.Client:
"""The client, from the environment. If it can't start, the bot must not trade."""
return retrade.Client(
os.environ["RETRADE_EXECUTOR_KEY_ID"],
os.environ["RETRADE_EXECUTOR_KEY_PATH"],
root_key=os.environ["RETRADE_ROOT_KEY"],
state_dir=os.environ.get("RETRADE_STATE_DIR", "retrade-state"),
)
def place_order(rt: retrade.Client, broker: retrade.BrokerAdapter, order: retrade.Order,
account: str, agent: str) -> retrade.Result:
"""Ask reTrade, and place only what it approved. A BLOCK, or reTrade not answering, is a
result, never an exception. An exception (bad input or setup) means nothing was placed."""
result = rt.place(order, account=account, agent=agent, via=broker)
record_receipt(order, result)
if (result.outcome is retrade.Outcome.BLOCK and result.fits
and result.placement is retrade.Placement.NOT_PLACED):
# Over a dollar line or an agent conflict: resend once at the size that fits.
smaller = replace(order, quantity=result.fits)
result = rt.place(smaller, account=account, agent=agent, via=broker)
record_receipt(smaller, result)
return result
def record_receipt(order: retrade.Order, result: retrade.Result) -> None:
"""Keep the receipt id (decision_id) and the answer with the bot's own order record."""
row = {"at": datetime.now(UTC).isoformat(), "instrument": order.instrument,
"side": str(order.side), **asdict(result)}
with open(RECEIPTS_FILE, "a", encoding="utf-8") as file:
file.write(json.dumps(row) + "\n")
```
TypeScript:
```typescript
// reTrade's pre-trade check. Every order this bot sends goes through placeOrder().
import { appendFileSync } from "node:fs";
import { Client, type BrokerAdapter, type Order, type Result } from "retrade";
const RECEIPTS_FILE = "retrade-receipts.jsonl";
function env(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
}
/** The client, from the environment. If it can't start, the bot must not trade. */
export function openRetrade(): Promise<Client> {
return Client.open({
keyId: env("RETRADE_EXECUTOR_KEY_ID"),
keyPath: env("RETRADE_EXECUTOR_KEY_PATH"),
rootKey: env("RETRADE_ROOT_KEY"),
stateDir: process.env.RETRADE_STATE_DIR ?? "retrade-state",
});
}
/**
* Ask reTrade, and place only what it approved. A BLOCK, or reTrade not answering, is a
* result, never an exception. An exception (bad input or setup) means nothing was placed.
*/
export async function placeOrder(
rt: Client, broker: BrokerAdapter, order: Order, account: string, agent: string,
): Promise<Result> {
let result = await rt.place(order, { account, agent, via: broker });
recordReceipt(order, result);
const resizable = result.outcome === "BLOCK" && result.placement === "not_placed"
&& result.fits !== null && result.fits !== "0";
if (resizable) {
// Over a dollar line or an agent conflict: resend once at the size that fits.
const smaller = { ...order, quantity: Number(result.fits) };
result = await rt.place(smaller, { account, agent, via: broker });
recordReceipt(smaller, result);
}
return result;
}
/** Keep the receipt id (decisionId) and the answer with the bot's own order record. */
function recordReceipt(order: Order, result: Result): void {
const row = {
at: new Date().toISOString(), instrument: order.instrument, side: order.side, ...result,
};
appendFileSync(RECEIPTS_FILE, JSON.stringify(row) + "\n");
}
```
## 6. Handle ALLOW, CUT and BLOCK
Open the client once at start-up and pass it in. Branch on `placement` first (in shadow mode the original order is placed whatever the answer), then tell the bot's AI what happened.
Python:
```python
rt = open_retrade() # at start-up; if this raises, don't start trading
result = place_order(rt, broker, order,
os.environ["RETRADE_ACCOUNT"], os.environ["RETRADE_AGENT"])
if result.placement is retrade.Placement.PLACED:
track(result.broker_order_id, result.quantity) # on a CUT, less than asked
elif result.placement is retrade.Placement.UNKNOWN:
reconcile_from_broker(result) # the broker may have it: never send it again
else:
tell_agent(result.deciding or result.reason, result.fits) # nothing was placed
```
TypeScript:
```typescript
const rt = await openRetrade(); // at start-up; if this throws, don't start trading
const result = await placeOrder(rt, broker, order,
env("RETRADE_ACCOUNT"), env("RETRADE_AGENT"));
if (result.placement === "placed") {
track(result.brokerOrderId, result.quantity); // on a CUT, less than asked
} else if (result.placement === "unknown") {
reconcileFromBroker(result); // the broker may have it: never send it again
} else {
tellAgent(result.deciding ?? result.reason, result.fits); // nothing was placed
}
```
- ALLOW: placed as sent. CUT: placed at `quantity`, less than asked; don't send the rest. BLOCK: nothing placed; `deciding` names the rule (for example `{"code": "order_size_cap"}`), and `fits` is set over a dollar line or in an agent conflict.
- The gate resends once at `fits`, as a new decision. Never loop: resends count toward the agent's orders-per-minute limit.
- `place` raises only for code or setup problems (`InvalidInput`, `ConfigError`, `RequestRefused`) or when the adapter can't read the account, always before the broker call. Let these surface; don't retry by calling the broker.
## 7. Store the receipt ids
`decision_id` / `decisionId` is the receipt id. It's also in the broker's client order id (`rt1-<id>` in enforce, `rs1-` in shadow, `rx1-` in test). The gate appends every result to `retrade-receipts.jsonl`; if the bot has an order table or journal, store `decision_id`, `outcome`, `placement`, `quantity`, `deciding`, `fits`, `reason` and `broker_order_id` there too. The SDK keeps the signed evidence in `retrade-state/claim-kit.sqlite3`: keep it for at least 90 days. `retrade claim-kit <decision id> --state-dir retrade-state` prints one decision's evidence.
## 8. Fail closed
- If reTrade doesn't answer within 2.5 seconds, `place` returns BLOCK with `reason` `unreachable`, `local` true, and nothing is placed. An answer that fails a signature or request check gives `unverified`, also unplaced.
- If `open_retrade()` / `openRetrade()` raises at start-up (a wrong root key gives `VerificationFailed`, a refused key `RequestRefused`), the bot must not trade. If reTrade is only unreachable at start-up, the client starts and orders fail closed until it answers.
- Never resend when `placement` is `unknown`; reconcile from the broker's order list.
- Keep the clock in time (NTP): answers more than 60 seconds off are refused.
## 9. Test
Fail-closed test, no network or keys needed. Add it to the bot's tests and run it.
Python (pytest):
```python
from datetime import UTC, datetime
from decimal import Decimal
import retrade
from retrade_gate import place_order
def test_blocks_and_places_nothing_when_retrade_is_unreachable(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path) # the receipts file goes here
key = tmp_path / "executor.pem"
retrade.generate_key(key)
root = retrade.generate_key(tmp_path / "root.pem") # any valid public key: nothing is fetched
submitted: list[str] = []
def read_account(instrument, adjustments_since):
now = datetime.now(UTC)
snapshot = retrade.Snapshot(as_of=now, cash={"USD": Decimal("50000")},
settled_cash={"USD": Decimal("50000")},
positions=(), open_orders=())
if instrument is None:
return snapshot, None
return snapshot, retrade.Quote(instrument, Decimal("10"), Decimal("10"), Decimal("10"), now)
broker = retrade.GenericAdapter(
snapshot=read_account,
submit=lambda order, tag: submitted.append(tag) or "never",
find=lambda tag, order, since: None,
cancel=lambda broker_order_id: None,
)
# Nothing listens on port 9, so reTrade can't answer.
with retrade.Client("exk_test_01JB8Z6Q4M3T9V2K7X5R1C0N8D", key, root_key=root,
base_url="http://127.0.0.1:9", state_dir=tmp_path / "state") as rt:
order = retrade.Order("XNAS:AAPL", retrade.Side.BUY, 1, retrade.OrderType.LIMIT,
retrade.Tif.DAY, limit_price=Decimal("10"))
result = place_order(rt, broker, order, "A1", "agt_test")
assert result.outcome is retrade.Outcome.BLOCK
assert result.reason == "unreachable"
assert result.placement is retrade.Placement.NOT_PLACED
assert submitted == []
```
TypeScript (node:test, `node --test`):
```typescript
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { Client, GenericAdapter, generateKey } from "retrade";
import { placeOrder } from "./retrade-gate.ts";
test("blocks and places nothing when reTrade is unreachable", async () => {
const dir = mkdtempSync(join(tmpdir(), "retrade-"));
process.chdir(dir); // the receipts file goes here
const keyPath = join(dir, "executor.pem");
generateKey(keyPath);
const rootKey = generateKey(join(dir, "root.pem")); // any valid public key: nothing is fetched
const submitted: string[] = [];
const broker = new GenericAdapter({
snapshot(instrument) {
const asOf = new Date();
const snapshot = {
asOf, cash: { USD: "50000" }, settledCash: { USD: "50000" }, positions: [], openOrders: [],
};
const quote = instrument === null
? null
: { instrument, last: "10", bid: "10", ask: "10", asOf };
return { snapshot, quote };
},
submit(order, tag) {
submitted.push(tag);
return "never";
},
find: () => null,
cancel() {},
});
// Nothing listens on port 9, so reTrade can't answer.
const rt = await Client.open({
keyId: "exk_test_01JB8Z6Q4M3T9V2K7X5R1C0N8D", keyPath, rootKey,
baseUrl: "http://127.0.0.1:9", stateDir: join(dir, "state"),
});
try {
const order = {
instrument: "XNAS:AAPL", side: "buy", quantity: 1, type: "limit", tif: "day", limitPrice: "10",
} as const;
const result = await placeOrder(rt, broker, order, "A1", "agt_test");
assert.equal(result.outcome, "BLOCK");
assert.equal(result.reason, "unreachable");
assert.equal(result.placement, "not_placed");
assert.deepEqual(submitted, []);
} finally {
await rt.close();
}
});
```
Then against the live API, with the user's `test` key and a paper or practice broker account (live trading isn't switched on yet):
1. `curl -fsS https://api.retrade.app/healthz` answers `{"ok":true}`.
2. Start the bot. The client must open without an error.
3. Send one small order through the gate. Expect `local` false and a `decision_id`, then run `retrade claim-kit <decision id> --state-dir retrade-state`.
4. Send a buy worth more than the account's per-order line (5,000 for a new tenant, unless the admins set another): expect BLOCK, `deciding` `{"code": "approval_line", "reason": "per_order"}`, a `fits`, and the gate's one resend at `fits`.
## Checklist
- [ ] Every order path from step 1 goes through the gate; no direct broker order calls remain.
- [ ] `retrade==0.1.2` / `retrade@0.1.2` pinned in the project's dependencies.
- [ ] Keys come from environment variables; nothing secret is in the code or the repository.
- [ ] `executor.pem` is mode 0600 and, with `retrade-state/`, in `.gitignore`.
- [ ] The adapter reads the account and quote fresh from the broker, with exact decimals.
- [ ] `submit` raises `BrokerRejected` only on a definite refusal, and never retries.
- [ ] A BLOCK is never sent; a CUT is never topped up; `unknown` is never resent.
- [ ] At most one resend at `fits`.
- [ ] Each result's `decision_id` is stored with the bot's order record.
- [ ] The bot doesn't trade if the client can't start.
- [ ] The fail-closed test passes.
- [ ] One test-mode order against the live API returned a verified decision.
More: [the developer docs](https://retrade.app/docs.md), [the quickstart](https://retrade.app/docs/quickstart.md) and [all the docs in one file](https://retrade.app/llms-full.txt).
reTrade is pre-release. It is not an insurer, guarantees nothing and pays no losses: the signed records are evidence of what was asked, what reTrade answered and what was placed.
Every docs page is also plain Markdown: add .md to its address, as in /docs/quickstart.md, or use “Copy as Markdown for AI” at the top of the page. /llms.txt lists them all, and /llms-full.txt holds all of them in one file.