qtsurfer-sdk 0.2.0__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.
@@ -0,0 +1,42 @@
1
+ """QTSurfer Python SDK.
2
+
3
+ Public surface::
4
+
5
+ from qtsurfer_sdk import auth, AuthenticatedSession, TokenStore, InMemoryTokenStore
6
+ from qtsurfer_sdk import QTSAuthError, QTSError
7
+
8
+ The :func:`auth` helper is the recommended entry point: one call exchanges
9
+ a long-lived API key for a short-lived JWT and returns an
10
+ :class:`AuthenticatedSession` that transparently refreshes the JWT on 401.
11
+ """
12
+
13
+ from qtsurfer_sdk._errors import (
14
+ QTSAuthError,
15
+ QTSCanceledError,
16
+ QTSCompileError,
17
+ QTSDownloadError,
18
+ QTSError,
19
+ QTSExecutionError,
20
+ QTSPreparationError,
21
+ QTSTimeoutError,
22
+ QTSUploadError,
23
+ )
24
+ from qtsurfer_sdk._session import APIKEY_ENV_VAR, AuthenticatedSession, auth
25
+ from qtsurfer_sdk._tokens import InMemoryTokenStore, TokenStore
26
+
27
+ __all__ = [
28
+ "APIKEY_ENV_VAR",
29
+ "AuthenticatedSession",
30
+ "InMemoryTokenStore",
31
+ "QTSAuthError",
32
+ "QTSCanceledError",
33
+ "QTSCompileError",
34
+ "QTSDownloadError",
35
+ "QTSError",
36
+ "QTSExecutionError",
37
+ "QTSPreparationError",
38
+ "QTSTimeoutError",
39
+ "QTSUploadError",
40
+ "TokenStore",
41
+ "auth",
42
+ ]
@@ -0,0 +1,71 @@
1
+ """SDK error hierarchy.
2
+
3
+ Mirrors the conventions used by the Java / TypeScript SDKs so error
4
+ handling looks idiomatic across the three languages:
5
+
6
+ * ``QTSError`` is the catch-all base.
7
+ * ``QTSAuthError`` covers missing-apikey and JWT-exchange failures.
8
+ * ``QTSUploadError`` covers credential-free dataset uploads.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class QTSError(Exception):
15
+ """Root of the QTSurfer SDK exception hierarchy.
16
+
17
+ Carries an optional HTTP status code (when the underlying transport
18
+ surfaced one) to make refresh-on-401 and custom error handling easy.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ status: int | None = None,
26
+ cause: BaseException | None = None,
27
+ ) -> None:
28
+ super().__init__(message)
29
+ self.status = status
30
+ if cause is not None:
31
+ self.__cause__ = cause
32
+
33
+
34
+ class QTSAuthError(QTSError):
35
+ """Raised when ``auth()`` cannot obtain a JWT.
36
+
37
+ Triggered by:
38
+
39
+ * No API key supplied (neither argument nor ``QTSURFER_APIKEY`` env var).
40
+ * ``POST /v1/auth/token`` returned a 4xx (typically 401 — invalid /
41
+ revoked / expired apikey).
42
+ * The endpoint returned a 2xx but the body was empty or undecodeable.
43
+ """
44
+
45
+
46
+ class QTSCompileError(QTSError):
47
+ """Raised when ``POST /strategy`` cannot compile/register a strategy."""
48
+
49
+
50
+ class QTSPreparationError(QTSError):
51
+ """Raised when preparing market data (``prepare_backtest``) fails."""
52
+
53
+
54
+ class QTSExecutionError(QTSError):
55
+ """Raised when a backtest/sweep execution fails or errors mid-run."""
56
+
57
+
58
+ class QTSTimeoutError(QTSError):
59
+ """Raised when a poll loop reaches its deadline before a job resolves."""
60
+
61
+
62
+ class QTSCanceledError(QTSError):
63
+ """Raised when an in-flight run is canceled (or the caller aborts)."""
64
+
65
+
66
+ class QTSDownloadError(QTSError):
67
+ """Raised when downloading tickers/klines fails."""
68
+
69
+
70
+ class QTSUploadError(QTSError):
71
+ """Raised when a direct dataset upload fails without exposing its presigned URL."""
@@ -0,0 +1,369 @@
1
+ """``auth(apikey)`` helper and :class:`AuthenticatedSession`.
2
+
3
+ One-call setup: exchange a long-lived API key for a short-lived JWT,
4
+ returning a session that transparently refreshes the JWT on 401 and
5
+ lets callers plug in a custom :class:`TokenStore`.
6
+
7
+ Mirrors the surface of the Java and TypeScript SDKs so adopters get the
8
+ same ergonomics across languages.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import threading
15
+ from collections.abc import Callable
16
+ from typing import TypeVar
17
+
18
+ from qtsurfer.api.client._generated import AuthenticatedClient as _GenAuthClient
19
+ from qtsurfer.api.client._generated.api.auth.authenticate import (
20
+ sync_detailed as _auth_sync_detailed,
21
+ )
22
+ from qtsurfer.api.client._generated.models import AuthTokenError, AuthTokenResponse
23
+ from qtsurfer.api.client._generated.types import Response
24
+
25
+ from qtsurfer_sdk._errors import QTSAuthError
26
+ from qtsurfer_sdk._tokens import InMemoryTokenStore, TokenStore
27
+
28
+ APIKEY_ENV_VAR = "QTSURFER_APIKEY"
29
+ DEFAULT_BASE_URL = "https://api.qtsurfer.com/v1"
30
+
31
+ T = TypeVar("T")
32
+
33
+
34
+ class AuthenticatedSession:
35
+ """Authenticated SDK session.
36
+
37
+ Returned by :func:`auth`. Owns a JWT (in memory by default, or in
38
+ the provided :class:`TokenStore`), exposes the underlying
39
+ ``qtsurfer-api-client`` ``AuthenticatedClient`` for typed API calls,
40
+ and transparently re-exchanges the API key for a fresh JWT when a
41
+ call returns 401.
42
+
43
+ Refresh policy: one shot — refresh once, retry the wrapped call
44
+ once, surface the error if the retry also 401s. No loops.
45
+
46
+ Thread-safety: :meth:`refresh` is guarded by an internal lock so
47
+ concurrent callers piggy-back on a single in-flight mint.
48
+
49
+ The returned :attr:`client` is the *same* object whose ``token``
50
+ attribute is mutated on refresh, so any in-flight code holding a
51
+ reference to it continues to see the latest JWT.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ apikey: str,
57
+ *,
58
+ base_url: str = DEFAULT_BASE_URL,
59
+ store: TokenStore | None = None,
60
+ ) -> None:
61
+ self._apikey = apikey
62
+ self.base_url = base_url
63
+ self.store: TokenStore = store if store is not None else InMemoryTokenStore()
64
+ self._lock = threading.RLock()
65
+ self._cached: AuthTokenResponse | None = None
66
+ # The mint client carries the apikey in the X-API-Key header (the
67
+ # generated client treats `token` as the value of `auth_header_name`,
68
+ # with an empty prefix giving us a raw `X-API-Key: <apikey>` header).
69
+ self._mint_client = _GenAuthClient(
70
+ base_url=base_url,
71
+ token=apikey,
72
+ prefix="",
73
+ auth_header_name="X-API-Key",
74
+ )
75
+ # The session client carries the JWT — populated on first auth.
76
+ # Use an empty placeholder token until `ensure_token` runs; the
77
+ # auth_header_name + prefix defaults give `Authorization: Bearer ...`.
78
+ self.client = _GenAuthClient(base_url=base_url, token="")
79
+
80
+ # ---- Token lifecycle ----
81
+
82
+ @property
83
+ def token(self) -> AuthTokenResponse | None:
84
+ """Currently cached token, or ``None`` if no exchange has happened."""
85
+ return self._cached
86
+
87
+ def refresh(self) -> AuthTokenResponse:
88
+ """Force a fresh JWT exchange. Bypasses the cache; writes the new
89
+ token to :attr:`store` and updates :attr:`client` so subsequent
90
+ calls carry the new Bearer header.
91
+ """
92
+ with self._lock:
93
+ resp: Response[AuthTokenResponse | AuthTokenError | None] = _auth_sync_detailed(
94
+ client=self._mint_client
95
+ )
96
+ if resp.status_code != 200 or not isinstance(resp.parsed, AuthTokenResponse):
97
+ raise QTSAuthError(
98
+ f"auth() failed: HTTP {resp.status_code}",
99
+ status=int(resp.status_code),
100
+ )
101
+ self._cached = resp.parsed
102
+ self.store.save(resp.parsed)
103
+ self._apply_bearer(resp.parsed.access_token)
104
+ return resp.parsed
105
+
106
+ def ensure_token(self) -> AuthTokenResponse:
107
+ """Return the cached token, seeding from :attr:`store` on first
108
+ use and minting a new one if neither cache nor store hold one.
109
+ """
110
+ if self._cached is not None:
111
+ return self._cached
112
+ stored = self.store.load()
113
+ if stored is not None:
114
+ self._cached = stored
115
+ self._apply_bearer(stored.access_token)
116
+ return stored
117
+ return self.refresh()
118
+
119
+ def clear(self) -> None:
120
+ """Drop the cached token (in memory and in the store)."""
121
+ with self._lock:
122
+ self._cached = None
123
+ self.store.clear()
124
+ self._apply_bearer("")
125
+
126
+ def _apply_bearer(self, token: str) -> None:
127
+ """Update both the api-client's ``token`` attribute *and* the live
128
+ httpx.Client's headers, so a refresh swaps the Bearer header even
129
+ after the client has been used (the api-client builds the header
130
+ lazily on first ``get_httpx_client()`` and caches the dict — without
131
+ this, refresh would only land on a brand-new client).
132
+ """
133
+ self.client.token = token
134
+ # Use the private accessor (no public one in the generated client).
135
+ httpx_client = getattr(self.client, "_client", None)
136
+ if httpx_client is not None:
137
+ if token:
138
+ httpx_client.headers["Authorization"] = f"Bearer {token}"
139
+ else:
140
+ httpx_client.headers.pop("Authorization", None)
141
+
142
+ # ---- Refresh-on-401 plumbing ----
143
+
144
+ def call(self, fn: Callable[[_GenAuthClient], T]) -> T:
145
+ """Run ``fn(self.client)`` once, refresh-on-401 once on failure.
146
+
147
+ ``fn`` receives the session's underlying ``AuthenticatedClient``
148
+ (its ``token`` attribute is kept in sync on every refresh). If
149
+ the call returns a ``Response`` whose ``status_code`` is 401, or
150
+ raises an exception that surfaces a 401, the SDK refreshes the
151
+ JWT once and re-invokes ``fn`` exactly once.
152
+
153
+ A second 401 is surfaced to the caller as-is — no retry loops.
154
+ """
155
+ self.ensure_token()
156
+ try:
157
+ result = fn(self.client)
158
+ except Exception as exc:
159
+ if not _is_unauthorized(exc):
160
+ raise
161
+ self._cached = None
162
+ self.refresh()
163
+ return fn(self.client)
164
+ if _result_is_unauthorized(result):
165
+ self._cached = None
166
+ self.refresh()
167
+ return fn(self.client)
168
+ return result
169
+
170
+ # ---- High-level workflows (parity with sdk-java / sdk-ts) ----
171
+
172
+ def list_exchanges(self):
173
+ """List the exchanges the platform serves."""
174
+ from qtsurfer_sdk import _workflows as wf
175
+
176
+ return wf.list_exchanges(self)
177
+
178
+ def list_instruments(self, exchange_id: str, segment: str | None = None):
179
+ """List an exchange's instruments, optionally for a segment."""
180
+ from qtsurfer_sdk import _workflows as wf
181
+
182
+ return wf.list_instruments(self, exchange_id, segment)
183
+
184
+ def compile_strategy(self, source: str):
185
+ """Compile and register a Java strategy."""
186
+ from qtsurfer_sdk import _workflows as wf
187
+
188
+ return wf.compile_strategy(self, source)
189
+
190
+ def validate_strategy(self, strategy_id: str):
191
+ from qtsurfer_sdk import _workflows as wf
192
+
193
+ return wf.validate_strategy(self, strategy_id)
194
+
195
+ def get_strategy(self, strategy_id: str):
196
+ from qtsurfer_sdk import _workflows as wf
197
+
198
+ return wf.get_strategy(self, strategy_id)
199
+
200
+ def list_strategies(self):
201
+ from qtsurfer_sdk import _workflows as wf
202
+
203
+ return wf.list_strategies(self)
204
+
205
+ def delete_strategy(self, strategy_id: str):
206
+ from qtsurfer_sdk import _workflows as wf
207
+
208
+ return wf.delete_strategy(self, strategy_id)
209
+
210
+ def get_strategy_code(self, strategy_id: str) -> str:
211
+ from qtsurfer_sdk import _workflows as wf
212
+
213
+ return wf.get_strategy_code(self, strategy_id)
214
+
215
+ def prepare(self, **kwargs):
216
+ from qtsurfer_sdk import _workflows as wf
217
+
218
+ return wf.prepare(self, **kwargs)
219
+
220
+ def get_prepare_status(self, **kwargs):
221
+ from qtsurfer_sdk import _workflows as wf
222
+
223
+ return wf.get_prepare_status(self, **kwargs)
224
+
225
+ def execute(self, **kwargs):
226
+ from qtsurfer_sdk import _workflows as wf
227
+
228
+ return wf.execute(self, **kwargs)
229
+
230
+ def get_backtest_result(self, **kwargs):
231
+ from qtsurfer_sdk import _workflows as wf
232
+
233
+ return wf.get_backtest_result(self, **kwargs)
234
+
235
+ def cancel_backtest(self, **kwargs):
236
+ from qtsurfer_sdk import _workflows as wf
237
+
238
+ return wf.cancel_backtest(self, **kwargs)
239
+
240
+ def sweep(self, **kwargs):
241
+ from qtsurfer_sdk import _workflows as wf
242
+
243
+ return wf.sweep(self, **kwargs)
244
+
245
+ def get_sweep_result(self, **kwargs):
246
+ from qtsurfer_sdk import _workflows as wf
247
+
248
+ return wf.get_sweep_result(self, **kwargs)
249
+
250
+ def get_sweep_sensitivity(self, **kwargs):
251
+ from qtsurfer_sdk import _workflows as wf
252
+
253
+ return wf.get_sweep_sensitivity(self, **kwargs)
254
+
255
+ def get_sweep_run_equity_curve(self, **kwargs):
256
+ from qtsurfer_sdk import _workflows as wf
257
+
258
+ return wf.get_sweep_run_equity_curve(self, **kwargs)
259
+
260
+ def cancel_sweep(self, **kwargs):
261
+ from qtsurfer_sdk import _workflows as wf
262
+
263
+ return wf.cancel_sweep(self, **kwargs)
264
+
265
+ def create_dataset(self, **kwargs):
266
+ from qtsurfer_sdk import _workflows as wf
267
+
268
+ return wf.create_dataset(self, **kwargs)
269
+
270
+ def list_datasets(self):
271
+ from qtsurfer_sdk import _workflows as wf
272
+
273
+ return wf.list_datasets(self)
274
+
275
+ def get_dataset(self, dataset_id: str):
276
+ from qtsurfer_sdk import _workflows as wf
277
+
278
+ return wf.get_dataset(self, dataset_id)
279
+
280
+ def delete_dataset(self, dataset_id: str):
281
+ from qtsurfer_sdk import _workflows as wf
282
+
283
+ return wf.delete_dataset(self, dataset_id)
284
+
285
+ def open_dataset_upload(self, dataset_id: str):
286
+ """Open the dataset's pending upload session or its next version's session."""
287
+ from qtsurfer_sdk import _workflows as wf
288
+
289
+ return wf.open_dataset_upload(self, dataset_id)
290
+
291
+ def upload_dataset_file(self, upload, source):
292
+ """PUT a local binary source to a dataset upload session's presigned URL."""
293
+ from qtsurfer_sdk import _workflows as wf
294
+
295
+ return wf.upload_dataset_file(upload, source)
296
+
297
+ def finalize_dataset_upload(self, **kwargs):
298
+ from qtsurfer_sdk import _workflows as wf
299
+
300
+ return wf.finalize_dataset_upload(self, **kwargs)
301
+
302
+ def get_dataset_upload(self, **kwargs):
303
+ from qtsurfer_sdk import _workflows as wf
304
+
305
+ return wf.get_dataset_upload(self, **kwargs)
306
+
307
+
308
+ def auth(
309
+ apikey: str | None = None,
310
+ *,
311
+ base_url: str = DEFAULT_BASE_URL,
312
+ store: TokenStore | None = None,
313
+ ) -> AuthenticatedSession:
314
+ """Exchange a long-lived API key for an authenticated session.
315
+
316
+ If ``apikey`` is ``None`` or empty, the SDK reads
317
+ ``QTSURFER_APIKEY`` from the environment. The returned
318
+ :class:`AuthenticatedSession` caches the JWT (in memory by default,
319
+ or in the provided :class:`TokenStore`), exposes the underlying
320
+ ``qtsurfer-api-client`` ``AuthenticatedClient`` via
321
+ :attr:`AuthenticatedSession.client`, and transparently refreshes
322
+ the JWT on 401 via :meth:`AuthenticatedSession.call`.
323
+
324
+ :param apikey: Long-lived API key. When omitted, read from
325
+ ``QTSURFER_APIKEY``.
326
+ :param base_url: API base URL. Defaults to
327
+ ``https://api.qtsurfer.com/v1``.
328
+ :param store: Custom :class:`TokenStore`. Defaults to
329
+ :class:`InMemoryTokenStore`.
330
+ :raises QTSAuthError: when no apikey is available, or the initial JWT
331
+ exchange fails.
332
+ """
333
+ resolved = _resolve_apikey(apikey)
334
+ session = AuthenticatedSession(resolved, base_url=base_url, store=store)
335
+ session.ensure_token()
336
+ return session
337
+
338
+
339
+ def _resolve_apikey(explicit: str | None) -> str:
340
+ if explicit and explicit.strip():
341
+ return explicit
342
+ env = os.environ.get(APIKEY_ENV_VAR)
343
+ if env and env.strip():
344
+ return env
345
+ raise QTSAuthError(f"auth() requires an apikey (argument or {APIKEY_ENV_VAR} env var)")
346
+
347
+
348
+ def _result_is_unauthorized(result: object) -> bool:
349
+ """Detect a 401 across the response types callers commonly return.
350
+
351
+ * The generated ``qtsurfer-api-client`` ``Response`` (``*_detailed``).
352
+ * Raw ``httpx.Response`` (when callers reach into ``client.get_httpx_client()``).
353
+ * Any other object exposing a numeric ``status_code`` attribute.
354
+ """
355
+ status = getattr(result, "status_code", None)
356
+ return status == 401
357
+
358
+
359
+ def _is_unauthorized(exc: BaseException) -> bool:
360
+ status = getattr(exc, "status_code", None)
361
+ if status == 401:
362
+ return True
363
+ status = getattr(exc, "status", None)
364
+ if status == 401:
365
+ return True
366
+ cause = exc.__cause__
367
+ if cause is not None and getattr(cause, "status_code", None) == 401:
368
+ return True
369
+ return False
@@ -0,0 +1,51 @@
1
+ """Pluggable token persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from qtsurfer.api.client._generated.models import AuthTokenResponse
8
+
9
+
10
+ @runtime_checkable
11
+ class TokenStore(Protocol):
12
+ """Pluggable token persistence strategy for :class:`AuthenticatedSession`.
13
+
14
+ The SDK ships an :class:`InMemoryTokenStore` as the default. Adopters
15
+ implement this Protocol (or subclass it) to back tokens by an on-disk
16
+ file, a secret manager, etc. The SDK calls :meth:`load` once per
17
+ session-startup to seed any previously cached token, :meth:`save`
18
+ after every successful ``auth()`` / refresh, and :meth:`clear` when
19
+ the session is explicitly invalidated.
20
+
21
+ Implementations are expected to be thread-safe if used from multiple
22
+ threads.
23
+ """
24
+
25
+ def load(self) -> AuthTokenResponse | None:
26
+ """Return the persisted token, or :data:`None` if none."""
27
+
28
+ def save(self, token: AuthTokenResponse) -> None:
29
+ """Persist the token returned by ``POST /v1/auth/token``."""
30
+
31
+ def clear(self) -> None:
32
+ """Drop any persisted token."""
33
+
34
+
35
+ class InMemoryTokenStore:
36
+ """Default :class:`TokenStore` — holds the most recent token in a
37
+ single in-memory slot. Lost on process exit. Sufficient for
38
+ short-lived scripts and for tests.
39
+ """
40
+
41
+ def __init__(self) -> None:
42
+ self._token: AuthTokenResponse | None = None
43
+
44
+ def load(self) -> AuthTokenResponse | None:
45
+ return self._token
46
+
47
+ def save(self, token: AuthTokenResponse) -> None:
48
+ self._token = token
49
+
50
+ def clear(self) -> None:
51
+ self._token = None