simudyne-pulse 0.9.0.dev2__tar.gz → 0.9.0.dev3__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.
Files changed (25) hide show
  1. {simudyne_pulse-0.9.0.dev2/src/simudyne_pulse.egg-info → simudyne_pulse-0.9.0.dev3}/PKG-INFO +1 -1
  2. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/pyproject.toml +1 -1
  3. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/client.py +89 -12
  4. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/api_keys.py +13 -5
  5. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/data.py +32 -16
  6. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/fix.py +20 -8
  7. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/fm.py +34 -11
  8. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/profile.py +23 -14
  9. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/simulation.py +120 -60
  10. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/validation.py +147 -28
  11. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3/src/simudyne_pulse.egg-info}/PKG-INFO +1 -1
  12. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne_pulse.egg-info/SOURCES.txt +1 -0
  13. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/tests/test_new_resources.py +9 -0
  14. simudyne_pulse-0.9.0.dev3/tests/test_retries.py +173 -0
  15. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/tests/test_validation.py +45 -0
  16. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/LICENSE +0 -0
  17. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/README.md +0 -0
  18. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/setup.cfg +0 -0
  19. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/__init__.py +0 -0
  20. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/exceptions.py +0 -0
  21. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne/resources/__init__.py +0 -0
  22. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  23. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  24. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  25. {simudyne_pulse-0.9.0.dev2 → simudyne_pulse-0.9.0.dev3}/tests/test_api_contract.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.9.0.dev2
3
+ Version: 0.9.0.dev3
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simudyne-pulse"
7
- version = "0.9.0-dev.2"
7
+ version = "0.9.0-dev.3"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -11,6 +11,30 @@ import math
11
11
  from simudyne.exceptions import PulseAPIError, error_from_response # noqa: F401
12
12
 
13
13
 
14
+ def _never_reached_server(error: Exception) -> bool:
15
+ """Whether a transport error happened before the request was sent.
16
+
17
+ A connect timeout, a refused connection or a failed DNS lookup means the
18
+ API never saw the request, so even a submit is safe to send again. A
19
+ read timeout or a dropped connection may come after the API acted.
20
+ """
21
+ if isinstance(error, requests.exceptions.ConnectTimeout):
22
+ return True
23
+ if isinstance(error, requests.exceptions.ConnectionError):
24
+ from urllib3.exceptions import NewConnectionError
25
+
26
+ seen = set()
27
+ cause = error
28
+ while cause is not None and id(cause) not in seen:
29
+ seen.add(id(cause))
30
+ if isinstance(cause, NewConnectionError):
31
+ return True
32
+ reason = getattr(cause, "reason", None)
33
+ nested = cause.args[0] if getattr(cause, "args", None) else None
34
+ cause = reason or (nested if isinstance(nested, BaseException) else None) or cause.__cause__
35
+ return False
36
+
37
+
14
38
  class PulseABM:
