simudyne-pulse 0.7.0.dev5__tar.gz → 0.7.0.dev7__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 (30) hide show
  1. {simudyne_pulse-0.7.0.dev5/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev7}/PKG-INFO +1 -1
  2. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/pyproject.toml +1 -1
  3. simudyne_pulse-0.7.0.dev7/src/simudyne/resources/api_keys.py +83 -0
  4. simudyne_pulse-0.7.0.dev7/src/simudyne/resources/profile.py +103 -0
  5. simudyne_pulse-0.7.0.dev7/src/simudyne/resources/validation.py +346 -0
  6. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7/src/simudyne_pulse.egg-info}/PKG-INFO +1 -1
  7. simudyne_pulse-0.7.0.dev7/tests/test_validation.py +230 -0
  8. simudyne_pulse-0.7.0.dev5/src/simudyne/resources/api_keys.py +0 -12
  9. simudyne_pulse-0.7.0.dev5/src/simudyne/resources/profile.py +0 -30
  10. simudyne_pulse-0.7.0.dev5/src/simudyne/resources/validation.py +0 -712
  11. simudyne_pulse-0.7.0.dev5/tests/test_validation.py +0 -268
  12. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/LICENSE +0 -0
  13. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/README.md +0 -0
  14. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/setup.cfg +0 -0
  15. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/__init__.py +0 -0
  16. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/client.py +0 -0
  17. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/exceptions.py +0 -0
  18. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/__init__.py +0 -0
  19. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/data.py +0 -0
  20. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/fix.py +0 -0
  21. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/fm.py +0 -0
  22. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/historical.py +0 -0
  23. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/simulation.py +0 -0
  24. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/simulator_gym.py +0 -0
  25. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -0
  26. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  27. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  28. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  29. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/tests/test_new_resources.py +0 -0
  30. {simudyne_pulse-0.7.0.dev5 → simudyne_pulse-0.7.0.dev7}/tests/test_simulator_gym.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev5
