Metadata-Version: 2.4
Name: retrade
Version: 0.1.2
Summary: Python SDK for reTrade, a pre-trade check for AI trading agents: every order gets ALLOW, CUT or BLOCK with a signed receipt
Keywords: trading,pre-trade,risk,ai-agents,orders,broker,ed25519,receipts
Author: reTrade
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial :: Investment
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Dist: cryptography>=46,<47
Requires-Python: >=3.12
Project-URL: Homepage, https://retrade.app
Project-URL: Documentation, https://retrade.app/docs/sdk
Description-Content-Type: text/markdown

# retrade: the Python SDK for reTrade

reTrade is a pre-trade check for AI trading agents. Before your agent's order goes to your broker, the SDK sends it
to reTrade, which checks it against the rules your tenant's admins set, and answers at once:

- **ALLOW**: the SDK places the order as sent.
- **CUT**: the SDK places the smaller quantity reTrade approved.
- **BLOCK**: nothing is placed. When the order was over an approval line or clashed with another agent's order, the
  answer includes `fits`, the largest quantity that would pass.

Every answer is signed by reTrade and chained into your tenant's log. The SDK checks each signature before it acts,
places orders through your own broker connection (reTrade never connects to your broker), and keeps the signed records
in a local file.

**Status:** in development. This is version 0.1.2 of the SDK, built to the wire spec `retrade-arith/2.0-draft`, which
may still change: pin the version you test with. The SDK's default address is the hosted service,
`https://api.retrade.app`; to use a reTrade server you run instead, pass `base_url`
([SDK docs](https://retrade.app/docs/sdk)).

## Install

```
pip install retrade
```

Python 3.12 or later. The one dependency is `cryptography`. The package also installs the `retrade` command.

## What you need from reTrade

| What | Looks like | Who gives it to you |
|---|---|---|
| The server's address | `https://...`, or `http://127.0.0.1:8080` for a server on your machine | the server's operator |
| The root public key | 43 characters of base64url | the server's operator |
| Your bot key id | `exk_enforce_01M...` | your tenant's admin, after registering your executor key |
| Your executor key | a file such as `executor.pem` | you make it, and it never leaves your machine |

Make the executor key on the machine that will run your agent:

```
retrade keygen executor.pem
```

It writes the private key (file mode 0600) and prints its public half. Send only the public half to your tenant's
admin. The admin registers it with `retrade-admin keys add-executor --mode enforce <public key>` and sends you back the
`key_id` that command prints: that is your bot key id. The mode in the id (`enforce`, `shadow` or `test`) is the mode of
every order the key asks about. Keep `executor.pem` secret: anyone holding it can ask for decisions as your agent.

## Configure

```python
import retrade

rt = retrade.Client(
    "exk_enforce_01M...",            # your bot key id
    "executor.pem",                  # your executor key file
    root_key="<the root public key>",
    base_url="http://127.0.0.1:8080",
)
```

The server's address comes from, in this order: the `base_url` argument, then the `RETRADE_BASE_URL` environment
variable, then `retrade.DEFAULT_BASE_URL` (`https://api.retrade.app`, the hosted service). So you can leave `base_url` out
and run with `RETRADE_BASE_URL=http://127.0.0.1:8080`. Plain `http` is allowed only for a loopback address
(`127.0.0.1`, `localhost`, `::1`). The older keyword `server=` still works.

The root key is pinned: the SDK never fetches it. On start-up it fetches reTrade's list of signing keys and checks the
list against the root key. The claim kit (below) goes in `state_dir`, `retrade-state` by default.

## Connect your broker

The SDK reads the account and places orders through four functions you write against your broker's own API:

```python
from datetime import UTC, datetime
from decimal import Decimal


def read_account(instrument, adjustments_since):
    """Read the account from your broker now, and a quote for `instrument` when it is given."""
    now = datetime.now(UTC)
    snapshot = retrade.Snapshot(
        as_of=now,
        cash={"USD": Decimal("38380.90")},          # from your broker
        settled_cash={"USD": Decimal("38380.90")},  # from your broker
        positions=(retrade.Position("XNAS:AAPL", 81, Decimal("187.10")),),
        open_orders=(),
    )
    if instrument is None:
        return snapshot, None
    return snapshot, retrade.Quote(instrument, Decimal("187.10"), Decimal("187.08"), Decimal("187.12"), now)


broker = retrade.GenericAdapter(
    snapshot=read_account,
    submit=place_order,   # (retrade.ApprovedOrder, tag) -> the broker's order id
    find=find_order,      # (tag, retrade.ApprovedOrder, since) -> the broker's order id, or None
    cancel=cancel_order,  # (broker order id) -> None
    g_seconds=60,         # must equal the account's g_seconds at reTrade
    converts_cash=True,   # must equal the account's converts_cash at reTrade
)
```

`submit` places exactly the `ApprovedOrder` it is given, with `tag` in the broker's client-order-id field. It raises
`retrade.BrokerRejected` only when the broker definitely refused the order, and lets any other error through when the
outcome is unknown. The SDK never retries a submit.

## Submit an order

```python
from retrade import OrderType, Side, Tif

order = retrade.Order("XNAS:AAPL", Side.BUY, 40, OrderType.LIMIT, Tif.DAY, limit_price=Decimal("187.25"))
result = rt.place(order, account="A1", agent="agt_momentum", via=broker)
```

`account` is the account's ref in your tenant's account register. `agent` names the agent sending the order.
Instruments are `<MIC>:<symbol>`, such as `XNAS:AAPL`. Plain strings (`"buy"`, `"limit"`, `"day"`) and lists work
too; the enums and tuples are what a type checker accepts. The package ships its type hints (`py.typed`).

## Handle ALLOW, CUT and BLOCK

```python
if result.outcome == "ALLOW":
    print("placed as sent:", result.broker_order_id)
elif result.outcome == "CUT":
    print(f"placed {result.quantity} of {result.requested_quantity}:", result.deciding)
elif result.fits:
    # BLOCK at an approval line or an agent conflict, and a smaller order would pass. The resend is a new
    # decision and counts toward the agent's per-minute limit, so resend once, don't loop.
    smaller = retrade.Order("XNAS:AAPL", Side.BUY, result.fits, OrderType.LIMIT, Tif.DAY, limit_price=Decimal("187.25"))
    result = rt.place(smaller, account="A1", agent="agt_momentum", via=broker)
else:
    # BLOCK, nothing placed: reTrade's rule, or the SDK's own reason when it had no verified answer.
    print("blocked:", result.deciding or result.reason)
```

- Check `result.placement` as well: `placed`, `not_placed` (the broker refused it, or it was never sent), or `unknown`
  (the broker call's outcome isn't known yet; don't place the order again).
- `result.deciding` is reTrade's deciding rule, for example `{"code": "approval_line", "reason": "per_order"}` or
  `{"code": "order_size_cap"}`.
- `result.fits` is set only on a BLOCK at an approval line or an agent conflict. It is 0 when no size would pass.
- `result.local` is True when the SDK decided without a verified answer from reTrade, with `result.reason` saying why:
  for example `unreachable` (no answer within 2.5 s) or `unverified` (the answer failed a check). In enforce and test
  mode the order is then not placed.

## Verify receipts

The SDK checks every signed answer before it uses it:

- the key list must be signed by the root key you pinned;
- each decision must be signed by a listed key of the right environment, valid at the decision's time, and must
  answer the exact request bytes the SDK sent, with the same order fields, signed within 60 s of your clock;
- after the broker call, the SDK reports what happened and checks the signed receipt reTrade sends back.

An answer that fails any check is never placed (`result.reason == "unverified"`). Everything is kept in
`retrade-state/claim-kit.sqlite3`: the request bytes, the signed decision, the policy it was decided under, your
report and reTrade's receipt. To read the evidence for one decision:

```python
if result.decision_id is not None:  # None only when the SDK decided on its own
    evidence = rt.claim_kit(result.decision_id)
# {"format": "retrade-claim-kit/1", "decision_id": ..., "request": ..., "decision": ..., "policy": ...,
#  "report": ..., "receipt": ..., "heads": [...]}
```

or, on the command line, `retrade claim-kit <decision id> --state-dir retrade-state`. `decision` and `receipt` are
compact JWS documents (Ed25519). Anyone with the root public key can check them again against `GET /v1/keys`, as
sections 3 and 4 of reTrade's API reference describe ([docs](https://retrade.app/docs/sdk)). Keep the file for
at least 90 days. The TypeScript SDK reads and writes the same format.

To read your tenant's log for this key's mode, each record verified and chained to the one before it:

```python
records = rt.log()  # the whole log, from record 1: a list of retrade.LogRecord (seq, kind, hash, token, payload)
first20 = rt.log(from_seq=1, to_seq=20)  # records 1 to 20
```

reTrade sends the log a page at a time (at most 1,000 records and 512 KiB a page), and `log()` asks for one page after
another until it reaches `to_seq` or the end of the log. Each page has its own 2.5 s deadline. The records it returns
are held in memory, so read a very long log in ranges.

reTrade offers no guarantee and pays no losses. The signed records are evidence of what was asked, what reTrade
answered and what was placed, nothing more.

## Status

- In development. 0.x versions may change the API between minor releases.
- Wire spec: `retrade-arith/2.0-draft`.
- The default address is the hosted service at `https://api.retrade.app`; pass `base_url` to use your own server.
- Not built on the server yet: snapshots sent with no order (`keep_fresh`, `send_snapshot`; the server answers 404,
  which the SDK only logs) and webhooks.

---

## Reference

### What `place` returns

`retrade.Result`, with these fields:

| Field | Meaning |
|---|---|
| `outcome` | `ALLOW`, `CUT` or `BLOCK` (`retrade.Outcome`) |
| `placement` | `placed`, `not_placed` or `unknown` (`retrade.Placement`); `unknown` means the broker call's outcome isn't known yet, so don't place the order again |
| `quantity` | the approved quantity (ALLOW, CUT), or what the SDK placed on its own; None for a BLOCK |
| `requested_quantity` | the order's quantity |
| `deciding` | reTrade's deciding rule, as reTrade sent it, for example `{"code": "approval_line", "reason": "per_order"}`; None for an ALLOW and for every result the SDK made itself |
| `fits` | on a BLOCK at an approval line or an agent conflict, the largest quantity that would pass (0 when none would); otherwise None |
| `flags` | the approval-line and agent-conflict codes reTrade listed on an ALLOW or CUT; empty while those always BLOCK |
| `alerts` | reTrade's alerts, passed through |
| `reason` | the SDK's own reason when it acted without or against an answer (below) |
| `decision_id`, `broker_order_id` | the decision and the broker's order id |
| `local` | True when the SDK decided without a verified answer from reTrade |
| `claimable` | True when the decision was made in enforce mode and the order was placed; it marks evidence only, never a promise of payment |

The SDK's own reasons: `unreachable` (no answer in 2.5 s), `unverified` (the answer failed a check, including one
whose order fields differ from what was sent), `exits-only:<why>` (exits-only refused the sell), `exit` (exits-only
placed it), `shadow-unreachable` and `shadow-unverified`, `expired` and `stopped` (an approved order withdrawn before
the broker call), `already-used`, `broker-rejected` and `broker-outcome-unknown`.

### Other calls

- `rt.replace(broker_order_id, order, account=..., agent=..., via=...)`: cancel a working order, then decide on the new
  one.
- `rt.stop(scope, account=None, agent=None, reason=...)`: engage a stop (`tenant`, `account` or `agent`). Any
  executor key can stop; only an admin key releases it.
- `rt.cancel_open_orders(via, all_orders=False)`, `rt.send_pending_reports()`, `rt.refresh()`, `rt.send_snapshot(...)`,
  `rt.keep_fresh(...)`, `rt.log(from_seq=None, to_seq=None)`, `rt.claim_kit(decision_id)` and `rt.close()`. A `Client`
  is also a context manager.
- `rt.keep_fresh({"A1": broker})`, every 5 minutes on a background thread until `rt.close()`: refreshes stops and
  account settings, and sends each account's snapshot.
- `retrade claim-kit DECISION_ID --state-dir DIR` prints the evidence for one decision.

### What the SDK does on your side

- Nothing reaches your broker that reTrade didn't approve (in enforce and test mode), and never more than the approved
  quantity. A decision is placed at most once.
- If reTrade doesn't answer within 2.5 s, the order is blocked (or, with exits-only on, a qualifying sell goes ahead).
- reTrade's status (stops and account settings, exits-only included) is used only after its signature and freshness
  are checked.
- In shadow mode, your agent's orders are placed unchanged; reTrade only records what it would have done.
- The dollar lines are your tenant's policy, never constants in the SDK. A new tenant starts with an approval line of
  5,000 per order and 10,000 per day, which its admins change with `retrade-admin policy set`. The one fixed number is
  exits-only's fallback cap: when the last rules this SDK decided under have no `approval_line`, an exits-only sell is
  held to 5,000 per order in the account's currency.

### Exits-only, ids and submits

1. **Exits-only** (an admin turns it on; it applies only while reTrade can't be reached): a limit sell of shares the
   broker holds now, at most held minus sells already working and this SDK's own recent sells the broker may not show
   yet (its exits, and sells reTrade approved), no more than 10% below last, of no company on the won't-sell list,
   within the per-order approval line (5,000 when the last rules have none), and with no stop known for the account or
   the agent. Everything else is refused.
2. **Ids and tags**: a decision id is a ULID; the broker tag is `rt1-<id>` (enforce), `rs1-<id>` (shadow) or
   `rx1-<id>` (test), 30 characters. A broker whose client-order-id holds fewer characters would need a shorter form.
3. **A submit is never retried.** When the outcome is unknown, the SDK looks the order up by its tag; if it still
   can't tell, the decision keeps counting at reTrade until a snapshot shows the order or its absence.

The wire formats are in reTrade's API reference ([docs](https://retrade.app/docs/sdk)).

### Development

```
uv sync
uv run python -m pytest -q
uv run ruff check .
uv run mypy
```

The server end-to-end test (`tests/test_server_e2e.py`) is skipped unless a server is running; the Rust test
`server/tests/sdk_e2e.rs` starts one and runs it.

## License

MIT. See the `LICENSE` file in this package.
