retrade 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- retrade-0.1.1/LICENSE +21 -0
- retrade-0.1.1/PKG-INFO +301 -0
- retrade-0.1.1/README.md +275 -0
- retrade-0.1.1/pyproject.toml +52 -0
- retrade-0.1.1/src/retrade/__init__.py +66 -0
- retrade-0.1.1/src/retrade/adapter.py +99 -0
- retrade-0.1.1/src/retrade/cli.py +55 -0
- retrade-0.1.1/src/retrade/client.py +880 -0
- retrade-0.1.1/src/retrade/errors.py +46 -0
- retrade-0.1.1/src/retrade/keys.py +77 -0
- retrade-0.1.1/src/retrade/kit.py +204 -0
- retrade-0.1.1/src/retrade/model.py +450 -0
- retrade-0.1.1/src/retrade/protocol.py +378 -0
- retrade-0.1.1/src/retrade/py.typed +0 -0
- retrade-0.1.1/src/retrade/transport.py +163 -0
- retrade-0.1.1/src/retrade/webhook.py +34 -0
retrade-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 reTrade
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
retrade-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: retrade
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for reTrade, a pre-trade check for AI trading agents: every order gets ALLOW, CUT or BLOCK with a signed receipt
|
|
5
|
+
Keywords: trading,pre-trade,risk,ai-agents,orders,broker,ed25519,receipts
|
|
6
|
+
Author: reTrade
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: cryptography>=46,<47
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Project-URL: Homepage, https://retrade.app
|
|
23
|
+
Project-URL: Repository, https://github.com/lance-lii/reTrade-real-app
|
|
24
|
+
Project-URL: Source, https://github.com/lance-lii/reTrade-real-app/tree/main/sdk-python
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# retrade: the Python SDK for reTrade
|
|
28
|
+
|
|
29
|
+
reTrade is a pre-trade check for AI trading agents. Before your agent's order goes to your broker, the SDK sends it
|
|
30
|
+
to reTrade, which checks it against the rules your tenant's admins set, and answers at once:
|
|
31
|
+
|
|
32
|
+
- **ALLOW**: the SDK places the order as sent.
|
|
33
|
+
- **CUT**: the SDK places the smaller quantity reTrade approved.
|
|
34
|
+
- **BLOCK**: nothing is placed. When the order was over an approval line or clashed with another agent's order, the
|
|
35
|
+
answer includes `fits`, the largest quantity that would pass.
|
|
36
|
+
|
|
37
|
+
Every answer is signed by reTrade and chained into your tenant's log. The SDK checks each signature before it acts,
|
|
38
|
+
places orders through your own broker connection (reTrade never connects to your broker), and keeps the signed records
|
|
39
|
+
in a local file.
|
|
40
|
+
|
|
41
|
+
**Status:** in development. This is version 0.1.1 of the SDK, built to the wire spec `retrade-arith/2.0-draft`, which
|
|
42
|
+
may still change: pin the version you test with. The SDK's default address is the hosted service,
|
|
43
|
+
`https://api.retrade.app`; to use a reTrade server you run instead, pass `base_url`
|
|
44
|
+
([how to run one](https://github.com/lance-lii/reTrade-real-app#run-the-server-locally)).
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install retrade
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.12 or later. The one dependency is `cryptography`. The package also installs the `retrade` command.
|
|
53
|
+
|
|
54
|
+
## What you need from reTrade
|
|
55
|
+
|
|
56
|
+
| What | Looks like | Who gives it to you |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| The server's address | `https://...`, or `http://127.0.0.1:8080` for a server on your machine | the server's operator |
|
|
59
|
+
| The root public key | 43 characters of base64url | the server's operator |
|
|
60
|
+
| Your bot key id | `exk_enforce_01M...` | your tenant's admin, after registering your executor key |
|
|
61
|
+
| Your executor key | a file such as `executor.pem` | you make it, and it never leaves your machine |
|
|
62
|
+
|
|
63
|
+
Make the executor key on the machine that will run your agent:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
retrade keygen executor.pem
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
It writes the private key (file mode 0600) and prints its public half. Send only the public half to your tenant's
|
|
70
|
+
admin. The admin registers it with `retrade-admin keys add-executor --mode enforce <public key>` and sends you back the
|
|
71
|
+
`key_id` that command prints: that is your bot key id. The mode in the id (`enforce`, `shadow` or `test`) is the mode of
|
|
72
|
+
every order the key asks about. Keep `executor.pem` secret: anyone holding it can ask for decisions as your agent.
|
|
73
|
+
|
|
74
|
+
## Configure
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import retrade
|
|
78
|
+
|
|
79
|
+
rt = retrade.Client(
|
|
80
|
+
"exk_enforce_01M...", # your bot key id
|
|
81
|
+
"executor.pem", # your executor key file
|
|
82
|
+
root_key="<the root public key>",
|
|
83
|
+
base_url="http://127.0.0.1:8080",
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The server's address comes from, in this order: the `base_url` argument, then the `RETRADE_BASE_URL` environment
|
|
88
|
+
variable, then `retrade.DEFAULT_BASE_URL` (`https://api.retrade.app`, the hosted service). So you can leave `base_url` out
|
|
89
|
+
and run with `RETRADE_BASE_URL=http://127.0.0.1:8080`. Plain `http` is allowed only for a loopback address
|
|
90
|
+
(`127.0.0.1`, `localhost`, `::1`). The older keyword `server=` still works.
|
|
91
|
+
|
|
92
|
+
The root key is pinned: the SDK never fetches it. On start-up it fetches reTrade's list of signing keys and checks the
|
|
93
|
+
list against the root key. The claim kit (below) goes in `state_dir`, `retrade-state` by default.
|
|
94
|
+
|
|
95
|
+
## Connect your broker
|
|
96
|
+
|
|
97
|
+
The SDK reads the account and places orders through four functions you write against your broker's own API:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from datetime import UTC, datetime
|
|
101
|
+
from decimal import Decimal
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_account(instrument, adjustments_since):
|
|
105
|
+
"""Read the account from your broker now, and a quote for `instrument` when it is given."""
|
|
106
|
+
now = datetime.now(UTC)
|
|
107
|
+
snapshot = retrade.Snapshot(
|
|
108
|
+
as_of=now,
|
|
109
|
+
cash={"USD": Decimal("38380.90")}, # from your broker
|
|
110
|
+
settled_cash={"USD": Decimal("38380.90")}, # from your broker
|
|
111
|
+
positions=(retrade.Position("XNAS:AAPL", 81, Decimal("187.10")),),
|
|
112
|
+
open_orders=(),
|
|
113
|
+
)
|
|
114
|
+
if instrument is None:
|
|
115
|
+
return snapshot, None
|
|
116
|
+
return snapshot, retrade.Quote(instrument, Decimal("187.10"), Decimal("187.08"), Decimal("187.12"), now)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
broker = retrade.GenericAdapter(
|
|
120
|
+
snapshot=read_account,
|
|
121
|
+
submit=place_order, # (retrade.ApprovedOrder, tag) -> the broker's order id
|
|
122
|
+
find=find_order, # (tag, retrade.ApprovedOrder, since) -> the broker's order id, or None
|
|
123
|
+
cancel=cancel_order, # (broker order id) -> None
|
|
124
|
+
g_seconds=60, # must equal the account's g_seconds at reTrade
|
|
125
|
+
converts_cash=True, # must equal the account's converts_cash at reTrade
|
|
126
|
+
)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`submit` places exactly the `ApprovedOrder` it is given, with `tag` in the broker's client-order-id field. It raises
|
|
130
|
+
`retrade.BrokerRejected` only when the broker definitely refused the order, and lets any other error through when the
|
|
131
|
+
outcome is unknown. The SDK never retries a submit.
|
|
132
|
+
|
|
133
|
+
## Submit an order
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from retrade import OrderType, Side, Tif
|
|
137
|
+
|
|
138
|
+
order = retrade.Order("XNAS:AAPL", Side.BUY, 40, OrderType.LIMIT, Tif.DAY, limit_price=Decimal("187.25"))
|
|
139
|
+
result = rt.place(order, account="A1", agent="agt_momentum", via=broker)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`account` is the account's ref in your tenant's account register. `agent` names the agent sending the order.
|
|
143
|
+
Instruments are `<MIC>:<symbol>`, such as `XNAS:AAPL`. Plain strings (`"buy"`, `"limit"`, `"day"`) and lists work
|
|
144
|
+
too; the enums and tuples are what a type checker accepts. The package ships its type hints (`py.typed`).
|
|
145
|
+
|
|
146
|
+
## Handle ALLOW, CUT and BLOCK
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
if result.outcome == "ALLOW":
|
|
150
|
+
print("placed as sent:", result.broker_order_id)
|
|
151
|
+
elif result.outcome == "CUT":
|
|
152
|
+
print(f"placed {result.quantity} of {result.requested_quantity}:", result.deciding)
|
|
153
|
+
elif result.fits:
|
|
154
|
+
# BLOCK at an approval line or an agent conflict, and a smaller order would pass. The resend is a new
|
|
155
|
+
# decision and counts toward the agent's per-minute limit, so resend once, don't loop.
|
|
156
|
+
smaller = retrade.Order("XNAS:AAPL", Side.BUY, result.fits, OrderType.LIMIT, Tif.DAY, limit_price=Decimal("187.25"))
|
|
157
|
+
result = rt.place(smaller, account="A1", agent="agt_momentum", via=broker)
|
|
158
|
+
else:
|
|
159
|
+
# BLOCK, nothing placed: reTrade's rule, or the SDK's own reason when it had no verified answer.
|
|
160
|
+
print("blocked:", result.deciding or result.reason)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- Check `result.placement` as well: `placed`, `not_placed` (the broker refused it, or it was never sent), or `unknown`
|
|
164
|
+
(the broker call's outcome isn't known yet; don't place the order again).
|
|
165
|
+
- `result.deciding` is reTrade's deciding rule, for example `{"code": "approval_line", "reason": "per_order"}` or
|
|
166
|
+
`{"code": "order_size_cap"}`.
|
|
167
|
+
- `result.fits` is set only on a BLOCK at an approval line or an agent conflict. It is 0 when no size would pass.
|
|
168
|
+
- `result.local` is True when the SDK decided without a verified answer from reTrade, with `result.reason` saying why:
|
|
169
|
+
for example `unreachable` (no answer within 2.5 s) or `unverified` (the answer failed a check). In enforce and test
|
|
170
|
+
mode the order is then not placed.
|
|
171
|
+
|
|
172
|
+
## Verify receipts
|
|
173
|
+
|
|
174
|
+
The SDK checks every signed answer before it uses it:
|
|
175
|
+
|
|
176
|
+
- the key list must be signed by the root key you pinned;
|
|
177
|
+
- each decision must be signed by a listed key of the right environment, valid at the decision's time, and must
|
|
178
|
+
answer the exact request bytes the SDK sent, with the same order fields, signed within 60 s of your clock;
|
|
179
|
+
- after the broker call, the SDK reports what happened and checks the signed receipt reTrade sends back.
|
|
180
|
+
|
|
181
|
+
An answer that fails any check is never placed (`result.reason == "unverified"`). Everything is kept in
|
|
182
|
+
`retrade-state/claim-kit.sqlite3`: the request bytes, the signed decision, the policy it was decided under, your
|
|
183
|
+
report and reTrade's receipt. To read the evidence for one decision:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
if result.decision_id is not None: # None only when the SDK decided on its own
|
|
187
|
+
evidence = rt.claim_kit(result.decision_id)
|
|
188
|
+
# {"format": "retrade-claim-kit/1", "decision_id": ..., "request": ..., "decision": ..., "policy": ...,
|
|
189
|
+
# "report": ..., "receipt": ..., "heads": [...]}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
or, on the command line, `retrade claim-kit <decision id> --state-dir retrade-state`. `decision` and `receipt` are
|
|
193
|
+
compact JWS documents (Ed25519). Anyone with the root public key can check them again against `GET /v1/keys`, as
|
|
194
|
+
[API.md sections 3 and 4](https://github.com/lance-lii/reTrade-real-app/blob/main/API.md) describe. Keep the file for
|
|
195
|
+
at least 90 days. The TypeScript SDK reads and writes the same format.
|
|
196
|
+
|
|
197
|
+
To read your tenant's log for this key's mode, each record verified and chained to the one before it:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
records = rt.log() # the whole log, from record 1: a list of retrade.LogRecord (seq, kind, hash, token, payload)
|
|
201
|
+
first20 = rt.log(from_seq=1, to_seq=20) # records 1 to 20
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
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
|
|
205
|
+
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
|
|
206
|
+
are held in memory, so read a very long log in ranges.
|
|
207
|
+
|
|
208
|
+
reTrade offers no guarantee and pays no losses. The signed records are evidence of what was asked, what reTrade
|
|
209
|
+
answered and what was placed, nothing more.
|
|
210
|
+
|
|
211
|
+
## Status
|
|
212
|
+
|
|
213
|
+
- In development. 0.x versions may change the API between minor releases.
|
|
214
|
+
- Wire spec: `retrade-arith/2.0-draft`.
|
|
215
|
+
- The default address is the hosted service at `https://api.retrade.app`; pass `base_url` to use your own server.
|
|
216
|
+
- Not built on the server yet: snapshots sent with no order (`keep_fresh`, `send_snapshot`; the server answers 404,
|
|
217
|
+
which the SDK only logs) and webhooks.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Reference
|
|
222
|
+
|
|
223
|
+
### What `place` returns
|
|
224
|
+
|
|
225
|
+
`retrade.Result`, with these fields:
|
|
226
|
+
|
|
227
|
+
| Field | Meaning |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `outcome` | `ALLOW`, `CUT` or `BLOCK` (`retrade.Outcome`) |
|
|
230
|
+
| `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 |
|
|
231
|
+
| `quantity` | the approved quantity (ALLOW, CUT), or what the SDK placed on its own; None for a BLOCK |
|
|
232
|
+
| `requested_quantity` | the order's quantity |
|
|
233
|
+
| `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 |
|
|
234
|
+
| `fits` | on a BLOCK at an approval line or an agent conflict, the largest quantity that would pass (0 when none would); otherwise None |
|
|
235
|
+
| `flags` | the approval-line and agent-conflict codes reTrade listed on an ALLOW or CUT; empty while those always BLOCK |
|
|
236
|
+
| `alerts` | reTrade's alerts, passed through |
|
|
237
|
+
| `reason` | the SDK's own reason when it acted without or against an answer (below) |
|
|
238
|
+
| `decision_id`, `broker_order_id` | the decision and the broker's order id |
|
|
239
|
+
| `local` | True when the SDK decided without a verified answer from reTrade |
|
|
240
|
+
| `claimable` | True when the decision was made in enforce mode and the order was placed; it marks evidence only, never a promise of payment |
|
|
241
|
+
|
|
242
|
+
The SDK's own reasons: `unreachable` (no answer in 2.5 s), `unverified` (the answer failed a check, including one
|
|
243
|
+
whose order fields differ from what was sent), `exits-only:<why>` (exits-only refused the sell), `exit` (exits-only
|
|
244
|
+
placed it), `shadow-unreachable` and `shadow-unverified`, `expired` and `stopped` (an approved order withdrawn before
|
|
245
|
+
the broker call), `already-used`, `broker-rejected` and `broker-outcome-unknown`.
|
|
246
|
+
|
|
247
|
+
### Other calls
|
|
248
|
+
|
|
249
|
+
- `rt.replace(broker_order_id, order, account=..., agent=..., via=...)`: cancel a working order, then decide on the new
|
|
250
|
+
one.
|
|
251
|
+
- `rt.stop(scope, account=None, agent=None, reason=...)`: engage a stop (`tenant`, `account` or `agent`). Any
|
|
252
|
+
executor key can stop; only an admin key releases it.
|
|
253
|
+
- `rt.cancel_open_orders(via, all_orders=False)`, `rt.send_pending_reports()`, `rt.refresh()`, `rt.send_snapshot(...)`,
|
|
254
|
+
`rt.keep_fresh(...)`, `rt.log(from_seq=None, to_seq=None)`, `rt.claim_kit(decision_id)` and `rt.close()`. A `Client`
|
|
255
|
+
is also a context manager.
|
|
256
|
+
- `rt.keep_fresh({"A1": broker})`, every 5 minutes on a background thread until `rt.close()`: refreshes stops and
|
|
257
|
+
account settings, and sends each account's snapshot.
|
|
258
|
+
- `retrade claim-kit DECISION_ID --state-dir DIR` prints the evidence for one decision.
|
|
259
|
+
|
|
260
|
+
### What the SDK does on your side
|
|
261
|
+
|
|
262
|
+
- Nothing reaches your broker that reTrade didn't approve (in enforce and test mode), and never more than the approved
|
|
263
|
+
quantity. A decision is placed at most once.
|
|
264
|
+
- If reTrade doesn't answer within 2.5 s, the order is blocked (or, with exits-only on, a qualifying sell goes ahead).
|
|
265
|
+
- reTrade's status (stops and account settings, exits-only included) is used only after its signature and freshness
|
|
266
|
+
are checked.
|
|
267
|
+
- In shadow mode, your agent's orders are placed unchanged; reTrade only records what it would have done.
|
|
268
|
+
- The dollar lines are your tenant's policy, never constants in the SDK. A new tenant starts with an approval line of
|
|
269
|
+
5,000 per order and 10,000 per day, which its admins change with `retrade-admin policy set`. The one fixed number is
|
|
270
|
+
exits-only's fallback cap: when the last rules this SDK decided under have no `approval_line`, an exits-only sell is
|
|
271
|
+
held to 5,000 per order in the account's currency.
|
|
272
|
+
|
|
273
|
+
### Exits-only, ids and submits
|
|
274
|
+
|
|
275
|
+
1. **Exits-only** (an admin turns it on; it applies only while reTrade can't be reached): a limit sell of shares the
|
|
276
|
+
broker holds now, at most held minus sells already working and this SDK's own recent sells the broker may not show
|
|
277
|
+
yet (its exits, and sells reTrade approved), no more than 10% below last, of no company on the won't-sell list,
|
|
278
|
+
within the per-order approval line (5,000 when the last rules have none), and with no stop known for the account or
|
|
279
|
+
the agent. Everything else is refused.
|
|
280
|
+
2. **Ids and tags**: a decision id is a ULID; the broker tag is `rt1-<id>` (enforce), `rs1-<id>` (shadow) or
|
|
281
|
+
`rx1-<id>` (test), 30 characters. A broker whose client-order-id holds fewer characters would need a shorter form.
|
|
282
|
+
3. **A submit is never retried.** When the outcome is unknown, the SDK looks the order up by its tag; if it still
|
|
283
|
+
can't tell, the decision keeps counting at reTrade until a snapshot shows the order or its absence.
|
|
284
|
+
|
|
285
|
+
The wire formats are in [API.md](https://github.com/lance-lii/reTrade-real-app/blob/main/API.md).
|
|
286
|
+
|
|
287
|
+
### Development
|
|
288
|
+
|
|
289
|
+
```
|
|
290
|
+
uv sync
|
|
291
|
+
uv run python -m pytest -q
|
|
292
|
+
uv run ruff check .
|
|
293
|
+
uv run mypy
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The server end-to-end test (`tests/test_server_e2e.py`) is skipped unless a server is running; the Rust test
|
|
297
|
+
`server/tests/sdk_e2e.rs` starts one and runs it.
|
|
298
|
+
|
|
299
|
+
## License
|
|
300
|
+
|
|
301
|
+
MIT. See [LICENSE](https://github.com/lance-lii/reTrade-real-app/blob/main/sdk-python/LICENSE).
|
retrade-0.1.1/README.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# retrade: the Python SDK for reTrade
|
|
2
|
+
|
|
3
|
+
reTrade is a pre-trade check for AI trading agents. Before your agent's order goes to your broker, the SDK sends it
|
|
4
|
+
to reTrade, which checks it against the rules your tenant's admins set, and answers at once:
|
|
5
|
+
|
|
6
|
+
- **ALLOW**: the SDK places the order as sent.
|
|
7
|
+
- **CUT**: the SDK places the smaller quantity reTrade approved.
|
|
8
|
+
- **BLOCK**: nothing is placed. When the order was over an approval line or clashed with another agent's order, the
|
|
9
|
+
answer includes `fits`, the largest quantity that would pass.
|
|
10
|
+
|
|
11
|
+
Every answer is signed by reTrade and chained into your tenant's log. The SDK checks each signature before it acts,
|
|
12
|
+
places orders through your own broker connection (reTrade never connects to your broker), and keeps the signed records
|
|
13
|
+
in a local file.
|
|
14
|
+
|
|
15
|
+
**Status:** in development. This is version 0.1.1 of the SDK, built to the wire spec `retrade-arith/2.0-draft`, which
|
|
16
|
+
may still change: pin the version you test with. The SDK's default address is the hosted service,
|
|
17
|
+
`https://api.retrade.app`; to use a reTrade server you run instead, pass `base_url`
|
|
18
|
+
([how to run one](https://github.com/lance-lii/reTrade-real-app#run-the-server-locally)).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pip install retrade
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python 3.12 or later. The one dependency is `cryptography`. The package also installs the `retrade` command.
|
|
27
|
+
|
|
28
|
+
## What you need from reTrade
|
|
29
|
+
|
|
30
|
+
| What | Looks like | Who gives it to you |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| The server's address | `https://...`, or `http://127.0.0.1:8080` for a server on your machine | the server's operator |
|
|
33
|
+
| The root public key | 43 characters of base64url | the server's operator |
|
|
34
|
+
| Your bot key id | `exk_enforce_01M...` | your tenant's admin, after registering your executor key |
|
|
35
|
+
| Your executor key | a file such as `executor.pem` | you make it, and it never leaves your machine |
|
|
36
|
+
|
|
37
|
+
Make the executor key on the machine that will run your agent:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
retrade keygen executor.pem
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It writes the private key (file mode 0600) and prints its public half. Send only the public half to your tenant's
|
|
44
|
+
admin. The admin registers it with `retrade-admin keys add-executor --mode enforce <public key>` and sends you back the
|
|
45
|
+
`key_id` that command prints: that is your bot key id. The mode in the id (`enforce`, `shadow` or `test`) is the mode of
|
|
46
|
+
every order the key asks about. Keep `executor.pem` secret: anyone holding it can ask for decisions as your agent.
|
|
47
|
+
|
|
48
|
+
## Configure
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import retrade
|
|
52
|
+
|
|
53
|
+
rt = retrade.Client(
|
|
54
|
+
"exk_enforce_01M...", # your bot key id
|
|
55
|
+
"executor.pem", # your executor key file
|
|
56
|
+
root_key="<the root public key>",
|
|
57
|
+
base_url="http://127.0.0.1:8080",
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The server's address comes from, in this order: the `base_url` argument, then the `RETRADE_BASE_URL` environment
|
|
62
|
+
variable, then `retrade.DEFAULT_BASE_URL` (`https://api.retrade.app`, the hosted service). So you can leave `base_url` out
|
|
63
|
+
and run with `RETRADE_BASE_URL=http://127.0.0.1:8080`. Plain `http` is allowed only for a loopback address
|
|
64
|
+
(`127.0.0.1`, `localhost`, `::1`). The older keyword `server=` still works.
|
|
65
|
+
|
|
66
|
+
The root key is pinned: the SDK never fetches it. On start-up it fetches reTrade's list of signing keys and checks the
|
|
67
|
+
list against the root key. The claim kit (below) goes in `state_dir`, `retrade-state` by default.
|
|
68
|
+
|
|
69
|
+
## Connect your broker
|
|
70
|
+
|
|
71
|
+
The SDK reads the account and places orders through four functions you write against your broker's own API:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from datetime import UTC, datetime
|
|
75
|
+
from decimal import Decimal
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_account(instrument, adjustments_since):
|
|
79
|
+
"""Read the account from your broker now, and a quote for `instrument` when it is given."""
|
|
80
|
+
now = datetime.now(UTC)
|
|
81
|
+
snapshot = retrade.Snapshot(
|
|
82
|
+
as_of=now,
|
|
83
|
+
cash={"USD": Decimal("38380.90")}, # from your broker
|
|
84
|
+
settled_cash={"USD": Decimal("38380.90")}, # from your broker
|
|
85
|
+
positions=(retrade.Position("XNAS:AAPL", 81, Decimal("187.10")),),
|
|
86
|
+
open_orders=(),
|
|
87
|
+
)
|
|
88
|
+
if instrument is None:
|
|
89
|
+
return snapshot, None
|
|
90
|
+
return snapshot, retrade.Quote(instrument, Decimal("187.10"), Decimal("187.08"), Decimal("187.12"), now)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
broker = retrade.GenericAdapter(
|
|
94
|
+
snapshot=read_account,
|
|
95
|
+
submit=place_order, # (retrade.ApprovedOrder, tag) -> the broker's order id
|
|
96
|
+
find=find_order, # (tag, retrade.ApprovedOrder, since) -> the broker's order id, or None
|
|
97
|
+
cancel=cancel_order, # (broker order id) -> None
|
|
98
|
+
g_seconds=60, # must equal the account's g_seconds at reTrade
|
|
99
|
+
converts_cash=True, # must equal the account's converts_cash at reTrade
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`submit` places exactly the `ApprovedOrder` it is given, with `tag` in the broker's client-order-id field. It raises
|
|
104
|
+
`retrade.BrokerRejected` only when the broker definitely refused the order, and lets any other error through when the
|
|
105
|
+
outcome is unknown. The SDK never retries a submit.
|
|
106
|
+
|
|
107
|
+
## Submit an order
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from retrade import OrderType, Side, Tif
|
|
111
|
+
|
|
112
|
+
order = retrade.Order("XNAS:AAPL", Side.BUY, 40, OrderType.LIMIT, Tif.DAY, limit_price=Decimal("187.25"))
|
|
113
|
+
result = rt.place(order, account="A1", agent="agt_momentum", via=broker)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`account` is the account's ref in your tenant's account register. `agent` names the agent sending the order.
|
|
117
|
+
Instruments are `<MIC>:<symbol>`, such as `XNAS:AAPL`. Plain strings (`"buy"`, `"limit"`, `"day"`) and lists work
|
|
118
|
+
too; the enums and tuples are what a type checker accepts. The package ships its type hints (`py.typed`).
|
|
119
|
+
|
|
120
|
+
## Handle ALLOW, CUT and BLOCK
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
if result.outcome == "ALLOW":
|
|
124
|
+
print("placed as sent:", result.broker_order_id)
|
|
125
|
+
elif result.outcome == "CUT":
|
|
126
|
+
print(f"placed {result.quantity} of {result.requested_quantity}:", result.deciding)
|
|
127
|
+
elif result.fits:
|
|
128
|
+
# BLOCK at an approval line or an agent conflict, and a smaller order would pass. The resend is a new
|
|
129
|
+
# decision and counts toward the agent's per-minute limit, so resend once, don't loop.
|
|
130
|
+
smaller = retrade.Order("XNAS:AAPL", Side.BUY, result.fits, OrderType.LIMIT, Tif.DAY, limit_price=Decimal("187.25"))
|
|
131
|
+
result = rt.place(smaller, account="A1", agent="agt_momentum", via=broker)
|
|
132
|
+
else:
|
|
133
|
+
# BLOCK, nothing placed: reTrade's rule, or the SDK's own reason when it had no verified answer.
|
|
134
|
+
print("blocked:", result.deciding or result.reason)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Check `result.placement` as well: `placed`, `not_placed` (the broker refused it, or it was never sent), or `unknown`
|
|
138
|
+
(the broker call's outcome isn't known yet; don't place the order again).
|
|
139
|
+
- `result.deciding` is reTrade's deciding rule, for example `{"code": "approval_line", "reason": "per_order"}` or
|
|
140
|
+
`{"code": "order_size_cap"}`.
|
|
141
|
+
- `result.fits` is set only on a BLOCK at an approval line or an agent conflict. It is 0 when no size would pass.
|
|
142
|
+
- `result.local` is True when the SDK decided without a verified answer from reTrade, with `result.reason` saying why:
|
|
143
|
+
for example `unreachable` (no answer within 2.5 s) or `unverified` (the answer failed a check). In enforce and test
|
|
144
|
+
mode the order is then not placed.
|
|
145
|
+
|
|
146
|
+
## Verify receipts
|
|
147
|
+
|
|
148
|
+
The SDK checks every signed answer before it uses it:
|
|
149
|
+
|
|
150
|
+
- the key list must be signed by the root key you pinned;
|
|
151
|
+
- each decision must be signed by a listed key of the right environment, valid at the decision's time, and must
|
|
152
|
+
answer the exact request bytes the SDK sent, with the same order fields, signed within 60 s of your clock;
|
|
153
|
+
- after the broker call, the SDK reports what happened and checks the signed receipt reTrade sends back.
|
|
154
|
+
|
|
155
|
+
An answer that fails any check is never placed (`result.reason == "unverified"`). Everything is kept in
|
|
156
|
+
`retrade-state/claim-kit.sqlite3`: the request bytes, the signed decision, the policy it was decided under, your
|
|
157
|
+
report and reTrade's receipt. To read the evidence for one decision:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
if result.decision_id is not None: # None only when the SDK decided on its own
|
|
161
|
+
evidence = rt.claim_kit(result.decision_id)
|
|
162
|
+
# {"format": "retrade-claim-kit/1", "decision_id": ..., "request": ..., "decision": ..., "policy": ...,
|
|
163
|
+
# "report": ..., "receipt": ..., "heads": [...]}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
or, on the command line, `retrade claim-kit <decision id> --state-dir retrade-state`. `decision` and `receipt` are
|
|
167
|
+
compact JWS documents (Ed25519). Anyone with the root public key can check them again against `GET /v1/keys`, as
|
|
168
|
+
[API.md sections 3 and 4](https://github.com/lance-lii/reTrade-real-app/blob/main/API.md) describe. Keep the file for
|
|
169
|
+
at least 90 days. The TypeScript SDK reads and writes the same format.
|
|
170
|
+
|
|
171
|
+
To read your tenant's log for this key's mode, each record verified and chained to the one before it:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
records = rt.log() # the whole log, from record 1: a list of retrade.LogRecord (seq, kind, hash, token, payload)
|
|
175
|
+
first20 = rt.log(from_seq=1, to_seq=20) # records 1 to 20
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
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
|
|
179
|
+
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
|
|
180
|
+
are held in memory, so read a very long log in ranges.
|
|
181
|
+
|
|
182
|
+
reTrade offers no guarantee and pays no losses. The signed records are evidence of what was asked, what reTrade
|
|
183
|
+
answered and what was placed, nothing more.
|
|
184
|
+
|
|
185
|
+
## Status
|
|
186
|
+
|
|
187
|
+
- In development. 0.x versions may change the API between minor releases.
|
|
188
|
+
- Wire spec: `retrade-arith/2.0-draft`.
|
|
189
|
+
- The default address is the hosted service at `https://api.retrade.app`; pass `base_url` to use your own server.
|
|
190
|
+
- Not built on the server yet: snapshots sent with no order (`keep_fresh`, `send_snapshot`; the server answers 404,
|
|
191
|
+
which the SDK only logs) and webhooks.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Reference
|
|
196
|
+
|
|
197
|
+
### What `place` returns
|
|
198
|
+
|
|
199
|
+
`retrade.Result`, with these fields:
|
|
200
|
+
|
|
201
|
+
| Field | Meaning |
|
|
202
|
+
|---|---|
|
|
203
|
+
| `outcome` | `ALLOW`, `CUT` or `BLOCK` (`retrade.Outcome`) |
|
|
204
|
+
| `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 |
|
|
205
|
+
| `quantity` | the approved quantity (ALLOW, CUT), or what the SDK placed on its own; None for a BLOCK |
|
|
206
|
+
| `requested_quantity` | the order's quantity |
|
|
207
|
+
| `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 |
|
|
208
|
+
| `fits` | on a BLOCK at an approval line or an agent conflict, the largest quantity that would pass (0 when none would); otherwise None |
|
|
209
|
+
| `flags` | the approval-line and agent-conflict codes reTrade listed on an ALLOW or CUT; empty while those always BLOCK |
|
|
210
|
+
| `alerts` | reTrade's alerts, passed through |
|
|
211
|
+
| `reason` | the SDK's own reason when it acted without or against an answer (below) |
|
|
212
|
+
| `decision_id`, `broker_order_id` | the decision and the broker's order id |
|
|
213
|
+
| `local` | True when the SDK decided without a verified answer from reTrade |
|
|
214
|
+
| `claimable` | True when the decision was made in enforce mode and the order was placed; it marks evidence only, never a promise of payment |
|
|
215
|
+
|
|
216
|
+
The SDK's own reasons: `unreachable` (no answer in 2.5 s), `unverified` (the answer failed a check, including one
|
|
217
|
+
whose order fields differ from what was sent), `exits-only:<why>` (exits-only refused the sell), `exit` (exits-only
|
|
218
|
+
placed it), `shadow-unreachable` and `shadow-unverified`, `expired` and `stopped` (an approved order withdrawn before
|
|
219
|
+
the broker call), `already-used`, `broker-rejected` and `broker-outcome-unknown`.
|
|
220
|
+
|
|
221
|
+
### Other calls
|
|
222
|
+
|
|
223
|
+
- `rt.replace(broker_order_id, order, account=..., agent=..., via=...)`: cancel a working order, then decide on the new
|
|
224
|
+
one.
|
|
225
|
+
- `rt.stop(scope, account=None, agent=None, reason=...)`: engage a stop (`tenant`, `account` or `agent`). Any
|
|
226
|
+
executor key can stop; only an admin key releases it.
|
|
227
|
+
- `rt.cancel_open_orders(via, all_orders=False)`, `rt.send_pending_reports()`, `rt.refresh()`, `rt.send_snapshot(...)`,
|
|
228
|
+
`rt.keep_fresh(...)`, `rt.log(from_seq=None, to_seq=None)`, `rt.claim_kit(decision_id)` and `rt.close()`. A `Client`
|
|
229
|
+
is also a context manager.
|
|
230
|
+
- `rt.keep_fresh({"A1": broker})`, every 5 minutes on a background thread until `rt.close()`: refreshes stops and
|
|
231
|
+
account settings, and sends each account's snapshot.
|
|
232
|
+
- `retrade claim-kit DECISION_ID --state-dir DIR` prints the evidence for one decision.
|
|
233
|
+
|
|
234
|
+
### What the SDK does on your side
|
|
235
|
+
|
|
236
|
+
- Nothing reaches your broker that reTrade didn't approve (in enforce and test mode), and never more than the approved
|
|
237
|
+
quantity. A decision is placed at most once.
|
|
238
|
+
- If reTrade doesn't answer within 2.5 s, the order is blocked (or, with exits-only on, a qualifying sell goes ahead).
|
|
239
|
+
- reTrade's status (stops and account settings, exits-only included) is used only after its signature and freshness
|
|
240
|
+
are checked.
|
|
241
|
+
- In shadow mode, your agent's orders are placed unchanged; reTrade only records what it would have done.
|
|
242
|
+
- The dollar lines are your tenant's policy, never constants in the SDK. A new tenant starts with an approval line of
|
|
243
|
+
5,000 per order and 10,000 per day, which its admins change with `retrade-admin policy set`. The one fixed number is
|
|
244
|
+
exits-only's fallback cap: when the last rules this SDK decided under have no `approval_line`, an exits-only sell is
|
|
245
|
+
held to 5,000 per order in the account's currency.
|
|
246
|
+
|
|
247
|
+
### Exits-only, ids and submits
|
|
248
|
+
|
|
249
|
+
1. **Exits-only** (an admin turns it on; it applies only while reTrade can't be reached): a limit sell of shares the
|
|
250
|
+
broker holds now, at most held minus sells already working and this SDK's own recent sells the broker may not show
|
|
251
|
+
yet (its exits, and sells reTrade approved), no more than 10% below last, of no company on the won't-sell list,
|
|
252
|
+
within the per-order approval line (5,000 when the last rules have none), and with no stop known for the account or
|
|
253
|
+
the agent. Everything else is refused.
|
|
254
|
+
2. **Ids and tags**: a decision id is a ULID; the broker tag is `rt1-<id>` (enforce), `rs1-<id>` (shadow) or
|
|
255
|
+
`rx1-<id>` (test), 30 characters. A broker whose client-order-id holds fewer characters would need a shorter form.
|
|
256
|
+
3. **A submit is never retried.** When the outcome is unknown, the SDK looks the order up by its tag; if it still
|
|
257
|
+
can't tell, the decision keeps counting at reTrade until a snapshot shows the order or its absence.
|
|
258
|
+
|
|
259
|
+
The wire formats are in [API.md](https://github.com/lance-lii/reTrade-real-app/blob/main/API.md).
|
|
260
|
+
|
|
261
|
+
### Development
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
uv sync
|
|
265
|
+
uv run python -m pytest -q
|
|
266
|
+
uv run ruff check .
|
|
267
|
+
uv run mypy
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The server end-to-end test (`tests/test_server_e2e.py`) is skipped unless a server is running; the Rust test
|
|
271
|
+
`server/tests/sdk_e2e.rs` starts one and runs it.
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT. See [LICENSE](https://github.com/lance-lii/reTrade-real-app/blob/main/sdk-python/LICENSE).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "retrade"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Python SDK for reTrade, a pre-trade check for AI trading agents: every order gets ALLOW, CUT or BLOCK with a signed receipt"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "reTrade" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = ["cryptography>=46,<47"]
|
|
11
|
+
keywords = ["trading", "pre-trade", "risk", "ai-agents", "orders", "broker", "ed25519", "receipts"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://retrade.app"
|
|
28
|
+
Repository = "https://github.com/lance-lii/reTrade-real-app"
|
|
29
|
+
Source = "https://github.com/lance-lii/reTrade-real-app/tree/main/sdk-python"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
retrade = "retrade.cli:main"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = ["pytest>=8", "jsonschema>=4.23", "ruff>=0.15", "mypy>=1.18"]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 120
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint]
|
|
48
|
+
select = ["E", "F", "W", "B", "UP", "SIM", "RUF", "I"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
strict = true
|
|
52
|
+
files = ["src", "tests/typed_usage.py"]
|