15
39
  """Client for the Simudyne Pulse synthetic market data API.
16
40
 
@@ -19,9 +43,20 @@ class PulseABM:
19
43
  Construct it once and reuse it; the underlying :class:`requests.Session`
20
44
  keeps connections alive across calls.
21
45
 
22
- Transient failures (HTTP 429, 502, 503, 504) and network timeouts are
23
- retried automatically with exponential backoff. Any other non-2xx response
24
- raises :class:`~simudyne.exceptions.PulseAPIError`.
46
+ Transient failures are retried automatically with exponential backoff,
47
+ but only where sending the request twice is safe. A read (GET, and the
48
+ bulk download) is retried on HTTP 502, 503, 504, timeouts and connection
49
+ errors. A call that creates or changes something — submitting a
50
+ simulation or validation, minting or revoking a key — is retried only on
51
+ 503 or when the connection never opened, because a 502, 504 or read
52
+ timeout may arrive after the API acted. A 429 is retried only when it
53
+ carries ``Retry-After``; the download quota's 429 is raised at once. Any
54
+ other non-2xx response, and a retried status still failing once the
55
+ retries run out, raises :class:`~simudyne.exceptions.PulseAPIError`. A timeout or
56
+ connection failure that outlasts the retries is not wrapped: it surfaces
57
+ as the underlying :class:`requests.Timeout` or
58
+ :class:`requests.ConnectionError`, so catch those alongside
59
+ ``PulseAPIError`` if you need to handle every failure.
25
60
 
26
61
  Parameters
27
62
  ----------
@@ -36,7 +71,8 @@ class PulseABM:
36
71
  Per-request timeout in seconds. Defaults to 30.
37
72
  max_retries : int, default 3
38
73
  Retry attempts for transient errors. Backoff is ``2 ** attempt``
39
- seconds, so the default waits 1s, 2s then 4s before giving up.
74
+ seconds, so the default waits 1s, 2s then 4s before giving up; a
75
+ ``Retry-After`` header, when sent, is honoured (capped at 60 s).
40
76
 
41
77
  Attributes
42
78
  ----------
@@ -83,6 +119,9 @@ class PulseABM:
83
119
  DEFAULT_BASE_URL = "https://pulse-api.simudyne.com"
84
120
  DEFAULT_TIMEOUT = 30
85
121
  RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
122
+ #: Methods safe to send twice. DELETE is left out on purpose: a revoke
123
+ #: that succeeded but whose answer was lost would come back as a 404.
124
+ IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS"}
86
125
 
87
126
  def __init__(
88
127
  self,
@@ -117,9 +156,25 @@ class PulseABM:
117
156
  self.simulation = SimulationResource(self)
118
157
  self.validation = ValidationResource(self)
119
158
 
120
- def _request_with_retries(self, method: str, url: str, **kwargs):
121
- """Execute request with timeout and exponential backoff for transient errors."""
159
+ def _request_with_retries(self, method: str, url: str, idempotent=None, **kwargs):
160
+ """Execute a request, retrying only what is safe to send twice.
161
+
162
+ A read (GET, or a POST the caller marks ``idempotent=True``, such as
163
+ the bulk download) is retried on 502/503/504, on a timeout and on a
164
+ connection failure. A call that creates or changes something —
165
+ submitting a simulation or validation, minting or revoking a key,
166
+ launching a live session — is retried only when the API cannot have
167
+ acted on it: the connection never opened, or the API answered 503.
168
+ A 502, 504 or read timeout on such a call may arrive after the job
169
+ was queued, and retrying it used to queue a second one.
170
+
171
+ A 429 is retried only when it carries ``Retry-After``, as a rate
172
+ limiter's does. The API's own 429 is the free-tier download quota,
173
+ which does not clear in seconds; it is raised at once.
174
+ """
122
175
  kwargs.setdefault("timeout", self.timeout)
176
+ if idempotent is None:
177
+ idempotent = method.upper() in self.IDEMPOTENT_METHODS
123
178
  last_exception = None
124
179
 
125
180
  for attempt in range(self.max_retries + 1):
@@ -129,23 +184,45 @@ class PulseABM:
129
184
  if response.ok:
130
185
  return response
131
186
 
132
- if response.status_code in self.RETRYABLE_STATUS_CODES and attempt < self.max_retries:
133
- delay = 2 ** attempt
134
- time.sleep(delay)
187
+ if attempt < self.max_retries and self._should_retry(
188
+ response, idempotent
189
+ ):
190
+ time.sleep(self._retry_delay(response, attempt))
135
191
  continue
136
192
 
137
193
  raise error_from_response(response)
138
194
 
139
195
  except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
140
196
  last_exception = e
141
- if attempt < self.max_retries:
142
- delay = 2 ** attempt
143
- time.sleep(delay)
197
+ if attempt < self.max_retries and (
198
+ idempotent or _never_reached_server(e)
199
+ ):
200
+ time.sleep(2 ** attempt)
144
201
  continue
145
202
  raise
146
203
 
147
204
  raise last_exception
148
205
 
206
+ def _should_retry(self, response, idempotent: bool) -> bool:
207
+ status = response.status_code
208
+ if status == 429:
209
+ return "Retry-After" in response.headers
210
+ if status not in self.RETRYABLE_STATUS_CODES:
211
+ return False
212
+ return idempotent or status == 503
213
+
214
+ @staticmethod
215
+ def _retry_delay(response, attempt: int) -> float:
216
+ """``Retry-After`` seconds when the server gives them (capped at 60),
217
+ otherwise exponential backoff: 1, 2, 4 s."""
218
+ header = response.headers.get("Retry-After")
219
+ if header is not None:
220
+ try:
221
+ return min(max(float(header), 0.0), 60.0)
222
+ except ValueError:
223
+ pass
224
+ return 2 ** attempt
225
+
149
226
  def _request(self, method: str, endpoint: str, **kwargs):
150
227
  url = f"{self.base_url}{endpoint}"
151
228
  response = self._request_with_retries(method, url, **kwargs)
@@ -8,6 +8,7 @@ class ApiKeysResource:
8
8
  --------
9
9
  Mint a key for a job, use it, then revoke it:
10
10
 
11
+ >>> from simudyne import PulseABM
11
12
  >>> key = client.api_keys.create(name="Nightly backtest")
12
13
  >>> run_client = PulseABM(api_key=key["api_key"])
13
14
  >>> client.api_keys.revoke(key_id=key["api_key_id"])
@@ -46,7 +47,8 @@ class ApiKeysResource:
46
47
  Raises
47
48
  ------
48
49
  PulseAPIError
49
- If the account has reached its key limit, or ``name`` is rejected.
50
+ If the calling key is invalid, revoked or expired — status 401.
51
+ There is no per-account key limit.
50
52
 
51
53
  See Also
52
54
  --------
@@ -65,7 +67,8 @@ class ApiKeysResource:
65
67
 
66
68
  The full key is never returned after creation; only ``key_prefix``,
67
69
  the first 12 characters, is stored. That is enough to tell keys apart
68
- and not enough to reconstruct one.
70
+ and not enough to reconstruct one. Revoked keys are not listed, and
71
+ at most 100 keys are returned.
69
72
 
70
73
  Returns
71
74
  -------
@@ -75,7 +78,7 @@ class ApiKeysResource:
75
78
  - api_key_id (str): the handle to pass to :meth:`revoke`
76
79
  - key_prefix (str): first 12 characters, e.g. "pk_live_ab12"
77
80
  - name (str): the label you gave it
78
- - is_active (bool): False once revoked
81
+ - is_active (bool): always True, since revoked keys are omitted
79
82
  - created_at (str): when it was created
80
83
  - last_used_at (str or None): None until the key is first used
81
84
  - expires_at (str or None): None for keys that do not expire
@@ -111,12 +114,14 @@ class ApiKeysResource:
111
114
  Returns
112
115
  -------
113
116
  dict
114
- The revocation result.
117
+ ``{"message": "API key revoked"}``.
115
118
 
116
119
  Raises
117
120
  ------
118
121
  PulseAPIError
119
- If ``key_id`` does not exist or does not belong to your account.
122
+ Status 404 if ``key_id`` does not exist, does not belong to your
123
+ account, or has already been revoked; 401 if the calling key is
124
+ invalid.
120
125
 
121
126
  See Also
122
127
  --------
@@ -131,7 +136,10 @@ class ApiKeysResource:
131
136
  Revoking the key you are currently authenticating with locks you out
132
137
  of the API, so mint the replacement first:
133
138
 
139
+ >>> from simudyne import PulseABM
140
+ >>> old_key_id = client.api_keys.list()[0]["api_key_id"]
134
141
  >>> replacement = client.api_keys.create(name="Rotated")
142
+ >>> client = PulseABM(api_key=replacement["api_key"])
135
143
  >>> client.api_keys.revoke(key_id=old_key_id)
136
144
  """
