Python SDK

Install it, connect your broker, ask reTrade about each order and act on the signed answer, in Python.

In development. Not released yet; names and formats may change.

Install

Terminal
pip install retrade

The package is retrade on PyPI, and you import it as retrade. It needs Python 3.12 or later.

Make an executor key

Terminal
retrade keygen executor.pem

This writes a new Ed25519 private key to executor.pem, readable only by its owner, and prints its public half. It never overwrites a key file. Keep the private key secret: reTrade only ever sees the public half.

An admin registers the public half for one mode, with their own admin key, and gets back the key id, exk_<mode>_<ULID>. The key works at once. The admin command line is in development:

Terminal
# with the admin's --server, --key-id, --key and --root
# (or the RETRADE_* environment variables)
retrade-admin keys add-executor --mode shadow <public key>

Connect

Python
import retrade

rt = retrade.Client(
    key_id,  # "exk_<mode>_<ULID>", from registration
    "executor.pem",
    root_key="<reTrade root public key>",
    state_dir="retrade-state",  # where the claim kit lives
)

root_key is reTrade’s root public key. The SDK pins it and never fetches it; it checks the list of daily decision keys against it. It isn’t published yet. The SDK talks to reTrade’s hosted address, https://api.retrade.app, by default; pass base_url (or set RETRADE_BASE_URL) to use another.

The client signs every request for you. On start-up it 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; rt.refresh() fetches them again. If reTrade can’t be reached then, it still starts, and orders fail closed until reTrade answers. Call rt.close() when you’re done, or use the client in a with block. See keys and signatures for what’s signed.

Connect your broker

The SDK reaches your broker only through an adapter. retrade.GenericAdapter takes four plain functions. Here my_broker stands for your own broker client.

Python
from datetime import UTC, datetime
import retrade

def read_account(instrument, adjustments_since):
    # Read the account now, from the broker, never from the agent.
    # Amounts and prices are Decimal (or int), never float.
    account = my_broker.account()
    snapshot = retrade.Snapshot(
        as_of=datetime.now(UTC),
        cash={"USD": account.cash},
        settled_cash={"USD": account.settled_cash},
        positions=[retrade.Position(p.instrument, p.quantity, p.mark)
                   for p in account.positions],
        open_orders=[retrade.OpenOrder(o.id, o.client_order_id or None,
                                       o.instrument, o.side, o.remaining,
                                       o.type, o.tif, o.limit_price)
                     for o in account.open_orders],
    )
    if instrument is None:
        return snapshot, None
    q = my_broker.quote(instrument)
    return snapshot, retrade.Quote(instrument, q.last, q.bid, q.ask, q.time)

def submit(order, tag):
    # 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:
        raise retrade.BrokerRejected(str(error)) from error

def find(tag, order, since):
    found = my_broker.order_by_client_id(tag)
    return found.id if found else None

def cancel(broker_order_id):
    my_broker.cancel(broker_order_id)

broker = retrade.GenericAdapter(
    snapshot=read_account,
    submit=submit,
    find=find,
    cancel=cancel,
    g_seconds=60,
    converts_cash=False,
)
What each function must do
FunctionRules
snapshotReturn a Snapshot with settled cash, and a Quote when instrument is given. Include the USDCAD rate (fx) when the broker has one, and the deposits, withdrawals, dividends, fees, transfers and splits since adjustments_since.
submitPlace the ApprovedOrder and return the broker order id. Raise BrokerRejected only when the broker definitely refused it; anything else you raise means the outcome is unknown. The SDK never retries a submit.
findLook the order up by tag, or, where the broker doesn’t keep tags, match the order’s fields among orders placed since since. None never means “not placed”: the broker may not show it yet.
cancelReturn once the broker has accepted the cancel; raise if it refused or the outcome is unknown.

g_seconds (60 by default) is how long the broker takes to show a new order: a whole number of seconds from 1 to 3,600, or GenericAdapter raises InvalidInput. converts_cash (False by default) says whether it converts currency on its own. Both must match the account’s settings at reTrade, or place raises ConfigError.

Ask for a decision

Python
from decimal import Decimal

order = retrade.Order("XNAS:AAPL", "buy", 40, "limit", "day",
                      limit_price=Decimal("187.25"))
result = rt.place(order, account="A1", agent="agt_momentum",
                  via=broker)

Order(instrument, side, quantity, type, tif, limit_price=None, stop_price=None). The instrument is an exact listing id such as XNAS:AAPL; 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, never float. A stop price, the exit level a written-exit rule reads, must be above 0: Order raises InvalidInput for 0. The SDK works out from the account whether the order opens, adds to or reduces a position.

