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 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).
@@ -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"]