137
145
  return self._client._request("DELETE", f"/api-keys/{key_id}")
@@ -108,9 +108,11 @@ class DataResource:
108
108
  Raises
109
109
  ------
110
110
  PulseAPIError
111
- If the key is not pro or demo tier (403), ``model_id`` names a
112
- model that is not registered (404), or ``date`` is not
113
- ``YYYY-MM-DD`` (422).
111
+ If the key is not pro or demo tier (403); ``model_id`` names a
112
+ model that is not registered (404) or matches more than one
113
+ (400); ``date`` is not ``YYYY-MM-DD``, ``limit`` is outside
114
+ 1-200 or ``offset`` is negative (422); or, with ``model_id``, the
115
+ model registry (pulse-orchestrator) could not be reached (502).
114
116
 
115
117
  See Also
116
118
  --------
@@ -189,8 +191,8 @@ class DataResource:
189
191
  q : str, optional
190
192
  Case-insensitive substring match over ticker and company name.
191
193
  limit : int, optional
192
- Max instruments to return. Omitted, the whole calibrated catalogue
193
- comes back — it is far smaller than the registry.
194
+ Max instruments to return, 1-1000. Omitted, the whole calibrated
195
+ catalogue comes back — it is far smaller than the registry.
194
196
  offset : int, optional
