talis-cli 0.1.0a1__py3-none-any.whl
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.
- talis/__init__.py +3 -0
- talis/api.py +470 -0
- talis/cli.py +70 -0
- talis/commands/__init__.py +1 -0
- talis/commands/_shared.py +152 -0
- talis/commands/auth.py +580 -0
- talis/commands/outcome.py +176 -0
- talis/commands/portfolio.py +153 -0
- talis/commands/snapshot.py +266 -0
- talis/commands/strategies.py +118 -0
- talis/commands/test_tenant.py +212 -0
- talis/commands/trade.py +168 -0
- talis/commands/wait.py +378 -0
- talis/config.py +160 -0
- talis/output.py +99 -0
- talis/paper.py +44 -0
- talis/symbols.py +63 -0
- talis_cli-0.1.0a1.dist-info/METADATA +99 -0
- talis_cli-0.1.0a1.dist-info/RECORD +22 -0
- talis_cli-0.1.0a1.dist-info/WHEEL +5 -0
- talis_cli-0.1.0a1.dist-info/entry_points.txt +2 -0
- talis_cli-0.1.0a1.dist-info/top_level.txt +1 -0
talis/__init__.py
ADDED
talis/api.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thin httpx wrapper for the Talis control plane.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Bearer auth from the stored credentials
|
|
6
|
+
- ``X-API-Key`` header for the device-flow polling endpoints (no auth)
|
|
7
|
+
- ``X-Request-Id`` correlation header on every outbound request
|
|
8
|
+
- Sensible timeouts
|
|
9
|
+
- A small ``APIError`` that surfaces ``status_code``, parsed ``detail``,
|
|
10
|
+
and the server-echoed ``request_id`` so callers can paste a single
|
|
11
|
+
short string into a log search and find both sides of the failure.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import uuid
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import urlparse
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from . import config
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
|
|
29
|
+
|
|
30
|
+
REQUEST_ID_HEADER = "X-Request-Id"
|
|
31
|
+
|
|
32
|
+
# Loopback hosts don't traverse the network; HTTP there is fine and we
|
|
33
|
+
# shouldn't add noise to legitimate dev work.
|
|
34
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
35
|
+
|
|
36
|
+
# Process-once flag: one stderr line per CLI invocation, not per Client.
|
|
37
|
+
_http_warning_emitted = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _warn_non_https(endpoint: str) -> None:
|
|
41
|
+
"""One-time warning if `endpoint` is HTTP over a non-loopback host.
|
|
42
|
+
|
|
43
|
+
The risk we're flagging: the X-API-Key header carries a Talis JWT and
|
|
44
|
+
sending it over plain HTTP leaks it to any network observer. Loopback
|
|
45
|
+
HTTP is exempt because the bytes never hit the network.
|
|
46
|
+
"""
|
|
47
|
+
global _http_warning_emitted
|
|
48
|
+
if _http_warning_emitted:
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
parsed = urlparse(endpoint)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return
|
|
54
|
+
if parsed.scheme == "https":
|
|
55
|
+
return
|
|
56
|
+
host = (parsed.hostname or "").lower()
|
|
57
|
+
if host in _LOOPBACK_HOSTS:
|
|
58
|
+
return
|
|
59
|
+
sys.stderr.write(
|
|
60
|
+
f"warning: endpoint {endpoint} is not HTTPS — your token may be "
|
|
61
|
+
"sent in plaintext.\n"
|
|
62
|
+
)
|
|
63
|
+
_http_warning_emitted = True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class APIError(Exception):
|
|
68
|
+
status_code: int
|
|
69
|
+
detail: str
|
|
70
|
+
body: Any = None
|
|
71
|
+
request_id: str = ""
|
|
72
|
+
|
|
73
|
+
def __post_init__(self):
|
|
74
|
+
super().__init__(self.detail)
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
77
|
+
# Include request_id in the string form so a user pasting the error
|
|
78
|
+
# message into a bug report carries the correlation key without
|
|
79
|
+
# having to know the attribute exists.
|
|
80
|
+
if self.request_id:
|
|
81
|
+
return f"HTTP {self.status_code} (req {self.request_id}): {self.detail}"
|
|
82
|
+
return f"HTTP {self.status_code}: {self.detail}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _generate_request_id() -> str:
|
|
86
|
+
"""Per-Client correlation token. Short, URL-safe, easily greppable.
|
|
87
|
+
|
|
88
|
+
The ``cli-`` prefix flags the source so a log diff against server-
|
|
89
|
+
generated IDs (which start with ``req-``) is one substring away.
|
|
90
|
+
"""
|
|
91
|
+
return f"cli-{uuid.uuid4().hex[:12]}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_request_id(explicit: str | None) -> str:
|
|
95
|
+
"""Pick the request_id for a Client lifetime.
|
|
96
|
+
|
|
97
|
+
Order: explicit constructor arg > ``TALIS_REQUEST_ID`` env var > new.
|
|
98
|
+
Env var lets a wrapping test runner pin one ID across multiple CLI
|
|
99
|
+
invocations so a higher-level scenario shows up as one logical thread
|
|
100
|
+
in the server logs.
|
|
101
|
+
"""
|
|
102
|
+
if explicit:
|
|
103
|
+
return explicit
|
|
104
|
+
from_env = (os.environ.get("TALIS_REQUEST_ID") or "").strip()
|
|
105
|
+
if from_env:
|
|
106
|
+
return from_env
|
|
107
|
+
return _generate_request_id()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Client:
|
|
111
|
+
"""One-shot synchronous client. Construct, call, discard."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
endpoint: str | None = None,
|
|
117
|
+
token: str | None = None,
|
|
118
|
+
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
|
|
119
|
+
request_id: str | None = None,
|
|
120
|
+
):
|
|
121
|
+
self.endpoint = (endpoint or config.endpoint_from_env()).rstrip("/")
|
|
122
|
+
self.token = token
|
|
123
|
+
# One correlation ID per Client. Each command typically constructs
|
|
124
|
+
# one Client, so the ID covers all requests for that command. A
|
|
125
|
+
# test runner spanning multiple commands can pin one ID via the
|
|
126
|
+
# ``TALIS_REQUEST_ID`` env var.
|
|
127
|
+
self.request_id = _resolve_request_id(request_id)
|
|
128
|
+
self.last_server_request_id = "" # populated after each call
|
|
129
|
+
_warn_non_https(self.endpoint)
|
|
130
|
+
self._client = httpx.Client(base_url=self.endpoint, timeout=timeout)
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
self._client.close()
|
|
134
|
+
|
|
135
|
+
# Context manager keeps the dispatch site terse.
|
|
136
|
+
def __enter__(self) -> Client:
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *exc) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
# ---- request helpers -------------------------------------------------- #
|
|
143
|
+
|
|
144
|
+
def _headers(self, auth: bool) -> dict[str, str]:
|
|
145
|
+
h: dict[str, str] = {
|
|
146
|
+
"Accept": "application/json",
|
|
147
|
+
REQUEST_ID_HEADER: self.request_id,
|
|
148
|
+
}
|
|
149
|
+
if auth and self.token:
|
|
150
|
+
# X-API-Key carries the Talis JWT in the existing API contract.
|
|
151
|
+
# Authorization: Bearer is reserved for Privy tokens on the
|
|
152
|
+
# /auth/token + /auth/device/approve paths.
|
|
153
|
+
h["X-API-Key"] = self.token
|
|
154
|
+
return h
|
|
155
|
+
|
|
156
|
+
def _request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
params: dict[str, Any] | None = None,
|
|
162
|
+
json: Any = None,
|
|
163
|
+
auth: bool = True,
|
|
164
|
+
bearer: str | None = None,
|
|
165
|
+
) -> Any:
|
|
166
|
+
headers = self._headers(auth)
|
|
167
|
+
if bearer:
|
|
168
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
|
169
|
+
resp = self._client.request(method, path, params=params, json=json, headers=headers)
|
|
170
|
+
# Always record the server's request_id (echo of ours or, in rare
|
|
171
|
+
# cases like cache-served responses, something different) so callers
|
|
172
|
+
# have something to paste into a log search even on success.
|
|
173
|
+
self.last_server_request_id = resp.headers.get(REQUEST_ID_HEADER, "") or ""
|
|
174
|
+
if resp.status_code >= 400:
|
|
175
|
+
try:
|
|
176
|
+
body = resp.json()
|
|
177
|
+
detail = (
|
|
178
|
+
body.get("detail")
|
|
179
|
+
if isinstance(body, dict)
|
|
180
|
+
else str(body)
|
|
181
|
+
)
|
|
182
|
+
if not isinstance(detail, str):
|
|
183
|
+
detail = str(detail)
|
|
184
|
+
except ValueError:
|
|
185
|
+
body = None
|
|
186
|
+
detail = resp.text or f"HTTP {resp.status_code}"
|
|
187
|
+
raise APIError(
|
|
188
|
+
resp.status_code, detail, body,
|
|
189
|
+
request_id=self.last_server_request_id,
|
|
190
|
+
)
|
|
191
|
+
if not resp.content:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
return resp.json()
|
|
195
|
+
except ValueError:
|
|
196
|
+
return resp.text
|
|
197
|
+
|
|
198
|
+
# ---- public endpoints ------------------------------------------------- #
|
|
199
|
+
|
|
200
|
+
# Device flow
|
|
201
|
+
def device_code(self, *, client_name: str, scope: str) -> dict:
|
|
202
|
+
return self._request(
|
|
203
|
+
"POST", "/auth/device/code",
|
|
204
|
+
json={"client_name": client_name, "scope": scope},
|
|
205
|
+
auth=False,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def device_token(self, *, device_code: str) -> dict:
|
|
209
|
+
return self._request(
|
|
210
|
+
"POST", "/auth/device/token",
|
|
211
|
+
json={"device_code": device_code},
|
|
212
|
+
auth=False,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def device_approve(self, *, user_code: str, scope: str | None = None) -> dict:
|
|
216
|
+
payload: dict[str, Any] = {"user_code": user_code}
|
|
217
|
+
if scope:
|
|
218
|
+
payload["scope"] = scope
|
|
219
|
+
return self._request(
|
|
220
|
+
"POST", "/auth/device/approve",
|
|
221
|
+
json=payload,
|
|
222
|
+
auth=True,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# OAuth loopback (PKCE) flow — the codex-style primary login.
|
|
226
|
+
def oauth_token(self, *, code: str, code_verifier: str, redirect_uri: str) -> dict:
|
|
227
|
+
"""Exchange a loopback-captured authorization code for the Talis JWT.
|
|
228
|
+
|
|
229
|
+
PKCE is the proof of possession — no auth header. The server validates
|
|
230
|
+
SHA-256(code_verifier) == the stored code_challenge before minting.
|
|
231
|
+
"""
|
|
232
|
+
return self._request(
|
|
233
|
+
"POST", "/auth/oauth/token",
|
|
234
|
+
json={
|
|
235
|
+
"grant_type": "authorization_code",
|
|
236
|
+
"code": code,
|
|
237
|
+
"code_verifier": code_verifier,
|
|
238
|
+
"redirect_uri": redirect_uri,
|
|
239
|
+
},
|
|
240
|
+
auth=False,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Sessions
|
|
244
|
+
def list_sessions(self, *, tenant_id: str) -> dict:
|
|
245
|
+
return self._request("GET", "/auth/sessions", params={"tenant_id": tenant_id})
|
|
246
|
+
|
|
247
|
+
def revoke_session(self, *, tenant_id: str, jti_prefix: str) -> dict:
|
|
248
|
+
return self._request(
|
|
249
|
+
"DELETE", f"/auth/sessions/{jti_prefix}",
|
|
250
|
+
params={"tenant_id": tenant_id},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def revoke_other_sessions(self, *, tenant_id: str) -> dict:
|
|
254
|
+
return self._request("DELETE", "/auth/sessions", params={"tenant_id": tenant_id})
|
|
255
|
+
|
|
256
|
+
# Read surface
|
|
257
|
+
def get_portfolio(self, *, tenant_id: str, mode: str | None = None) -> dict:
|
|
258
|
+
# The route is GET /portfolio/{tenant_id} — tenant_id is bound via the
|
|
259
|
+
# path, not a query param. No need to send it twice.
|
|
260
|
+
params: dict[str, Any] = {}
|
|
261
|
+
if mode:
|
|
262
|
+
params["mode"] = mode
|
|
263
|
+
return self._request("GET", f"/portfolio/{tenant_id}", params=params or None)
|
|
264
|
+
|
|
265
|
+
def get_balance(self, *, tenant_id: str, exchange: str = "hyperliquid_perpetual") -> dict:
|
|
266
|
+
return self._request(
|
|
267
|
+
"GET", "/account/balance",
|
|
268
|
+
params={"tenant_id": tenant_id, "exchange": exchange},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def get_positions(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
tenant_id: str,
|
|
275
|
+
exchange: str = "hyperliquid_perpetual",
|
|
276
|
+
mode: str | None = None,
|
|
277
|
+
) -> dict:
|
|
278
|
+
params: dict[str, Any] = {"tenant_id": tenant_id, "exchange": exchange}
|
|
279
|
+
if mode:
|
|
280
|
+
params["mode"] = mode
|
|
281
|
+
return self._request("GET", "/positions", params=params)
|
|
282
|
+
|
|
283
|
+
def get_open_orders(
|
|
284
|
+
self,
|
|
285
|
+
*,
|
|
286
|
+
tenant_id: str,
|
|
287
|
+
exchange: str = "hyperliquid_perpetual",
|
|
288
|
+
) -> dict:
|
|
289
|
+
"""Open orders for this tenant. Used by ``talis wait order`` and
|
|
290
|
+
``talis snapshot``. Server returns ``{"open": [...]}``."""
|
|
291
|
+
return self._request(
|
|
292
|
+
"GET", "/orders/open",
|
|
293
|
+
params={"tenant_id": tenant_id, "exchange": exchange},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# -----------------------------------------------------------------
|
|
297
|
+
# Admin: test-tenant lifecycle (PR 3). All three require admin key.
|
|
298
|
+
# The CLI surfaces them via ``talis test-tenant {create,delete,list}``.
|
|
299
|
+
# -----------------------------------------------------------------
|
|
300
|
+
def create_test_tenant(
|
|
301
|
+
self,
|
|
302
|
+
*,
|
|
303
|
+
label: str = "",
|
|
304
|
+
initial_balance_usd: float = 10000.0,
|
|
305
|
+
ttl_seconds: int = 3600,
|
|
306
|
+
exchange: str = "hyperliquid_perpetual",
|
|
307
|
+
) -> dict:
|
|
308
|
+
return self._request(
|
|
309
|
+
"POST", "/admin/test-tenants",
|
|
310
|
+
json={
|
|
311
|
+
"label": label,
|
|
312
|
+
"initial_balance_usd": initial_balance_usd,
|
|
313
|
+
"ttl_seconds": ttl_seconds,
|
|
314
|
+
"exchange": exchange,
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def list_test_tenants(self) -> dict:
|
|
319
|
+
return self._request("GET", "/admin/test-tenants")
|
|
320
|
+
|
|
321
|
+
def delete_test_tenant(
|
|
322
|
+
self, *, tenant_id: str, exchange: str | None = None,
|
|
323
|
+
) -> dict:
|
|
324
|
+
"""Server resolves the exchange from metadata stored at
|
|
325
|
+
provision time. Pass ``exchange`` only as an explicit override
|
|
326
|
+
for the rare case where metadata expired (7-day TTL) and the
|
|
327
|
+
operator knows the original exchange."""
|
|
328
|
+
params: dict[str, Any] = {}
|
|
329
|
+
if exchange:
|
|
330
|
+
params["exchange"] = exchange
|
|
331
|
+
return self._request(
|
|
332
|
+
"DELETE", f"/admin/test-tenants/{tenant_id}",
|
|
333
|
+
params=params or None,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Strategies
|
|
337
|
+
def list_strategies(self, *, tenant_id: str) -> list[dict] | dict:
|
|
338
|
+
return self._request("GET", "/strategies", params={"tenant_id": tenant_id})
|
|
339
|
+
|
|
340
|
+
def get_strategy(self, *, tenant_id: str, strategy_id: str) -> dict:
|
|
341
|
+
return self._request(
|
|
342
|
+
"GET", f"/strategies/{strategy_id}",
|
|
343
|
+
params={"tenant_id": tenant_id},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def stop_strategy(self, *, tenant_id: str, strategy_id: str) -> dict:
|
|
347
|
+
return self._request(
|
|
348
|
+
"POST", f"/strategies/{strategy_id}/stop",
|
|
349
|
+
params={"tenant_id": tenant_id},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def delete_strategy(self, *, tenant_id: str, strategy_id: str) -> dict:
|
|
353
|
+
return self._request(
|
|
354
|
+
"DELETE", f"/strategies/{strategy_id}",
|
|
355
|
+
params={"tenant_id": tenant_id},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def create_strategy(self, *, body: dict) -> dict:
|
|
359
|
+
return self._request("POST", "/strategies", json=body)
|
|
360
|
+
|
|
361
|
+
# HIP-4 outcome markets
|
|
362
|
+
def list_outcomes(
|
|
363
|
+
self,
|
|
364
|
+
*,
|
|
365
|
+
limit: int = 50,
|
|
366
|
+
offset: int = 0,
|
|
367
|
+
include_settled: bool = False,
|
|
368
|
+
network: str | None = None,
|
|
369
|
+
) -> dict:
|
|
370
|
+
"""GET /outcomes — browse live HIP-4 outcome markets.
|
|
371
|
+
|
|
372
|
+
``network='testnet'`` browses the testnet catalog (served from an
|
|
373
|
+
isolated cache); omit it (or 'mainnet') for production markets.
|
|
374
|
+
"""
|
|
375
|
+
params: dict[str, Any] = {
|
|
376
|
+
"limit": limit,
|
|
377
|
+
"offset": offset,
|
|
378
|
+
"include_settled": str(include_settled).lower(),
|
|
379
|
+
}
|
|
380
|
+
if network is not None:
|
|
381
|
+
params["network"] = network
|
|
382
|
+
return self._request("GET", "/outcomes", params=params)
|
|
383
|
+
|
|
384
|
+
# Direct orders (the iOS manual-trade surface)
|
|
385
|
+
def place_order(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
tenant_id: str,
|
|
389
|
+
coin: str,
|
|
390
|
+
side: str,
|
|
391
|
+
size: float | str,
|
|
392
|
+
size_mode: str = "usd",
|
|
393
|
+
order_type: str = "market",
|
|
394
|
+
price: float | str | None = None,
|
|
395
|
+
is_spot: bool = False,
|
|
396
|
+
reduce_only: bool = False,
|
|
397
|
+
leverage: int | None = None,
|
|
398
|
+
slippage: float | None = None,
|
|
399
|
+
network: str | None = None,
|
|
400
|
+
) -> dict:
|
|
401
|
+
"""POST /trading/orders — place a direct market/limit order.
|
|
402
|
+
|
|
403
|
+
This is the EXACT endpoint the iOS app uses for manual trades, including
|
|
404
|
+
HIP-4 outcome books (pass an outcome ``#<encoding>`` coin). Unlike
|
|
405
|
+
``buy``/``sell`` (which create one-shot strategies via /strategies), this
|
|
406
|
+
exercises the manual order path end-to-end, so it's the faithful harness
|
|
407
|
+
for validating manual outcome trading.
|
|
408
|
+
"""
|
|
409
|
+
body: dict[str, Any] = {
|
|
410
|
+
"coin": coin,
|
|
411
|
+
"side": side,
|
|
412
|
+
"size": str(size),
|
|
413
|
+
"size_mode": size_mode,
|
|
414
|
+
"order_type": order_type,
|
|
415
|
+
"is_spot": is_spot,
|
|
416
|
+
"reduce_only": reduce_only,
|
|
417
|
+
}
|
|
418
|
+
if price is not None:
|
|
419
|
+
body["price"] = str(price)
|
|
420
|
+
if leverage is not None:
|
|
421
|
+
body["leverage"] = leverage
|
|
422
|
+
if slippage is not None:
|
|
423
|
+
body["slippage"] = slippage
|
|
424
|
+
if network is not None:
|
|
425
|
+
body["network"] = network
|
|
426
|
+
return self._request(
|
|
427
|
+
"POST", "/trading/orders",
|
|
428
|
+
params={"tenant_id": tenant_id},
|
|
429
|
+
json=body,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Positions
|
|
433
|
+
def close_position(
|
|
434
|
+
self,
|
|
435
|
+
*,
|
|
436
|
+
tenant_id: str,
|
|
437
|
+
symbol: str,
|
|
438
|
+
percentage: float = 100.0,
|
|
439
|
+
trading_pair: str | None = None,
|
|
440
|
+
exchange: str = "hyperliquid_perpetual",
|
|
441
|
+
mode: str | None = None,
|
|
442
|
+
) -> dict:
|
|
443
|
+
"""POST /positions/{SYMBOL}/close. Server resolves side + enforces
|
|
444
|
+
reduce_only based on the current position state.
|
|
445
|
+
|
|
446
|
+
``mode="paper"`` short-circuits to the isolated paper executor and
|
|
447
|
+
will refuse to touch real positions even if the symbol exists in
|
|
448
|
+
real state. See engine/api/routes/positions.py:_paper_close_position.
|
|
449
|
+
"""
|
|
450
|
+
body: dict = {"percentage": percentage}
|
|
451
|
+
if trading_pair:
|
|
452
|
+
body["trading_pair"] = trading_pair
|
|
453
|
+
params: dict[str, Any] = {"tenant_id": tenant_id, "exchange": exchange}
|
|
454
|
+
if mode:
|
|
455
|
+
params["mode"] = mode
|
|
456
|
+
return self._request(
|
|
457
|
+
"POST", f"/positions/{symbol.upper()}/close",
|
|
458
|
+
params=params,
|
|
459
|
+
json=body,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@contextmanager
|
|
464
|
+
def client_from_credentials(creds: config.Credentials):
|
|
465
|
+
"""Convenience: ``with client_from_credentials(creds) as c: ...``."""
|
|
466
|
+
c = Client(endpoint=creds.endpoint, token=creds.token)
|
|
467
|
+
try:
|
|
468
|
+
yield c
|
|
469
|
+
finally:
|
|
470
|
+
c.close()
|
talis/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Talis CLI entry point.
|
|
3
|
+
|
|
4
|
+
`talis` resolves to ``main()`` via the project.scripts entry in pyproject.toml.
|
|
5
|
+
|
|
6
|
+
Command registration here is explicit: each top-level command function is
|
|
7
|
+
imported from its module and decorated with ``app.command(...)``. We avoid
|
|
8
|
+
reaching into ``Typer.registered_commands`` because that's a private surface
|
|
9
|
+
and would break on a Typer upgrade.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from . import __version__, output
|
|
17
|
+
from .commands import auth, trade
|
|
18
|
+
from .commands.auth import sessions_app
|
|
19
|
+
from .commands.outcome import outcome_app
|
|
20
|
+
from .commands.portfolio import portfolio_app
|
|
21
|
+
from .commands.snapshot import snapshot_app
|
|
22
|
+
from .commands.strategies import strategies_app
|
|
23
|
+
from .commands.test_tenant import test_tenant_app
|
|
24
|
+
from .commands.wait import wait_app
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="talis",
|
|
28
|
+
help="Command-line client for the Talis trading platform.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
add_completion=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# --- top-level commands --------------------------------------------------- #
|
|
34
|
+
# Each function is defined as a plain function in its module (no module-level
|
|
35
|
+
# Typer decoration), then registered here. Keeps the call graph easy to follow
|
|
36
|
+
# from cli.py and avoids the "where is this command actually registered?"
|
|
37
|
+
# riddle that ad-hoc sub-typer mounting creates.
|
|
38
|
+
app.command("login")(auth.login)
|
|
39
|
+
app.command("approve")(auth.approve)
|
|
40
|
+
app.command("logout")(auth.logout)
|
|
41
|
+
app.command("whoami")(auth.whoami)
|
|
42
|
+
app.command("buy")(trade.buy)
|
|
43
|
+
app.command("sell")(trade.sell)
|
|
44
|
+
app.command("close")(trade.close)
|
|
45
|
+
|
|
46
|
+
# --- subgroups ------------------------------------------------------------ #
|
|
47
|
+
app.add_typer(sessions_app, name="sessions")
|
|
48
|
+
app.add_typer(strategies_app, name="strategies")
|
|
49
|
+
app.add_typer(portfolio_app, name="portfolio")
|
|
50
|
+
app.add_typer(wait_app, name="wait")
|
|
51
|
+
app.add_typer(snapshot_app, name="snapshot")
|
|
52
|
+
app.add_typer(test_tenant_app, name="test-tenant")
|
|
53
|
+
app.add_typer(outcome_app, name="outcome")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command("version")
|
|
57
|
+
def version(json_: bool = typer.Option(False, "--json", help="JSON output.")) -> None:
|
|
58
|
+
"""Print the CLI version."""
|
|
59
|
+
if output.should_render_json(json_):
|
|
60
|
+
output.emit_json({"version": __version__})
|
|
61
|
+
else:
|
|
62
|
+
typer.echo(__version__)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
app()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__": # pragma: no cover
|
|
70
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Talis CLI subcommands."""
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Shared helpers used by every command module.
|
|
2
|
+
|
|
3
|
+
Kept in a leading-underscore module so it doesn't show up in the public typer
|
|
4
|
+
subcommand tree. Importers should pull directly: ``from . import _shared``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json as _json
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from .. import config, output
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def require_creds() -> config.Credentials:
|
|
20
|
+
"""Return the active credentials, or exit 1 if not signed in.
|
|
21
|
+
|
|
22
|
+
Precedence:
|
|
23
|
+
1. ``JARVIS_TOKEN`` environment variable — for non-interactive runs
|
|
24
|
+
(CI, integration tests, Claude Code with a freshly minted admin
|
|
25
|
+
token). Validated locally for expiry; the JWT decode is unsigned,
|
|
26
|
+
which is safe — the server validates on receipt.
|
|
27
|
+
2. The on-disk credentials file written by ``talis login``.
|
|
28
|
+
|
|
29
|
+
The env-var path produces an ephemeral ``Credentials`` (never saved).
|
|
30
|
+
A real ``talis login`` is still the right path for a long-running
|
|
31
|
+
workstation; this is for headless agents.
|
|
32
|
+
"""
|
|
33
|
+
env_token = (os.environ.get("JARVIS_TOKEN") or "").strip()
|
|
34
|
+
if env_token:
|
|
35
|
+
creds = creds_from_jwt(env_token, source="JARVIS_TOKEN env var")
|
|
36
|
+
return creds
|
|
37
|
+
|
|
38
|
+
creds = config.load()
|
|
39
|
+
if not creds:
|
|
40
|
+
output.error("not signed in. Run `talis login`.")
|
|
41
|
+
raise typer.Exit(code=1)
|
|
42
|
+
return creds
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def jti_from_jwt(token: str) -> str:
|
|
46
|
+
"""Decode the jti claim from a JWT payload WITHOUT verifying the signature.
|
|
47
|
+
|
|
48
|
+
Safe: this only inspects a token the CLI itself holds, to extract a claim
|
|
49
|
+
we'll send back to the server for revocation. The server validates the
|
|
50
|
+
signature on receipt — a forged/corrupt JWT cannot revoke anyone else's
|
|
51
|
+
session even if its claims look plausible. Returns "" if the token doesn't
|
|
52
|
+
decode or the jti claim is absent.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
payload_b64 = token.split(".", 2)[1]
|
|
56
|
+
padding = "=" * (-len(payload_b64) % 4)
|
|
57
|
+
payload = _json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
|
58
|
+
jti = payload.get("jti", "")
|
|
59
|
+
return jti if isinstance(jti, str) else ""
|
|
60
|
+
except (ValueError, TypeError, IndexError):
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def decode_jwt_unverified(token: str) -> dict:
|
|
65
|
+
"""Decode JWT payload claims WITHOUT signature verification.
|
|
66
|
+
|
|
67
|
+
The result is used to populate a local ``Credentials`` from a token
|
|
68
|
+
obtained out-of-band (env var, ``talis login --token``). The server
|
|
69
|
+
validates the signature on every request — a forged token never
|
|
70
|
+
survives there. Locally we just need to read the claims to set
|
|
71
|
+
sensible expiry / scope / tenant on the credentials struct.
|
|
72
|
+
|
|
73
|
+
Raises ``ValueError`` on a malformed token. Callers should treat
|
|
74
|
+
that as "do not save this credential".
|
|
75
|
+
"""
|
|
76
|
+
parts = token.split(".")
|
|
77
|
+
if len(parts) != 3:
|
|
78
|
+
raise ValueError("not a JWT (expected three dot-separated segments)")
|
|
79
|
+
payload_b64 = parts[1]
|
|
80
|
+
padding = "=" * (-len(payload_b64) % 4)
|
|
81
|
+
try:
|
|
82
|
+
return _json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
|
|
83
|
+
except (ValueError, TypeError) as exc:
|
|
84
|
+
raise ValueError(f"could not decode JWT payload: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def creds_from_jwt(
|
|
88
|
+
token: str,
|
|
89
|
+
*,
|
|
90
|
+
endpoint: str | None = None,
|
|
91
|
+
client_name: str = "Talis CLI (token)",
|
|
92
|
+
source: str = "token",
|
|
93
|
+
) -> config.Credentials:
|
|
94
|
+
"""Build a ``Credentials`` from a raw JWT string.
|
|
95
|
+
|
|
96
|
+
Used by both ``talis login --token <jwt>`` (saved) and the
|
|
97
|
+
``JARVIS_TOKEN`` env-var path (ephemeral). Raises ``typer.Exit(1)``
|
|
98
|
+
on malformed or already-expired tokens — silently accepting an
|
|
99
|
+
expired token would let the very next request fail with a confusing
|
|
100
|
+
401.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
claims = decode_jwt_unverified(token)
|
|
104
|
+
except ValueError as exc:
|
|
105
|
+
output.error(f"invalid {source}: {exc}")
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
tenant_id = claims.get("sub") or claims.get("tenant_id")
|
|
109
|
+
if not isinstance(tenant_id, str) or not tenant_id:
|
|
110
|
+
output.error(f"invalid {source}: missing `sub` / `tenant_id` claim")
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
exp = claims.get("exp")
|
|
114
|
+
if isinstance(exp, (int, float)):
|
|
115
|
+
if exp <= datetime.now(timezone.utc).timestamp():
|
|
116
|
+
output.error(
|
|
117
|
+
f"{source} is already expired. Mint a new one and try again."
|
|
118
|
+
)
|
|
119
|
+
raise typer.Exit(code=1)
|
|
120
|
+
expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).strftime(
|
|
121
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
# No exp claim is unusual for our tokens but not strictly invalid
|
|
125
|
+
# — let the server enforce. Empty string disables the local check
|
|
126
|
+
# in config._is_expired.
|
|
127
|
+
expires_at = ""
|
|
128
|
+
|
|
129
|
+
jti = claims.get("jti", "")
|
|
130
|
+
if not isinstance(jti, str):
|
|
131
|
+
jti = ""
|
|
132
|
+
scope = claims.get("scope", "trade")
|
|
133
|
+
if not isinstance(scope, str):
|
|
134
|
+
scope = "trade"
|
|
135
|
+
wallet_address = claims.get("wallet_address")
|
|
136
|
+
if not isinstance(wallet_address, str):
|
|
137
|
+
wallet_address = None
|
|
138
|
+
wallet_id = claims.get("wallet_id")
|
|
139
|
+
if not isinstance(wallet_id, str):
|
|
140
|
+
wallet_id = None
|
|
141
|
+
|
|
142
|
+
return config.Credentials(
|
|
143
|
+
endpoint=(endpoint or config.endpoint_from_env()),
|
|
144
|
+
tenant_id=tenant_id,
|
|
145
|
+
token=token,
|
|
146
|
+
expires_at=expires_at,
|
|
147
|
+
scope=scope,
|
|
148
|
+
jti_prefix=jti[:8],
|
|
149
|
+
wallet_address=wallet_address,
|
|
150
|
+
wallet_id=wallet_id,
|
|
151
|
+
client_name=client_name,
|
|
152
|
+
)
|