reins-client 0.1.0__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.
@@ -0,0 +1,31 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.log
7
+ .DS_Store
8
+
9
+ # Demo agent runs: paper account, decision log, API spend.
10
+ demo-data/
11
+
12
+ # Packages made to copy the demo to a server.
13
+ *.tar.gz
14
+
15
+ # The MCP Registry publisher, if downloaded locally.
16
+ mcp-publisher.exe
17
+ mcp-publisher.tar.gz
18
+ mcp-publisher
19
+
20
+ # The site zipped up for uploading to the host.
21
+ r2rlabs-site.zip
22
+
23
+ # Working notes for Claude: local only, they are not part of the product.
24
+ CLAUDE.md
25
+
26
+ # Local-only tooling: the daily interest report and its history.
27
+ local/
28
+
29
+ # Python client build output.
30
+ clients/python/dist/
31
+ clients/python/**/__pycache__/
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.5
2
+ Name: reins-client
3
+ Version: 0.1.0
4
+ Summary: Risk limits and an audit log for Python trading bots on Hyperliquid, through a Reins server.
5
+ Project-URL: Homepage, https://r2rlabs.com
6
+ Project-URL: Documentation, https://r2rlabs.com/mcp/
7
+ Project-URL: Source, https://github.com/R2Rlabs/reins
8
+ Project-URL: Issues, https://github.com/R2Rlabs/reins/issues
9
+ Author: R2R Labs
10
+ License: MIT
11
+ Keywords: agent,bot,hyperliquid,mcp,risk,trading
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Office/Business :: Financial :: Investment
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+
21
+ # reins-client
22
+
23
+ Risk limits and an audit log around a Python trading bot on Hyperliquid.
24
+
25
+ Your bot decides what to trade. Reins decides what it is allowed to send: a cap
26
+ per position, leverage, a daily loss limit that halts trading, an allowlist, a
27
+ rate limit, a required stop-loss, and a cap on what one trade may lose. The
28
+ limits run in a separate process, so no prompt, bug or bad hour gets around
29
+ them, and every attempt — including the refusals — is written to an append-only
30
+ log.
31
+
32
+ ```bash
33
+ pip install reins-client
34
+ ```
35
+
36
+ Point it at a running Reins server:
37
+
38
+ ```bash
39
+ npx @r2rlabs/reins init # writes a paper-trading config
40
+ npx @r2rlabs/reins http # prints the URL and token
41
+ ```
42
+
43
+ ## Using it
44
+
45
+ ```python
46
+ from reins import Reins, Refused
47
+
48
+ reins = Reins.from_env() # REINS_URL, REINS_HTTP_TOKEN
49
+
50
+ book = reins.book("BTC")
51
+ entry = book["bestBid"]
52
+ stop = round(entry * 0.99, 1)
53
+
54
+ try:
55
+ order = reins.place_order(
56
+ symbol="BTC",
57
+ side="buy",
58
+ size_usd=Reins.size_for_risk(entry, stop, risk_usd=25),
59
+ price=entry,
60
+ stop_loss=stop,
61
+ reason="range low held on the hourly",
62
+ tif="Alo", # post-only: maker fees, no chasing
63
+ )
64
+ except Refused as refusal:
65
+ print(refusal.code, refusal.reason)
66
+ ```
67
+
68
+ A refusal is not an error in your code — it is Reins doing its job. It arrives
69
+ as `Refused` with the code, so a bot can branch on it:
70
+
71
+ `POSITION_TOO_LARGE`, `LEVERAGE_TOO_HIGH`, `HALTED_DAILY_LOSS`,
72
+ `TRADE_RISK_TOO_LARGE`, `NO_STOP_LOSS`, `SYMBOL_NOT_ALLOWED`, `RATE_LIMITED`,
73
+ `INVALID_ORDER`.
74
+
75
+ ### Sizing from the stop
76
+
77
+ `Reins.size_for_risk(entry, stop, risk_usd)` is the same arithmetic the risk
78
+ engine checks: size = risk ÷ (distance to the stop, as a fraction of entry).
79
+ Size this way and a wider stop gives you a smaller position instead of a bigger
80
+ loss — which is what keeps orders inside `TRADE_RISK_TOO_LARGE`.
81
+
82
+ ### Everything else
83
+
84
+ ```python
85
+ reins.health() # paper or live — check before you trade
86
+ reins.limits() # the limits in force, and what is left of them today
87
+ reins.positions() # equity, open positions, anything without a stop
88
+ reins.candles("ETH", interval="1h", count=50)
89
+ reins.set_stop_loss(symbol="BTC", trigger_price=82000, reason="under the low")
90
+ reins.cancel_order(symbol="BTC", order_id=2, reason="setup gone")
91
+ reins.close_position(symbol="BTC", reason="target hit")
92
+ reins.decisions(limit=20) # the log: tried, refused, filled
93
+ ```
94
+
95
+ `reason` is required on anything that changes a position. An order nobody
96
+ explained is the one you cannot account for afterwards.
97
+
98
+ ## Paper first
99
+
100
+ A Reins server in paper mode uses live Hyperliquid prices with simulated fills,
101
+ the same limits and the same fees, and signs nothing. Point your bot at it and
102
+ you can watch a week of its decisions before any money is involved. Paper is
103
+ free; live trading pays a 2 basis point Hyperliquid builder fee, and there is no
104
+ subscription.
105
+
106
+ ## Checked against the real thing
107
+
108
+ `tests/` has 17 unit tests against a stub server (no network), plus `e2e.py`,
109
+ which drives a real server:
110
+
111
+ ```bash
112
+ python -m unittest discover -s tests # unit
113
+ node dist/bin/cli.js http --port 8799 # in the Reins repo, paper mode
114
+ REINS_URL=http://127.0.0.1:8799 REINS_HTTP_TOKEN=... python tests/e2e.py
115
+ ```
116
+
117
+ The end-to-end run places a real paper order with an attached stop, then
118
+ deliberately breaches three limits and checks each refusal comes back with the
119
+ right code and lands in the log.
120
+
121
+ No dependencies: the client is one stdlib-only file, so it can be vendored if
122
+ you would rather not add a package.
123
+
124
+ MIT licensed. Source: https://github.com/R2Rlabs/reins
@@ -0,0 +1,104 @@
1
+ # reins-client
2
+
3
+ Risk limits and an audit log around a Python trading bot on Hyperliquid.
4
+
5
+ Your bot decides what to trade. Reins decides what it is allowed to send: a cap
6
+ per position, leverage, a daily loss limit that halts trading, an allowlist, a
7
+ rate limit, a required stop-loss, and a cap on what one trade may lose. The
8
+ limits run in a separate process, so no prompt, bug or bad hour gets around
9
+ them, and every attempt — including the refusals — is written to an append-only
10
+ log.
11
+
12
+ ```bash
13
+ pip install reins-client
14
+ ```
15
+
16
+ Point it at a running Reins server:
17
+
18
+ ```bash
19
+ npx @r2rlabs/reins init # writes a paper-trading config
20
+ npx @r2rlabs/reins http # prints the URL and token
21
+ ```
22
+
23
+ ## Using it
24
+
25
+ ```python
26
+ from reins import Reins, Refused
27
+
28
+ reins = Reins.from_env() # REINS_URL, REINS_HTTP_TOKEN
29
+
30
+ book = reins.book("BTC")
31
+ entry = book["bestBid"]
32
+ stop = round(entry * 0.99, 1)
33
+
34
+ try:
35
+ order = reins.place_order(
36
+ symbol="BTC",
37
+ side="buy",
38
+ size_usd=Reins.size_for_risk(entry, stop, risk_usd=25),
39
+ price=entry,
40
+ stop_loss=stop,
41
+ reason="range low held on the hourly",
42
+ tif="Alo", # post-only: maker fees, no chasing
43
+ )
44
+ except Refused as refusal:
45
+ print(refusal.code, refusal.reason)
46
+ ```
47
+
48
+ A refusal is not an error in your code — it is Reins doing its job. It arrives
49
+ as `Refused` with the code, so a bot can branch on it:
50
+
51
+ `POSITION_TOO_LARGE`, `LEVERAGE_TOO_HIGH`, `HALTED_DAILY_LOSS`,
52
+ `TRADE_RISK_TOO_LARGE`, `NO_STOP_LOSS`, `SYMBOL_NOT_ALLOWED`, `RATE_LIMITED`,
53
+ `INVALID_ORDER`.
54
+
55
+ ### Sizing from the stop
56
+
57
+ `Reins.size_for_risk(entry, stop, risk_usd)` is the same arithmetic the risk
58
+ engine checks: size = risk ÷ (distance to the stop, as a fraction of entry).
59
+ Size this way and a wider stop gives you a smaller position instead of a bigger
60
+ loss — which is what keeps orders inside `TRADE_RISK_TOO_LARGE`.
61
+
62
+ ### Everything else
63
+
64
+ ```python
65
+ reins.health() # paper or live — check before you trade
66
+ reins.limits() # the limits in force, and what is left of them today
67
+ reins.positions() # equity, open positions, anything without a stop
68
+ reins.candles("ETH", interval="1h", count=50)
69
+ reins.set_stop_loss(symbol="BTC", trigger_price=82000, reason="under the low")
70
+ reins.cancel_order(symbol="BTC", order_id=2, reason="setup gone")
71
+ reins.close_position(symbol="BTC", reason="target hit")
72
+ reins.decisions(limit=20) # the log: tried, refused, filled
73
+ ```
74
+
75
+ `reason` is required on anything that changes a position. An order nobody
76
+ explained is the one you cannot account for afterwards.
77
+
78
+ ## Paper first
79
+
80
+ A Reins server in paper mode uses live Hyperliquid prices with simulated fills,
81
+ the same limits and the same fees, and signs nothing. Point your bot at it and
82
+ you can watch a week of its decisions before any money is involved. Paper is
83
+ free; live trading pays a 2 basis point Hyperliquid builder fee, and there is no
84
+ subscription.
85
+
86
+ ## Checked against the real thing
87
+
88
+ `tests/` has 17 unit tests against a stub server (no network), plus `e2e.py`,
89
+ which drives a real server:
90
+
91
+ ```bash
92
+ python -m unittest discover -s tests # unit
93
+ node dist/bin/cli.js http --port 8799 # in the Reins repo, paper mode
94
+ REINS_URL=http://127.0.0.1:8799 REINS_HTTP_TOKEN=... python tests/e2e.py
95
+ ```
96
+
97
+ The end-to-end run places a real paper order with an attached stop, then
98
+ deliberately breaches three limits and checks each refusal comes back with the
99
+ right code and lands in the log.
100
+
101
+ No dependencies: the client is one stdlib-only file, so it can be vendored if
102
+ you would rather not add a package.
103
+
104
+ MIT licensed. Source: https://github.com/R2Rlabs/reins
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reins-client"
7
+ version = "0.1.0"
8
+ description = "Risk limits and an audit log for Python trading bots on Hyperliquid, through a Reins server."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "R2R Labs" }]
13
+ keywords = ["hyperliquid", "trading", "risk", "agent", "bot", "mcp"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Financial and Insurance Industry",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Office/Business :: Financial :: Investment",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://r2rlabs.com"
26
+ Documentation = "https://r2rlabs.com/mcp/"
27
+ Source = "https://github.com/R2Rlabs/reins"
28
+ Issues = "https://github.com/R2Rlabs/reins/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["reins"]
@@ -0,0 +1,289 @@
1
+ """Reins from Python: risk limits and an audit log around a trading agent.
2
+
3
+ Most Hyperliquid bots are Python, and Reins speaks MCP or HTTP. This wraps the
4
+ HTTP side so a bot does not hand-roll requests:
5
+
6
+ from reins import Reins, Refused
7
+
8
+ reins = Reins.from_env() # REINS_URL, REINS_HTTP_TOKEN
9
+ book = reins.book("BTC")
10
+ entry = book["bids"][0]["price"]
11
+ stop = entry * 0.99
12
+
13
+ try:
14
+ order = reins.place_order(
15
+ symbol="BTC",
16
+ side="buy",
17
+ size_usd=Reins.size_for_risk(entry, stop, risk_usd=25),
18
+ price=entry,
19
+ stop_loss=stop,
20
+ reason="range low held on the hourly",
21
+ )
22
+ except Refused as refusal:
23
+ print(refusal.code, refusal.reason) # e.g. TRADE_RISK_TOO_LARGE
24
+
25
+ A refusal is not a failure of the call: it is Reins doing its job, and it is
26
+ raised as `Refused` with the code so a bot can branch on it. Everything the
27
+ agent does, including what was refused, lands in the decision log.
28
+
29
+ No dependencies: stdlib only, so this can be vendored as a single file.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ import urllib.error
38
+ import urllib.parse
39
+ import urllib.request
40
+ from typing import Any, Literal
41
+
42
+ __all__ = [
43
+ "Reins",
44
+ "ReinsError",
45
+ "Refused",
46
+ "BadRequest",
47
+ "Unauthorized",
48
+ "ServerError",
49
+ "__version__",
50
+ ]
51
+
52
+ __version__ = "0.1.0"
53
+
54
+ Side = Literal["buy", "sell"]
55
+ TimeInForce = Literal["Gtc", "Ioc", "Alo"]
56
+ Interval = Literal["1m", "5m", "15m", "1h", "4h", "1d"]
57
+
58
+ # Refusals arrive as "BLOCKED (CODE): reason".
59
+ _BLOCKED = re.compile(r"^BLOCKED \(([A-Z_]+)\): (.*)$", re.S)
60
+
61
+
62
+ class ReinsError(Exception):
63
+ """Anything that stopped a call from succeeding."""
64
+
65
+ def __init__(self, message: str, *, status: int | None = None) -> None:
66
+ super().__init__(message)
67
+ self.message = message
68
+ self.status = status
69
+
70
+
71
+ class Refused(ReinsError):
72
+ """A risk limit said no. The order never reached the exchange.
73
+
74
+ `code` is one of POSITION_TOO_LARGE, LEVERAGE_TOO_HIGH, HALTED_DAILY_LOSS,
75
+ TRADE_RISK_TOO_LARGE, NO_STOP_LOSS, SYMBOL_NOT_ALLOWED, RATE_LIMITED or
76
+ INVALID_ORDER, and `reason` is the sentence explaining it.
77
+ """
78
+
79
+ def __init__(self, code: str, reason: str) -> None:
80
+ super().__init__(f"{code}: {reason}", status=400)
81
+ self.code = code
82
+ self.reason = reason
83
+
84
+
85
+ class BadRequest(ReinsError):
86
+ """The request itself was wrong — a missing field, an unknown market."""
87
+
88
+
89
+ class Unauthorized(ReinsError):
90
+ """No token, or the wrong one."""
91
+
92
+
93
+ class ServerError(ReinsError):
94
+ """Reins or the exchange failed. Nothing can be assumed about the order."""
95
+
96
+
97
+ class Reins:
98
+ """A client for one `reins http` server.
99
+
100
+ The server serialises requests, so two orders cannot race the same limit;
101
+ this class holds no state of its own beyond the address and token.
102
+ """
103
+
104
+ def __init__(self, base_url: str, token: str = "", *, timeout: float = 30.0) -> None:
105
+ self.base_url = base_url.rstrip("/")
106
+ self.token = token
107
+ self.timeout = timeout
108
+
109
+ @classmethod
110
+ def from_env(cls, **kwargs: Any) -> "Reins":
111
+ """Read the address and token from the environment.
112
+
113
+ REINS_URL (default http://127.0.0.1:8787) and REINS_HTTP_TOKEN, which
114
+ is what `reins http` prints when it starts.
115
+ """
116
+ return cls(
117
+ os.environ.get("REINS_URL", "http://127.0.0.1:8787"),
118
+ os.environ.get("REINS_HTTP_TOKEN", ""),
119
+ **kwargs,
120
+ )
121
+
122
+ # --- what the agent may know -------------------------------------------
123
+
124
+ def health(self) -> dict[str, Any]:
125
+ """Whether this server is paper or live, before anything is sent."""
126
+ return self._get("/health", authenticated=False)
127
+
128
+ def limits(self) -> dict[str, Any]:
129
+ """The limits in force, so a bot can size inside them rather than guess."""
130
+ return self._get("/limits")
131
+
132
+ def positions(self) -> dict[str, Any]:
133
+ """Open positions, equity, and the day's realised loss so far."""
134
+ return self._get("/positions")
135
+
136
+ def book(self, symbol: str, *, depth: int | None = None) -> dict[str, Any]:
137
+ params: dict[str, Any] = {"symbol": symbol}
138
+ if depth is not None:
139
+ params["depth"] = depth
140
+ return self._get("/book", params)
141
+
142
+ def candles(
143
+ self, symbol: str, *, interval: Interval | None = None, count: int | None = None
144
+ ) -> dict[str, Any]:
145
+ params: dict[str, Any] = {"symbol": symbol}
146
+ if interval is not None:
147
+ params["interval"] = interval
148
+ if count is not None:
149
+ params["count"] = count
150
+ return self._get("/candles", params)
151
+
152
+ def decisions(self, *, limit: int | None = None) -> dict[str, Any]:
153
+ """The log: what was tried, what was refused, what the exchange did."""
154
+ params: dict[str, Any] = {}
155
+ if limit is not None:
156
+ params["limit"] = limit
157
+ return self._get("/decisions", params)
158
+
159
+ # --- what the agent may do ---------------------------------------------
160
+
161
+ def place_order(
162
+ self,
163
+ *,
164
+ symbol: str,
165
+ side: Side,
166
+ size_usd: float,
167
+ reason: str,
168
+ price: float | None = None,
169
+ stop_loss: float | None = None,
170
+ reduce_only: bool | None = None,
171
+ tif: TimeInForce | None = None,
172
+ ) -> dict[str, Any]:
173
+ """Send an order, or raise `Refused` if a limit stops it.
174
+
175
+ `reason` is required, as it is for the agent: an order nobody explained
176
+ is the one you cannot account for afterwards. Pass `stop_loss` unless
177
+ the server was started without requiring one — it is attached to the
178
+ order, so a resting entry carries its stop from the moment it fills.
179
+ """
180
+ payload: dict[str, Any] = {
181
+ "symbol": symbol,
182
+ "side": side,
183
+ "sizeUsd": size_usd,
184
+ "reason": reason,
185
+ }
186
+ if price is not None:
187
+ payload["price"] = price
188
+ if stop_loss is not None:
189
+ payload["stopLoss"] = stop_loss
190
+ if reduce_only is not None:
191
+ payload["reduceOnly"] = reduce_only
192
+ if tif is not None:
193
+ payload["tif"] = tif
194
+ return self._post("/orders", payload)
195
+
196
+ def set_stop_loss(self, *, symbol: str, trigger_price: float, reason: str) -> dict[str, Any]:
197
+ """Put a stop on an open position, or move the one that is there."""
198
+ return self._post(
199
+ "/stops", {"symbol": symbol, "triggerPrice": trigger_price, "reason": reason}
200
+ )
201
+
202
+ def cancel_order(
203
+ self, *, symbol: str, order_id: int, reason: str | None = None
204
+ ) -> dict[str, Any]:
205
+ payload: dict[str, Any] = {"symbol": symbol, "orderId": order_id}
206
+ if reason is not None:
207
+ payload["reason"] = reason
208
+ return self._post("/cancel", payload)
209
+
210
+ def close_position(self, *, symbol: str, reason: str) -> dict[str, Any]:
211
+ """Close the whole position at market, whatever its exact size is."""
212
+ return self._post("/close", {"symbol": symbol, "reason": reason})
213
+
214
+ # --- sizing -------------------------------------------------------------
215
+
216
+ @staticmethod
217
+ def size_for_risk(entry: float, stop: float, risk_usd: float) -> float:
218
+ """The position size whose stop-out costs about `risk_usd`.
219
+
220
+ Reins refuses an order whose stop-out would cost more than the per-trade
221
+ limit (TRADE_RISK_TOO_LARGE), and this is the arithmetic it checks:
222
+ size = risk / (distance to the stop, as a fraction of entry). Sizing
223
+ this way is what makes a wider stop mean a smaller position rather than
224
+ a bigger loss.
225
+ """
226
+ if entry <= 0:
227
+ raise ValueError("entry must be positive")
228
+ if stop <= 0:
229
+ raise ValueError("stop must be positive")
230
+ if risk_usd <= 0:
231
+ raise ValueError("risk_usd must be positive")
232
+ distance = abs(entry - stop) / entry
233
+ if distance == 0:
234
+ raise ValueError("stop must differ from entry")
235
+ return risk_usd / distance
236
+
237
+ # --- plumbing -----------------------------------------------------------
238
+
239
+ def _get(
240
+ self, path: str, params: dict[str, Any] | None = None, *, authenticated: bool = True
241
+ ) -> dict[str, Any]:
242
+ url = self.base_url + path
243
+ if params:
244
+ url += "?" + urllib.parse.urlencode(params)
245
+ return self._send(url, None, authenticated)
246
+
247
+ def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
248
+ return self._send(self.base_url + path, json.dumps(payload).encode(), True)
249
+
250
+ def _send(self, url: str, data: bytes | None, authenticated: bool) -> dict[str, Any]:
251
+ headers = {"Accept": "application/json"}
252
+ if data is not None:
253
+ headers["Content-Type"] = "application/json"
254
+ if authenticated and self.token:
255
+ headers["Authorization"] = f"Bearer {self.token}"
256
+
257
+ request = urllib.request.Request(
258
+ url, data=data, headers=headers, method="POST" if data is not None else "GET"
259
+ )
260
+ try:
261
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
262
+ return self._decode(response.read())
263
+ except urllib.error.HTTPError as error:
264
+ raise self._error(error.code, error.read()) from None
265
+ except urllib.error.URLError as error:
266
+ raise ReinsError(f"could not reach Reins at {self.base_url}: {error.reason}") from None
267
+
268
+ @staticmethod
269
+ def _decode(raw: bytes) -> dict[str, Any]:
270
+ text = raw.decode("utf-8", "replace")
271
+ if not text:
272
+ return {}
273
+ try:
274
+ parsed = json.loads(text)
275
+ except json.JSONDecodeError:
276
+ return {"result": text}
277
+ return parsed if isinstance(parsed, dict) else {"result": parsed}
278
+
279
+ @classmethod
280
+ def _error(cls, status: int, raw: bytes) -> ReinsError:
281
+ message = str(cls._decode(raw).get("error") or raw.decode("utf-8", "replace") or "")
282
+ if status == 401:
283
+ return Unauthorized(message or "no token", status=401)
284
+ if status == 400:
285
+ blocked = _BLOCKED.match(message)
286
+ if blocked:
287
+ return Refused(blocked.group(1), blocked.group(2).strip())
288
+ return BadRequest(message, status=400)
289
+ return ServerError(message or f"HTTP {status}", status=status)
@@ -0,0 +1,112 @@
1
+ """Drives a real `reins http` server through the client, end to end.
2
+
3
+ Not part of the unit suite: it needs a server and the exchange's prices.
4
+
5
+ node dist/bin/cli.js http --port 8799 # paper mode
6
+ REINS_URL=http://127.0.0.1:8799 REINS_HTTP_TOKEN=... python tests/e2e.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
+
18
+ from reins import Refused, Reins # noqa: E402
19
+
20
+
21
+ def main() -> int:
22
+ reins = Reins.from_env()
23
+
24
+ health = reins.health()
25
+ print(f"health {health['mode']} on {health.get('network')}, {len(health['routes'])} routes")
26
+ if health["mode"] != "paper":
27
+ print("refusing to run against a live server")
28
+ return 1
29
+
30
+ limits = reins.limits()
31
+ print(f"limits {limits}")
32
+
33
+ positions = reins.positions()
34
+ print(
35
+ f"positions equity ${positions['accountValueUsd']:,}, "
36
+ f"{len(positions['positionsUsd'])} open, "
37
+ f"{len(positions['unprotected'])} without a stop"
38
+ )
39
+
40
+ book = reins.book("BTC", depth=3)
41
+ best_bid = book["bestBid"]
42
+ print(f"book BTC {best_bid} / {book['bestAsk']}, spread {book['spread']}")
43
+
44
+ candles = reins.candles("BTC", interval="1h", count=5)
45
+ print(f"candles {len(candles.get('candles', []))} hourly")
46
+
47
+ # An order sized so its stop-out costs about $20, inside the $50 per-trade cap.
48
+ entry = round(best_bid * 0.995, 1)
49
+ stop = round(entry * 0.99, 1)
50
+ size = Reins.size_for_risk(entry, stop, risk_usd=20)
51
+ order = reins.place_order(
52
+ symbol="BTC",
53
+ side="buy",
54
+ size_usd=size,
55
+ price=entry,
56
+ stop_loss=stop,
57
+ reason="end-to-end check of the Python client",
58
+ tif="Alo",
59
+ )
60
+ print(f"order ${size:,.0f} at {entry}, stop {stop} -> {order}")
61
+
62
+ # The same order with a stop far enough away to breach the per-trade cap.
63
+ try:
64
+ reins.place_order(
65
+ symbol="BTC",
66
+ side="buy",
67
+ size_usd=5000,
68
+ price=entry,
69
+ stop_loss=round(entry * 0.90, 1),
70
+ reason="deliberately too much risk",
71
+ )
72
+ print("refusal NOT REFUSED — the per-trade cap did not hold")
73
+ return 1
74
+ except Refused as refusal:
75
+ print(f"refusal {refusal.code}: {refusal.reason}")
76
+
77
+ # A market Reins was not told to allow.
78
+ try:
79
+ reins.place_order(symbol="SOL", side="buy", size_usd=100, reason="not on the allowlist")
80
+ print("allowlist NOT REFUSED")
81
+ return 1
82
+ except Refused as refusal:
83
+ print(f"allowlist {refusal.code}")
84
+
85
+ # No stop, on a server that requires one.
86
+ try:
87
+ reins.place_order(symbol="ETH", side="buy", size_usd=100, reason="no stop attached")
88
+ print("stop rule NOT REFUSED")
89
+ return 1
90
+ except Refused as refusal:
91
+ print(f"stop rule {refusal.code}")
92
+
93
+ # A resting order reports its exchange id as "oid".
94
+ order_id = order.get("oid")
95
+ if order_id:
96
+ cancelled = reins.cancel_order(
97
+ symbol="BTC", order_id=order_id, reason="end of the check"
98
+ )
99
+ print(f"cancel {cancelled}")
100
+
101
+ log = reins.decisions(limit=10)
102
+ records = log.get("decisions") or []
103
+ refused = [r for r in records if r.get("risk", {}).get("allowed") is False]
104
+ codes = ", ".join(sorted({r["risk"]["code"] for r in refused}))
105
+ print(f"decision log {len(records)} records, {len(refused)} refused ({codes})")
106
+
107
+ print("\nOK — every route answered and every limit held.")
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ raise SystemExit(main())
@@ -0,0 +1,218 @@
1
+ """What the client puts on the wire, and what it does with what comes back.
2
+
3
+ A stub server stands in for `reins http` so these run with no network and no
4
+ exchange. The end-to-end check against the real server is in README.md under
5
+ "Checked against the real thing".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ import threading
13
+ import unittest
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from pathlib import Path
16
+ from urllib.parse import parse_qs, urlparse
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19
+
20
+ from reins import BadRequest, Refused, Reins, ReinsError, ServerError, Unauthorized # noqa: E402
21
+
22
+
23
+ class Stub(BaseHTTPRequestHandler):
24
+ """Records the last request and replies with whatever the test queued."""
25
+
26
+ seen: dict = {}
27
+ reply: tuple[int, dict] = (200, {})
28
+
29
+ def _respond(self) -> None:
30
+ parsed = urlparse(self.path)
31
+ length = int(self.headers.get("Content-Length") or 0)
32
+ raw = self.rfile.read(length) if length else b""
33
+ Stub.seen = {
34
+ "method": self.command,
35
+ "path": parsed.path,
36
+ "query": {k: v[0] for k, v in parse_qs(parsed.query).items()},
37
+ "auth": self.headers.get("Authorization"),
38
+ "content_type": self.headers.get("Content-Type"),
39
+ "body": json.loads(raw) if raw else None,
40
+ }
41
+ status, body = Stub.reply
42
+ payload = json.dumps(body).encode()
43
+ self.send_response(status)
44
+ self.send_header("Content-Type", "application/json")
45
+ self.send_header("Content-Length", str(len(payload)))
46
+ self.end_headers()
47
+ self.wfile.write(payload)
48
+
49
+ do_GET = _respond
50
+ do_POST = _respond
51
+
52
+ def log_message(self, *args) -> None: # keep the test output clean
53
+ pass
54
+
55
+
56
+ class ClientTest(unittest.TestCase):
57
+ server: HTTPServer
58
+ thread: threading.Thread
59
+
60
+ @classmethod
61
+ def setUpClass(cls) -> None:
62
+ cls.server = HTTPServer(("127.0.0.1", 0), Stub)
63
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
64
+ cls.thread.start()
65
+ host, port = cls.server.server_address
66
+ cls.base = f"http://{host}:{port}"
67
+
68
+ @classmethod
69
+ def tearDownClass(cls) -> None:
70
+ cls.server.shutdown()
71
+ cls.server.server_close()
72
+
73
+ def client(self, token: str = "tok") -> Reins:
74
+ return Reins(self.base, token, timeout=5)
75
+
76
+ def reply(self, status: int, body: dict) -> None:
77
+ Stub.reply = (status, body)
78
+
79
+ # --- requests -----------------------------------------------------------
80
+
81
+ def test_get_sends_bearer_token(self):
82
+ self.reply(200, {"limits": {}})
83
+ self.client().limits()
84
+ self.assertEqual(Stub.seen["method"], "GET")
85
+ self.assertEqual(Stub.seen["path"], "/limits")
86
+ self.assertEqual(Stub.seen["auth"], "Bearer tok")
87
+
88
+ def test_health_needs_no_token(self):
89
+ self.reply(200, {"ok": True, "mode": "paper"})
90
+ Reins(self.base, "", timeout=5).health()
91
+ self.assertIsNone(Stub.seen["auth"])
92
+
93
+ def test_optional_query_parameters_are_left_out(self):
94
+ self.reply(200, {})
95
+ self.client().book("BTC")
96
+ self.assertEqual(Stub.seen["query"], {"symbol": "BTC"})
97
+ self.client().book("BTC", depth=5)
98
+ self.assertEqual(Stub.seen["query"], {"symbol": "BTC", "depth": "5"})
99
+
100
+ def test_candles_passes_interval_and_count(self):
101
+ self.reply(200, {})
102
+ self.client().candles("ETH", interval="1h", count=50)
103
+ self.assertEqual(Stub.seen["query"], {"symbol": "ETH", "interval": "1h", "count": "50"})
104
+
105
+ def test_order_uses_the_camel_case_the_server_expects(self):
106
+ self.reply(200, {"orderId": 7})
107
+ self.client().place_order(
108
+ symbol="BTC",
109
+ side="buy",
110
+ size_usd=500,
111
+ reason="range low held",
112
+ price=60000,
113
+ stop_loss=59400,
114
+ tif="Alo",
115
+ )
116
+ self.assertEqual(Stub.seen["method"], "POST")
117
+ self.assertEqual(Stub.seen["path"], "/orders")
118
+ self.assertEqual(Stub.seen["content_type"], "application/json")
119
+ self.assertEqual(
120
+ Stub.seen["body"],
121
+ {
122
+ "symbol": "BTC",
123
+ "side": "buy",
124
+ "sizeUsd": 500,
125
+ "reason": "range low held",
126
+ "price": 60000,
127
+ "stopLoss": 59400,
128
+ "tif": "Alo",
129
+ },
130
+ )
131
+
132
+ def test_order_omits_what_was_not_given(self):
133
+ self.reply(200, {})
134
+ self.client().place_order(symbol="BTC", side="sell", size_usd=100, reason="why")
135
+ self.assertEqual(
136
+ set(Stub.seen["body"]), {"symbol", "side", "sizeUsd", "reason"}
137
+ )
138
+
139
+ def test_reduce_only_false_is_still_sent(self):
140
+ self.reply(200, {})
141
+ self.client().place_order(
142
+ symbol="BTC", side="sell", size_usd=100, reason="why", reduce_only=False
143
+ )
144
+ self.assertIs(Stub.seen["body"]["reduceOnly"], False)
145
+
146
+ def test_stop_and_cancel_and_close(self):
147
+ self.reply(200, {})
148
+ self.client().set_stop_loss(symbol="BTC", trigger_price=59000, reason="under the low")
149
+ self.assertEqual(
150
+ Stub.seen["body"], {"symbol": "BTC", "triggerPrice": 59000, "reason": "under the low"}
151
+ )
152
+ self.client().cancel_order(symbol="BTC", order_id=42)
153
+ self.assertEqual(Stub.seen["body"], {"symbol": "BTC", "orderId": 42})
154
+ self.client().close_position(symbol="BTC", reason="done")
155
+ self.assertEqual(Stub.seen["path"], "/close")
156
+
157
+ # --- answers ------------------------------------------------------------
158
+
159
+ def test_refusal_becomes_refused_with_its_code(self):
160
+ self.reply(400, {"error": "BLOCKED (TRADE_RISK_TOO_LARGE): Stopping out would lose $120.00."})
161
+ with self.assertRaises(Refused) as caught:
162
+ self.client().place_order(symbol="BTC", side="buy", size_usd=9e9, reason="too big")
163
+ self.assertEqual(caught.exception.code, "TRADE_RISK_TOO_LARGE")
164
+ self.assertEqual(caught.exception.reason, "Stopping out would lose $120.00.")
165
+ self.assertIsInstance(caught.exception, ReinsError)
166
+
167
+ def test_a_plain_400_is_not_a_refusal(self):
168
+ self.reply(400, {"error": '"symbol" is required.'})
169
+ with self.assertRaises(BadRequest):
170
+ self.client().close_position(symbol="", reason="x")
171
+
172
+ def test_401_is_unauthorized(self):
173
+ self.reply(401, {"error": "Send the token as: Authorization: Bearer <token>."})
174
+ with self.assertRaises(Unauthorized):
175
+ self.client().positions()
176
+
177
+ def test_500_is_a_server_error(self):
178
+ self.reply(500, {"error": "exchange unreachable"})
179
+ with self.assertRaises(ServerError) as caught:
180
+ self.client().positions()
181
+ self.assertEqual(caught.exception.status, 500)
182
+
183
+ def test_unreachable_server_says_so(self):
184
+ with self.assertRaises(ReinsError) as caught:
185
+ Reins("http://127.0.0.1:9", "tok", timeout=2).limits()
186
+ self.assertIn("could not reach Reins", str(caught.exception))
187
+
188
+ def test_non_json_body_is_not_an_exception(self):
189
+ Stub.reply = (200, {})
190
+ self.assertEqual(self.client().limits(), {})
191
+
192
+ # --- sizing -------------------------------------------------------------
193
+
194
+ def test_size_for_risk_matches_the_engine(self):
195
+ # 1% away, $25 at risk -> $2,500 of notional.
196
+ self.assertAlmostEqual(Reins.size_for_risk(100, 99, 25), 2500)
197
+ # A wider stop means a smaller position, not a bigger loss.
198
+ self.assertAlmostEqual(Reins.size_for_risk(100, 98, 25), 1250)
199
+ # Direction does not matter.
200
+ self.assertAlmostEqual(Reins.size_for_risk(100, 101, 25), 2500)
201
+
202
+ def test_size_for_risk_rejects_nonsense(self):
203
+ for args in [(0, 99, 25), (100, 0, 25), (100, 99, 0), (100, 100, 25)]:
204
+ with self.assertRaises(ValueError):
205
+ Reins.size_for_risk(*args)
206
+
207
+ def test_from_env(self):
208
+ import os
209
+
210
+ os.environ["REINS_URL"] = "http://example.test:1234/"
211
+ os.environ["REINS_HTTP_TOKEN"] = "abc"
212
+ client = Reins.from_env()
213
+ self.assertEqual(client.base_url, "http://example.test:1234")
214
+ self.assertEqual(client.token, "abc")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ unittest.main()