195
197
  Instruments to skip, for paging alongside ``limit``.
196
198
 
@@ -215,8 +217,9 @@ class DataResource:
215
217
  Raises
216
218
  ------
217
219
  PulseAPIError
218
- If a filter is malformed — most often ``date`` not in
219
- ``YYYY-MM-DD`` form (422).
220
+ If ``limit`` is outside 1-1000 or ``offset`` is negative (422).
221
+ Available on every tier. A malformed ``date`` is not rejected:
222
+ it simply matches nothing, and ``total`` comes back 0.
220
223
 
221
224
  See Also
222
225
  --------
@@ -274,7 +277,7 @@ class DataResource:
274
277
  :meth:`available_data` and its output is an entry in
275
278
  :meth:`calibrated_data`. It runs asynchronously — the call returns once
276
279
  the job is queued, and the day appears in :meth:`calibrated_data` when
277
- the pipeline finishes.
280
+ the pipeline finishes. Demo tier only.
278
281
 
279
282
  A symbol is identified by four fields: ``provider``, ``exchange``,
280
283
  ``symbol`` and ``cal_date``.
@@ -299,14 +302,25 @@ class DataResource:
299
302
  Returns
300
303
  -------
301
304
  dict
302
- Calibration job submission result, containing the job identifier
303
- and its accepted status.
305
+ status : str
306
+ "calibration_queued".
307
+ symbol : str
308
+ The symbol as submitted.
309
+ cal_date : str
310
+ The date as submitted.
311
+
312
+ There is no job id: progress is not pollable through the API.
313
+ Watch :meth:`calibrated_data` for the day to appear.
304
314
 
305
315
  Raises
306
316
  ------
307
317
  PulseAPIError
308
- If the key is not demo tier (403), or there is no raw market data
309
- for the symbol on ``cal_date`` (400).
318
+ If the key is not demo tier (403), ``provider`` or ``exchange``
319
+ is not a recognised value (422), or the calibration service is
320
+ unreachable (503). Missing raw market data for the symbol-day is
321
+ not detected here: the job is queued and fails later in the
322
+ pipeline, so the day simply never appears in
323
+ :meth:`calibrated_data`.
310
324
 
311
325
  See Also
312
326
  --------
@@ -324,17 +338,19 @@ class DataResource:
324
338
  Examples
325
339
  --------
326
340
  >>> job = client.data.calibrate(
327
- ... symbol="9999.HK",
328
- ... cal_date="2025-09-01",
341
+ ... symbol="700.HK",
342
+ ... cal_date="2025-09-02",
329
343
  ... provider="omd",
330
344
  ... exchange="hkex_securities",
331
345
  ... )
346
+ >>> job["status"]
347
+ 'calibration_queued'
332
348
 
333
349
  With an explicit budget, and adjustment-parameter optimisation off:
334
350
 