3
+ Version: 0.7.0.dev7
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.7.0-dev.5"
7
+ version = "0.7.0-dev.7"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,83 @@
1
+ class ApiKeysResource:
2
+ def __init__(self, client):
3
+ self._client = client
4
+
5
+ def create(self, name: str):
6
+ """Mint a new API key.
7
+
8
+ The only call that ever returns the key material. It is shown once —
9
+ store it before the call returns, or mint another.
10
+
11
+ Parameters
12
+ ----------
13
+ name : str
14
+ A label for the key. Name it by purpose ("notebook",
15
+ "production", "CI pipeline") so you know which to revoke if one
16
+ is compromised.
17
+
18
+ Returns
19
+ -------
20
+ dict
21
+ api_key : str
22
+ The key material, shown exactly once.
23
+ api_key_id : str
24
+ The handle to pass to :meth:`revoke`.
25
+ warning : str
26
+ A reminder that the key will not be shown again.
27
+
28
+ Examples
29
+ --------
30
+ >>> new_key = client.api_keys.create(name="Training script")
31
+ >>> print(new_key["api_key"]) # save this, it won't be shown again
32
+ """
33
+ return self._client._request("POST", "/api-keys", json={"name": name})
34
+
35
+ def list(self):
36
+ """List your active API keys and their labels.
37
+
38
+ The full key is never returned after creation; only ``key_prefix``,
39
+ the first 12 characters, is stored. That is enough to tell keys apart
40
+ and not enough to reconstruct one.
41
+
42
+ Returns
43
+ -------
44
+ list of dict
45
+ One entry per key, each with:
46
+
47
+ - api_key_id (str): the handle to pass to :meth:`revoke`
48
+ - key_prefix (str): first 12 characters, e.g. "pk_live_ab12"
49
+ - name (str): the label you gave it
50
+ - is_active (bool): False once revoked
51
+ - created_at (str): when it was created
52
+ - last_used_at (str or None): None until the key is first used
53
+ - expires_at (str or None): None for keys that do not expire
54
+
55
+ Examples
56
+ --------
57
+ >>> for key in client.api_keys.list():
58
+ ... print(key["name"], key["key_prefix"], key["created_at"])
59
+ """
60
+ return self._client._request("GET", "/api-keys")
61
+
62
+ def revoke(self, key_id: str):
63
+ """Invalidate a key immediately.
64
+
65
+ Any request still using the key returns HTTP 401 from this point on.
66
+
67
+ Parameters
68
+ ----------
69
+ key_id : str
70
+ The ``api_key_id`` from :meth:`list` or :meth:`create` — not the
71
+ key material itself.
72
+
73
+ Returns
74
+ -------
75
+ dict
76
+ The revocation result.
77
+
78
+ Examples
79
+ --------
80
+ >>> new_key = client.api_keys.create(name="Training script")
81
+ >>> client.api_keys.revoke(key_id=new_key["api_key_id"])
82
+ """
83
+ return self._client._request("DELETE", f"/api-keys/{key_id}")
@@ -0,0 +1,103 @@
1
+ class ProfileResource:
2
+ def __init__(self, client):
3
+ self._client = client
4
+
5
+ def get(self):
6
+ """Return your account record.
7
+
8
+ The cheapest way to confirm a key works and to find out which tier it
9
+ is on — worth calling first when a 403 surprises you.
10
+
11
+ Returns
12
+ -------
13
+ dict
14
+ user_id : str
15
+ Your account id. Also your FIX CompID, where FIX is enabled.
16
+ email : str
17
+ The address the account was created with.
18
+ name : str
19
+ Display name.
20
+ organization : str
21
+ Organisation, empty string if never set.
22
+ is_admin : bool
23
+ Administrative flag governing user administration, not
24
+ simulator access. It is not a tier bypass: an admin flag on a
25
+ free-tier key still cannot run a simulation. When a call
26
+ returns 403, read ``tier``, not this.
27
+ tier : {"free", "pro", "demo"}
28
+ What gates every endpoint.
29
+ download_limit : int
30
+ New simulation groups per rolling 24 hours. Only enforced on
31
+ the free tier.
32
+
33
+ Examples
34
+ --------
35
+ >>> me = client.profile.get()
36
+ >>> print(f"{me['email']} — tier {me['tier']}")
37
+ """
38
+ return self._client._request("GET", "/profile")
39
+
40
+ def usage(self):
41
+ """Return your request count for the current calendar month.
42
+
43
+ Resets on the 1st, and counts *requests* rather than simulations —
44
+ one ``run()`` of 100 Monte Carlo runs is a single request here.
45
+
46
+ Returns
47
+ -------
48
+ dict
49
+ user_id : str
50
+ Your account id.
51
+ month : str
52
+ The calendar month being reported, "YYYY-MM".
53
+ total_requests : int
54
+ Requests so far this month.
55
+ by_endpoint : dict
56
+ Request count keyed by endpoint path. The keys are the
57
+ concrete paths that were called, so a job-scoped endpoint
58
+ appears once per job id rather than as a template — aggregate
59
+ by prefix if you want per-endpoint totals.
60
+
61
+ Examples
62
+ --------
63
+ >>> usage = client.profile.usage()
64
+ >>> print(f"{usage['month']}: {usage['total_requests']} requests")
65
+ """
66
+ return self._client._request("GET", "/profile/usage")
67
+
68
+ def downloads(self):
69
+ """Return the free-tier download allowance for the current window.
70
+
71
+ The unit is a simulation group — every Monte Carlo run of one
72
+ symbol/date/scenario — not a call, a run, or a file. Membership is
73
+ permanent: a group you have downloaded before is free to re-fetch
74
+ forever, in any run or file format.
75
+
76
+ Returns
77
+ -------
78
+ dict
79
+ limit : int or None
80
+ Groups per window. None on the pro and demo tiers, meaning
81
+ unlimited.
82
+ used : int
83
+ New groups added in the current window.
84
+ remaining : int or None
85
+ ``limit`` minus ``used``, floored at 0. None when unlimited.
86
+ window_hours : int
87
+ Length of the rolling window, 24.
88
+ downloaded_groups : list of str or None
89
+ Every group you have ever downloaded. None when unlimited.
90
+ downloaded_group_details : list of dict or None
91
+ The same groups, each with a ``downloaded_at`` timestamp.
92
+
93
+ Notes
94
+ -----
95
+ A request that would exceed the allowance returns HTTP 429 without
96
+ charging anything, so a partial download never silently eats quota.
97
+
98
+ Examples
99
+ --------
100
+ >>> quota = client.profile.downloads()
101
+ >>> print(f"{quota['used']}/{quota['limit']} used, {quota['remaining']} left")
102
+ """
103
+ return self._client._request("GET", "/profile/downloads")
@@ -0,0 +1,346 @@
1
+ """
2
+ Validation Resource for the Pulse SDK.
3
+
4
+ Compares simulated LOB data against a historical day using distributional
5
+ metrics, impact response, Cont's stylised facts, cross-level volume
6
+ correlation, and the MIND/FID inception distances on DeepLOB embeddings.
7
+
8
+ Submit, then poll::
9
+
10
+ job = client.validation.run(
11
+ symbol="BARC", date="2026-06-17", provider="bmll", exchange="lse",
12
+ sim_ids=[...], ticksize=0.05,
13
+ )
14
+ result = client.validation.get_job(job["job_id"])
15
+ result["distances"]["spread"]["l1"] # per simulation
16
+ result["stylised_fact_verdicts"]["heavy_tails"]
17
+
18
+ ``run`` submits and returns; a validation of a real day takes minutes, so
19
+ ``get_job`` is the status endpoint you call until it is done. ``list_jobs``
20
+ reaches past runs.
21
+
22
+ Selecting metrics
23
+ -----------------
24
+ One flag per metric — ``statistical``, ``stylised_facts``, ``impact``,
25
+ ``volume_correlation``, ``fid``, ``mind``. Left as ``None`` each takes the
26
+ default for your tier, resolved server-side, so naming none behaves exactly as
27
+ your account is entitled to. Pass ``False`` to skip an expensive pass or
28
+ ``True`` to force one on.
29
+
30
+ The historical gate
31
+ -------------------
32
+ Whether the historical half of the comparison comes back — the series in the
33
+ JSON *and* the historical trace on any rendered plot — is decided by your tier,
34
+ not by a parameter here. What is never gated: the L1 and Wasserstein
35
+ ``distances``, the FID and MIND scores, and the true/false
36
+ ``stylised_fact_verdicts`` for both the historical day and each simulation.
37
+ Those are derived scalars and booleans, not series, so every tier receives
38
+ them.
39
+ """
40
+
41
+ import json
42
+ from pathlib import Path
43
+
44
+
45
+ RUN_PATH = "/validation/run"
46
+ UPLOAD_PATH = "/validation/run/upload"
47
+ JOBS_PATH = "/validation/jobs"
48
+
49
+ #: The API rejects more per job; checked client-side so a 26-file submission
50
+ #: fails before any bytes are uploaded.
51
+ MAX_SIM_FILES = 25
52
+
53
+ #: One flag per area of checking. Left unset each takes the server's default.
54
+ _AREA_FLAGS = (
55
+ "statistical",
56
+ "stylised_facts",
57
+ "impact",
58
+ "volume_correlation",
59
+ "fid",
60
+ "mind",
61
+ )
62
+
63
+ #: ``lob`` marks the frames as L2 snapshots, which switches off anything
64
+ #: needing the message stream; ``sample_period`` and ``match_generated_sample``
65
+ #: set the grid the book is resampled onto; ``plots`` is False, True, or a list
66
+ #: of plot ids.
67
+ _EXTRA_FIELDS = ("lob", "sample_period", "match_generated_sample", "plots")
68
+
69
+
70
+ def _frame_to_parquet(entry, index: int):
71
+ """Normalise one simulated run to ``(filename, parquet bytes)``.
72
+
73
+ Accepts a path, a ``(filename, bytes)`` pair, or a polars / pandas
74
+ DataFrame, which is written to parquet in memory. The frame must be pulse
75
+ format -- the same shape the engine writes to ``sim_data.parquet`` -- which
76
+ the server validates; sending something else fails there, not here.
77
+ """
78
+ import io
79
+
80
+ if isinstance(entry, tuple):
81
+ return entry
82
+
83
+ # Duck-typed rather than imported: neither polars nor pandas is a hard
84
+ # dependency of the SDK, and importing one to test for the other would
85
+ # make it one.
86
+ writer = getattr(entry, "write_parquet", None) # polars
87
+ if writer is not None:
88
+ buf = io.BytesIO()
89
+ writer(buf)
90
+ return f"sim_{index}.parquet", buf.getvalue()
91
+
92
+ writer = getattr(entry, "to_parquet", None) # pandas
93
+ if writer is not None:
94
+ buf = io.BytesIO()
95
+ writer(buf, index=False)
96
+ return f"sim_{index}.parquet", buf.getvalue()
97
+
98
+ path = Path(entry)
99
+ return path.name, path.read_bytes()
100
+
101
+
102
+ def _build_config(n_levels, **flags) -> dict:
103
+ """The validation config as the API expects it.
104
+
105
+ Flags left as ``None`` are omitted rather than sent as null: the API reads
106
+ absence as "use my tier's default", and an explicit null would not do that.
107
+ """
108
+ config = {"n_levels": n_levels}
109
+ for name in _AREA_FLAGS + _EXTRA_FIELDS:
110
+ value = flags.get(name)
111
+ if value is not None:
112
+ config[name] = value
113
+
114
+ unknown = set(flags) - set(_AREA_FLAGS) - set(_EXTRA_FIELDS)
115
+ if unknown:
116
+ raise ValueError(
117
+ f"Unknown validation option(s): {sorted(unknown)}. "
118
+ f"Valid: {sorted(_AREA_FLAGS + _EXTRA_FIELDS)}"
119
+ )
120
+ return config
121
+
122
+
123
+ def _save_plots(result: dict, plot_dir=None) -> list:
124
+ """Write the returned figures to disk and return the paths written.
125
+
126
+ The API sends rendered plots base64-encoded in the response. Left to
127
+ itself that is bytes you cannot look at, so when plots were asked for they
128
+ are written out: to ``plot_dir`` when given, otherwise the current
129
+ directory. The directory is created if it does not exist.
130
+ """
131
+ import base64
132
+
133
+ target = Path(plot_dir) if plot_dir is not None else Path.cwd()
134
+ target.mkdir(parents=True, exist_ok=True)
135
+
136
+ written = []
137
+ for category, entries in (result.get("plots") or {}).items():
138
+ for entry in entries or []:
139
+ content = entry.get("content_base64")
140
+ if not content:
141
+ continue
142
+ path = target / f"{entry.get('name') or category}.png"
143
+ path.write_bytes(base64.b64decode(content))
144
+ written.append(str(path))
145
+ return written
146
+
147
+
148
+ class ValidationResource:
149
+ """Validate simulated market data against a historical day."""
150
+
151
+ def __init__(self, client):
152
+ self._client = client
153
+
154
+ def run(
155
+ self,
156
+ symbol: str,
157
+ date: str,
158
+ provider: str,
159
+ exchange: str,
160
+ *,
161
+ sim_ids=None,
162
+ sim_files=None,
163
+ ticksize: float = 1.0,
164
+ statistical=None,
165
+ stylised_facts=None,
166
+ impact=None,
167
+ volume_correlation=None,
168
+ fid=None,
169
+ mind=None,
170
+ lob=None,
171
+ sample_period=None,
172
+ match_generated_sample=None,
173
+ n_levels: int = 10,
174
+ plots=None,
175
+ ) -> dict:
176
+ """Submit a validation job.
177
+
178
+ Returns as soon as the job is accepted. Poll :meth:`get_job` for
179
+ status and, once it is complete, the result — a validation of a real
180
+ day takes minutes, which is far too long to hold a request open.
181
+
182
+ Parameters
183
+ ----------
184
+ symbol : str
185
+ Trading symbol, e.g. ``"BARC"`` or ``"700.HK"``.
186
+ date : str
187
+ Calibration date, ``"YYYY-MM-DD"``. The historical day compared
188
+ against.
189
+ provider : str
190
+ Data provider, ``"bmll"`` or ``"omd"``. Required: the same symbol
191
+ exists under both with different dates and tick sizes, so
192
+ ``(symbol, provider, exchange)`` is the identity, not the symbol.
193
+ exchange : str
194
+ Exchange, e.g. ``"lse"`` or ``"hkex_securities"``.
195
+ sim_ids : list of str, optional
196
+ Platform simulation IDs, 1 to 25. Exactly one of ``sim_ids`` or
197
+ ``sim_files`` is required.
198
+ sim_files : list, optional
199
+ Your own simulated runs, up to 25: paths, ``(filename, bytes)``
200
+ pairs, or polars / pandas DataFrames in pulse format. The
201
+ historical side is fetched for you.
202
+ ticksize : float, default 1.0
203
+ Minimum price increment. Should match the instrument.
204
+ statistical, stylised_facts, impact, volume_correlation, fid, mind : bool, optional
205
+ Whether to run each area. ``None`` uses your tier's default.
206
+ lob : bool, optional
207
+ The frames are L2 snapshots. Anything needing the message stream
208
+ is skipped, with the reason reported in
209
+ ``metadata["areas_skipped"]``.
210
+ sample_period : str, optional
211
+ The grid the book is resampled onto in lob mode, e.g. ``"1s"``.
212
+ Omitted, the cadence is read off your generated frames; if those
213
+ are event-level there is no grid to match and nothing is
214
+ resampled.
215
+ match_generated_sample : bool, optional
216
+ Match the generated frames' grid even when ``sample_period`` is
217
+ given.
218
+ n_levels : int, default 10
219
+ Book levels to measure over.
220
+ plots : bool or list of str, optional
221
+ ``None`` or ``False`` for none, ``True`` for every figure, or plot
222
+ ids such as ``["statistical.radar", "stylised_facts.overall"]``.
223
+ Rendered server-side; :meth:`get_job` writes them to disk.
224
+
225
+ Returns
226
+ -------
227
+ dict
228
+ ``{"job_id": str, "status": str, "message": str}``. Pass the
229
+ job_id to :meth:`get_job`.
230
+
231
+ Raises
232
+ ------
233
+ ValueError
234
+ If the parameters are invalid.
235
+ """
236
+ if (sim_ids is None) == (sim_files is None):
237
+ raise ValueError("Pass exactly one of sim_ids or sim_files")
238
+ if sim_ids is not None and not sim_ids:
239
+ raise ValueError("sim_ids must not be empty")
240
+ if sim_files is not None and not sim_files:
241
+ raise ValueError("sim_files must not be empty")
242
+ count = len(sim_ids if sim_ids is not None else sim_files)
243
+ if count > MAX_SIM_FILES:
244
+ raise ValueError(f"Maximum {MAX_SIM_FILES} simulations per validation job")
245
+ if ticksize <= 0:
246
+ raise ValueError("ticksize must be positive")
247
+ if n_levels < 1:
248
+ raise ValueError("n_levels must be at least 1")
249
+
250
+ config = _build_config(
251
+ n_levels,
252
+ statistical=statistical,
253
+ stylised_facts=stylised_facts,
254
+ impact=impact,
255
+ volume_correlation=volume_correlation,
256
+ fid=fid,
257
+ mind=mind,
258
+ lob=lob,
259
+ sample_period=sample_period,
260
+ match_generated_sample=match_generated_sample,
261
+ plots=plots,
262
+ )
263
+
264
+ if sim_ids is not None:
265
+ submitted = self._client._request(
266
+ "POST",
267
+ RUN_PATH,
268
+ json={
269
+ "symbol": symbol,
270
+ "date": date,
271
+ "provider": provider,
272
+ "exchange": exchange,
273
+ "sim_ids": list(sim_ids),
274
+ "ticksize": ticksize,
275
+ "config": config,
276
+ },
277
+ )
278
+ else:
279
+ files = [
280
+ ("sim_files", _frame_to_parquet(entry, i))
281
+ for i, entry in enumerate(sim_files)
282
+ ]
283
+ submitted = self._client._request(
284
+ "POST",
285
+ UPLOAD_PATH,
286
+ files=files,
287
+ data={
288
+ "symbol": symbol,
289
+ "date": date,
290
+ "provider": provider,
291
+ "exchange": exchange,
292
+ "ticksize": str(ticksize),
293
+ "config": json.dumps(config),
294
+ },
295
+ )
296
+
297
+ if not (submitted or {}).get("job_id"):
298
+ raise ValueError(f"Validation API returned no job_id: {submitted}")
299
+ return submitted
300
+
301
+
302
+ def get_job(self, job_id: str, plot_dir=None) -> dict:
303
+ """Fetch a validation job's status, and its result once it is done.
304
+
305
+ This is the status endpoint: call it until ``status`` is
306
+ ``"completed"`` or ``"failed"``.
307
+
308
+ Parameters
309
+ ----------
310
+ job_id : str
311
+ From :meth:`run`.
312
+ plot_dir : str or Path, optional
313
+ Where to write any figures the job rendered. Defaults to the
314
+ current directory; created if it does not exist. Only has an
315
+ effect once the job is complete and only if ``plots`` was set on
316
+ the run. The paths written come back in ``plot_paths``.
317
+
318
+ Returns
319
+ -------
320
+ dict
321
+ ``job_id``, ``status``, ``symbol``, ``date`` and, once complete:
322
+ ``metadata``, ``distances``, ``distributions``,
323
+ ``impact_response``, ``stylised_facts``,
324
+ ``stylised_fact_verdicts``, ``volume_correlation``,
325
+ ``fid_scores``, ``mind_scores`` — enough to rebuild every figure —
326
+ plus ``plot_paths`` when figures were written.
327
+ """
328
+ job = self._client._request("GET", f"{JOBS_PATH}/{job_id}")
329
+ if (job or {}).get("plots"):
330
+ job["plot_paths"] = _save_plots(job, plot_dir)
331
+ return job
332
+
333
+ def list_jobs(self, limit: int = 50) -> dict:
334
+ """List your validation jobs, newest first.
335
+
336
+ Parameters
337
+ ----------
338
+ limit : int, default 50
339
+ Maximum number of jobs to return.
340
+
341
+ Returns
342
+ -------
343
+ dict
344
+ ``{"jobs": list, "total": int}``.
345
+ """
346
+ return self._client._request("GET", JOBS_PATH, params={"limit": limit})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev5
3
+ Version: 0.7.0.dev7
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