One call does the whole round, at once: reads the account, asks reTrade, checks the answer, places what was approved, reports it and keeps the evidence. Nothing waits for a person.

Act on the answer

place returns a Result. An answer is never an exception: a BLOCK, and reTrade not answering, both come back as a result. Check placement first: in shadow mode the agent’s order is placed whatever the answer.

Python
from retrade import Outcome, Placement

def report_to_agent(result):
    if result.placement is Placement.UNKNOWN:
        # The broker may have the order: never place it again.
        return {"status": "unknown", "why": result.reason}
    if result.placement is Placement.PLACED:
        return {"status": "placed", "quantity": result.quantity}
    if result.outcome is 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.
        why = result.deciding or result.reason
        return {"status": "blocked", "why": 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:

Python
from dataclasses import replace

if result.outcome is Outcome.BLOCK and result.fits:
    resized = rt.place(replace(order, quantity=result.fits),
                       account="A1", agent="agt_momentum", via=broker)
The fields of a Result
FieldWhat it holds
outcomeALLOW, CUT or BLOCK.
placementplaced, not_placed, or unknown when the broker call’s outcome is unknown.
quantityThe approved quantity on ALLOW or CUT, or what the SDK placed on its own; None for a BLOCK.
requested_quantityThe order’s own quantity.
decidingreTrade’s deciding rule, verbatim, such as {"code": "approval_line", "reason": "per_order"} (see reason codes); None for an ALLOW and for every local result.
fitsOn a BLOCK over a dollar line or in an agent conflict, the largest quantity that would pass (0 when none would); None otherwise.
flagsAlways empty: over a line the answer is BLOCK, not a flag.
alertsThe decision’s alerts, passed through.
reasonThe SDK’s own reason when it acted without or against an answer, such as unreachable, expired or broker-rejected.
decision_idThe decision’s id, also in the broker tag.
broker_order_idThe broker’s id for the order, when one was placed.
localTrue when the SDK decided without reTrade.
claimableTrue only for a placed enforce-mode decision: evidence an insurer can review. It promises no payment.

The fields are the TypeScript result’s, in Python’s spelling: requested_quantity for requestedQuantity, and so on. Quantities are int.

When reTrade doesn’t answer

reTrade has 2.5 seconds in total, including one retry with the same key and bytes. After that, in enforce and test, the SDK blocks the order itself (in shadow it places the original order, with reason shadow-unreachable):

Python
result.outcome    # Outcome.BLOCK
result.placement  # Placement.NOT_PLACED
result.reason     # "unreachable"
result.deciding   # None: no answer from reTrade decided this
result.local      # True
result.claimable  # False

An answer that fails a check (a bad signature, an unknown key, other request bytes, or order fields or a quantity that differ from the request) is treated the same way, with reason unverified: nothing is placed and nothing is reported. When reTrade is unreachable and an admin has turned on exits-only 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:, such as exits-only:over-held. Exits-only never applies to an unverified answer. See the 2.5-second rule.

After the order

The SDK sends the submission report itself. One that doesn’t reach reTrade is kept and sent again by rt.send_pending_reports(), which returns how many went.

A placement of unknown means the submit failed in a way that doesn’t say whether the broker took the order, and find couldn’t see it. Don’t place it again: the decision keeps counting until the account data shows the order or its absence.

Python
# There's no modify: replace cancels, then asks for a new decision.
result = rt.replace(broker_order_id, new_order, 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).
stop_id = 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.

The claim kit

Python
evidence = rt.claim_kit(result.decision_id)  # a dict, or None
Terminal
retrade claim-kit <decision id> --state-dir retrade-state

Both give one decision’s evidence (format retrade-claim-kit/1): the request bytes, the signed decision, the rules it names, the submission report with reTrade’s receipt, and the signed log heads received since. Byte fields are base64url, so they survive exactly. It’s the evidence an insurer can review. The file is retrade-state/claim-kit.sqlite3, the same layout the TypeScript SDK writes; keep it for at least 90 days.

Errors

Every error the SDK raises is a retrade.ReTradeError. They’re for your code to fix, never an answer about an order.

The SDK’s errors
ErrorWhen
InvalidInputA value the SDK can’t send, such as a malformed order.
ConfigErrorA malformed key id or server address, or an adapter whose settings differ from the account’s.
RequestRefusedreTrade refused a request as malformed or unauthorised (a 4xx other than 429).
BrokerRejectedRaised by your adapter’s submit when the broker definitely refused the order.
KeyFileErrorThe key file can’t be created or used.
UnreachableNo usable answer in time, from a call other than place, which blocks the order instead.
VerificationFailedSomething claiming to be from reTrade, such as the key list or the signed status, failed a check, outside place. The SDK never acts on it.