335
351
  >>> job = client.data.calibrate(
336
- ... symbol="9999.HK",
337
- ... cal_date="2025-09-01",
352
+ ... symbol="700.HK",
353
+ ... cal_date="2025-09-02",
338
354
  ... provider="omd",
339
355
  ... exchange="hkex_securities",
340
356
  ... simulations=500,
@@ -1,7 +1,8 @@
1
1
  """FIX venue usage: what you have run over a FIX session.
2
2
 
3
- The FIX venue itself is a raw-TCP FIX 4.4 acceptor, not an HTTP API — you reach
4
- it with your own FIX engine, not with this SDK. What lives here is the one
3
+ The FIX venue itself is a raw-TCP FIX 5.0 SP2 acceptor (FIXT.1.1 session
4
+ layer; FIX 4.4 is not accepted), not an HTTP API — you reach it with your own
5
+ FIX engine, not with this SDK. What lives here is the one
5
6
  HTTP endpoint a FIX client wants: a record of what it has run.
6
7
 
7
8
  Runs are written by the venue rather than by the client, because the venue is
@@ -16,8 +17,8 @@ USAGE_PATH = "/fix/usage"
16
17
  class FixResource:
17
18
  """Usage records for simulations started over the FIX venue.
18
19
 
19
- Reached as ``client.fix``. The venue itself is raw-TCP FIX 4.4 and is not
20
- driven from this SDK — you connect to it with your own FIX engine. This
20
+ Reached as ``client.fix``. The venue itself is raw-TCP FIX 5.0 SP2 over
21
+ FIXT.1.1 and is not driven from this SDK — you connect to it with your own FIX engine. This
21
22
  resource only reads back what the venue recorded.
22
23
 
23
24
  Examples
@@ -35,14 +36,18 @@ class FixResource:
35
36
  Parameters
36
37
  ----------
37
38
  user_id : str, optional
38
- Another account to read, which requires an admin key.
39
- Omit it for your own usage.
39
+ Another account to read, which requires an admin key. Omit it
40
+ for your own usage — or, with an admin key, for estate-wide
41
+ totals across every account. An unknown ``user_id`` is not an
42
+ error: it returns an empty summary.
40
43
 
41
44
  Returns
42
45
  -------
43
46
  dict
44
47
  Usage summary containing:
45
48
 
49
+ - user_id (str or None): the account summarised; None for an
50
+ admin's estate-wide totals
46
51
  - total_runs (int): FIX sessions recorded
47
52
  - simulated_seconds (float): total horizon across those runs
48
53
  - by_symbol / by_scenario / by_cal_date (dict): run counts
@@ -51,12 +56,13 @@ class FixResource:
51
56
  - last_run_at (str or None): when the most recent one started,
52
57
  None if nothing has been run
53
58
  - truncated (bool): True if the history hit the server's cap
59
+ of 5,000 runs, in which case only the newest 5,000 are counted
54
60
 
55
61
  Raises
56
62
  ------
57
63
  PulseAPIError
58
- If ``user_id`` is given without an admin key, or names an account
59
- that does not exist.
64
+ If ``user_id`` names another account and the key is not an admin
65
+ key (403), or the key is invalid, revoked or expired (401).
60
66
 
61
67
  See Also
62
68
  --------
@@ -74,6 +80,12 @@ class FixResource:
74
80
  Read another account, which needs an admin key:
75
81
 
76
82
  >>> client.fix.usage(user_id="usr_01H...")
83
+
84
+ With an admin key and no ``user_id``, the whole estate:
85
+
86
+ >>> estate = client.fix.usage()
87
+ >>> estate["user_id"] is None
88
+ True
77
89
  """
78
90
  params = {"user_id": user_id} if user_id else None
79
91
  return self._client._request("GET", USAGE_PATH, params=params)
@@ -32,7 +32,11 @@ class FmResource:
32
32
  model accepts, then submit through ``client.simulation.run(engine="fm")``
33
33
  like any other simulation.
34
34
 
35
- Every call on this resource requires a pro-tier key.
35
+ :meth:`models` and :meth:`live` require a pro- or demo-tier key.
36
+ :meth:`registry`, :meth:`register`, :meth:`activate` and
37
+ :meth:`deactivate` require a key whose account has the admin flag
38
+ (``is_admin`` in :meth:`~simudyne.resources.profile.ProfileResource.get`);
39
+ tier plays no part in those four.
36
40
 
37
41
  Examples
38
42
  --------
@@ -61,7 +65,7 @@ class FmResource:
61
65
  def models(self):
62
66
  """Foundation models active in this environment.
63
67
 
64
- Requires a pro-tier key.
68
+ Requires a pro- or demo-tier key.
65
69
 
66
70
  Returns
67
71
  -------
@@ -84,7 +88,8 @@ class FmResource:
84
88
  Raises
85
89
  ------
86
90
  PulseAPIError
87
- If the key is not pro tier — status 403.
91
+ If the key is not pro or demo tier (403), or the model registry
92
+ (pulse-orchestrator) cannot be reached (502).
88
93
 
89
94
  See Also
90
95
  --------
@@ -124,11 +129,12 @@ class FmResource:
124
129
  Raises
125
130
  ------
126
131
  PulseAPIError
127
- If the key is not an admin key — status 403.
132
+ If the key's account lacks the admin flag (403), or the
133
+ orchestrator cannot be reached (502).
128
134
 
129
135
  See Also
130
136
  --------
131
- models : The active models only, for any pro key.
137
+ models : The active models only, for any pro or demo key.
132
138
  register : Add a model or a new version of one.
133
139
 
134
140
  Examples
@@ -148,6 +154,7 @@ class FmResource:
148
154
  supported_data: list | None = None,
149
155
  min_prompt_rows: int | None = None,
150
156
  production_name: str = "",
157
+ testdata: dict | None = None,
151
158
  active: bool = True,
152
159
  ):
153
160
  """Admin: register a model image, or a new version of one.
@@ -178,6 +185,11 @@ class FmResource:
178
185
  Context rows it needs to start inference.
179
186
  production_name : str, default ""
180
187
  The name to promote it under, ``{model}-{market}-{version}-{size}``.
188
+ testdata : dict, optional
189
+ The real day baked into the image as its test data,
190
+ ``{"provider", "exchange", "symbol", "date"}``. Test-data runs are
191
+ minted under that day, so their sim_ids name it instead of
192
+ ``na:na:testdata:na``.
181
193
  active : bool, default True
182
194
  Whether :meth:`models` lists it.
183
195
 
@@ -190,8 +202,12 @@ class FmResource:
190
202
  Raises
191
203
  ------
192
204
  PulseAPIError
193
- If the key is not an admin key (403), or a field is rejected (400
194
- or 422).
205
+ If the key's account lacks the admin flag (403); ``image`` is not
206
+ found in its container registry, or ``model_args`` or
207
+ ``supported_data`` is malformed (400); a field has the wrong type
208
+ (422); the deployment's registry is a read-only manifest, so
209
+ models cannot be registered through the API (501); or the
210
+ orchestrator, or the image check, cannot be reached (502).
195
211
 
196
212
  See Also
197
213
  --------
@@ -217,6 +233,7 @@ class FmResource:
217
233
  for name, value in (
218
234
  ("resources", resources), ("model_args", model_args),
219
235
  ("supported_data", supported_data), ("min_prompt_rows", min_prompt_rows),
236
+ ("testdata", testdata),
220
237
  ):
221
238
  if value is not None:
222
239
  payload[name] = value
@@ -239,7 +256,9 @@ class FmResource:
239
256
  Raises
240
257
  ------
241
258
  PulseAPIError
242
- If the key is not an admin key (403) or the model is unknown (404).
259
+ If the key's account lacks the admin flag (403), the model is
260
+ unknown (404), the deployment's registry is a read-only manifest
261
+ (501), or the orchestrator cannot be reached (502).
243
262
 
244
263
  See Also
245
264
  --------
@@ -270,7 +289,9 @@ class FmResource:
270
289
  Raises
271
290
  ------
272
291
  PulseAPIError
273
- If the key is not an admin key (403) or the model is unknown (404).
292
+ If the key's account lacks the admin flag (403), the model is
293
+ unknown (404), the deployment's registry is a read-only manifest
294
+ (501), or the orchestrator cannot be reached (502).
274
295
 
275
296
  See Also
276
297
  --------
@@ -293,7 +314,7 @@ class FmResource:
293
314
  """Start a live streaming session instead of a batch job.
294
315
 
295
316
  Returns the token a live chart client uses to connect. Requires a
296
- pro-tier key.
317
+ pro- or demo-tier key.
297
318
 
298
319
  Parameters
299
320
  ----------
@@ -316,7 +337,9 @@ class FmResource:
316
337
  ------
317
338
  PulseAPIError
318
339
  If ``model_id`` is unknown (404), ``model_args`` holds an unknown
319
- or out-of-range key (400), or the key is not pro tier (403).
340
+ or out-of-range key (400), the key is not pro or demo tier (403),
341
+ this deployment cannot host live sessions (501), or the
342
+ orchestrator cannot be reached (502).
320
343
 
321
344
  See Also
322
345
  --------
@@ -70,7 +70,13 @@ class ProfileResource:
70
70
  """Return your request count for the current calendar month.
71
71
 
72
72
  Resets on the 1st, and counts *requests* rather than simulations —
73
- one ``run()`` of 100 Monte Carlo runs is a single request here.
73
+ one ``run()`` of 100 Monte Carlo runs is a single request here. Only
74
+ the routes that log usage are counted: the data routes, the
75
+ simulation routes (run, LRM run, jobs and their status, results and
76
+ logs, per-sim files, params, metrics and data, bulk download, cached
77
+ and sample), validation ``run`` and ``run/upload``, FM ``live`` and
78
+ the historical routes. Profile, API-key, validation job reads, FM
79
+ model management and FIX usage calls are not counted.
74
80
 
75
81
  Returns
76
82
  -------
@@ -82,10 +88,10 @@ class ProfileResource:
82
88
  total_requests : int
83
89
  Requests so far this month.
84
90
  by_endpoint : dict
85
- Request count keyed by endpoint path. The keys are the
86
- concrete paths that were called, so a job-scoped endpoint
87
- appears once per job id rather than as a template — aggregate
88
- by prefix if you want per-endpoint totals.
91
+ Request count keyed by route label. Job- and sim-scoped
92
+ routes are logged under their template, e.g.
93
+ ``"/simulation/jobs/{job_id}/status"``, not once per concrete
94
+ job id, so each key is already a per-endpoint total.
89
95
 
90
96
  Raises
91
97
  ------
@@ -102,12 +108,13 @@ class ProfileResource:
102
108
  >>> usage = client.profile.usage()
103
109
  >>> print(f"{usage['month']}: {usage['total_requests']} requests")
104
110
 
105
- Aggregate the per-path counts back into per-endpoint totals:
111
+ Roll the per-route counts up into per-area totals:
106
112
 
107
113
  >>> from collections import Counter
108
114
  >>> totals = Counter()
109
- >>> for path, count in usage["by_endpoint"].items():
110
- ... totals[path.split("/")[1]] += count
115
+ >>> for route, count in usage["by_endpoint"].items():
116
+ ... totals[route.split("/")[1]] += count
117
+ >>> print(totals.most_common())
111
118
  """
112
119
  return self._client._request("GET", "/profile/usage")
113
120
 
@@ -134,7 +141,14 @@ class ProfileResource:
134
141
  downloaded_groups : list of str or None
135
142
  Every group you have ever downloaded. None when unlimited.
136
143
  downloaded_group_details : list of dict or None
137
- The same groups, each with a ``downloaded_at`` timestamp.
144
+ The same groups, newest first, each a dict with
145
+ ``job_key`` (str, the group, as in ``downloaded_groups``)
146
+ and ``downloaded_at`` (ISO timestamp str, or None).
147
+
148
+ Raises
149
+ ------
150
+ PulseAPIError
151
+ If the key is invalid, revoked or expired — status 401.
138
152
 
139
153
  See Also
140
154
  --------
@@ -147,11 +161,6 @@ class ProfileResource:
147
161
  A request that would exceed the allowance returns HTTP 429 without
148
162
  charging anything, so a partial download never silently eats quota.
149
163
 
150
- Raises
151
- ------
152
- PulseAPIError
153
- If the key is invalid, revoked or expired — status 401.
154
-
155
164
  Examples
156
165
  --------
157
166
  >>> quota = client.profile.downloads()