simudyne-pulse 0.7.0.dev12__tar.gz → 0.7.0.dev14__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 (26) hide show
  1. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/PKG-INFO +1 -1
  2. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/pyproject.toml +1 -1
  3. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/validation.py +156 -13
  4. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne_pulse.egg-info/PKG-INFO +1 -1
  5. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/tests/test_validation.py +65 -1
  6. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/LICENSE +0 -0
  7. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/README.md +0 -0
  8. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/setup.cfg +0 -0
  9. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/__init__.py +0 -0
  10. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/client.py +0 -0
  11. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/exceptions.py +0 -0
  12. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/__init__.py +0 -0
  13. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/api_keys.py +0 -0
  14. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/data.py +0 -0
  15. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/fix.py +0 -0
  16. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/fm.py +0 -0
  17. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/historical.py +0 -0
  18. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/profile.py +0 -0
  19. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/simulation.py +0 -0
  20. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne/resources/simulator_gym.py +0 -0
  21. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -0
  22. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  23. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  24. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  25. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/tests/test_new_resources.py +0 -0
  26. {simudyne_pulse-0.7.0.dev12 → simudyne_pulse-0.7.0.dev14}/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.dev12
3
+ Version: 0.7.0.dev14
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.12"
7
+ version = "0.7.0-dev.14"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,7 +9,7 @@ Submit, then poll::
9
9
 
10
10
  job = client.validation.run(
11
11
  symbol="BARC", date="2026-06-17", provider="bmll", exchange="lse",
12
- sim_ids=[...], ticksize=0.05,
12
+ sim_ids=[...],
13
13
  )
14
14
  result = client.validation.get_job(job["job_id"])
15
15
  result["distances"]["spread"]["l1"] # per simulation
@@ -27,6 +27,14 @@ default for your tier, resolved server-side, so naming none behaves exactly as
27
27
  your account is entitled to. Pass ``False`` to skip an expensive pass or
28
28
  ``True`` to force one on.
29
29
 
30
+ Tick size
31
+ ---------
32
+ Read off the historical day — the metadata sidecar, else pulse_format's own
33
+ metadata for that day's ``full_data.parquet``. It is not a parameter. The
34
+ result reports ``metadata["ticksize"]`` and ``metadata["ticksize_source"]``;
35
+ a source of ``"fallback"`` means none was found and 1.0 was assumed, which
36
+ puts the impact response in price units rather than ticks.
37
+
30
38
  The historical gate
31
39
  -------------------
32
40
  Whether the historical half of the comparison comes back — the series in the
@@ -39,6 +47,7 @@ them.
39
47
  """
40
48
 
41
49
  import json
50
+ from collections.abc import Iterable
42
51
  from pathlib import Path
43
52
 
44
53
 
@@ -146,7 +155,32 @@ def _save_plots(result: dict, plot_dir=None) -> list:
146
155
 
147
156
 
148
157
  class ValidationResource:
149
- """Validate simulated market data against a historical day."""
158
+ """Score simulated market data against a real historical day.
159
+
160
+ Reached as ``client.validation``. A job compares one or more simulated runs
161
+ — either platform ``sim_ids`` or your own uploaded frames — against the
162
+ real day for the same instrument, and reports distances, distribution
163
+ overlaps, stylised-fact verdicts and FID/MIND scores.
164
+
165
+ Jobs are asynchronous: :meth:`run` returns a ``job_id`` and :meth:`get_job`
166
+ is polled until it is terminal. A validation of a real day takes minutes.
167
+
168
+ Examples
169
+ --------
170
+ >>> import time
171
+ >>> job = client.validation.run(
172
+ ... symbol="BARC", date="2025-09-02",
173
+ ... provider="bmll", exchange="lse",
174
+ ... sim_ids=["sim_abc", "sim_def"],
175
+ ... plots=True,
176
+ ... )
177
+ >>> while True:
178
+ ... result = client.validation.get_job(job["job_id"], plot_dir="figures/")
179
+ ... if result["status"] in ("completed", "failed"):
180
+ ... break
181
+ ... time.sleep(30)
182
+ >>> result["distances"]
183
+ """
150
184
 
151
185
  def __init__(self, client):
152
186
  self._client = client
@@ -160,7 +194,6 @@ class ValidationResource:
160
194
  *,
161
195
  sim_ids=None,
162
196
  sim_files=None,
163
- ticksize: float = 1.0,
164
197
  statistical=None,
165
198
  stylised_facts=None,
166
199
  impact=None,
@@ -191,7 +224,10 @@ class ValidationResource:
191
224
  exists under both with different dates and tick sizes, so
192
225
  ``(symbol, provider, exchange)`` is the identity, not the symbol.
193
226
  exchange : str
194
- Exchange, e.g. ``"lse"`` or ``"hkex_securities"``.
227
+ Exchange, e.g. ``"lse"`` or ``"hkex_securities"``. The tick size
228
+ is read off the historical day for this instrument rather than
229
+ passed in; ``metadata["ticksize"]`` reports the value used and
230
+ ``metadata["ticksize_source"]`` where it came from.
195
231
  sim_ids : list of str or dict, optional
196
232
  Platform simulation IDs, 1 to 25. A flat list is one unnamed
197
233
  population. A mapping — ``{"fm": [...], "abm": [...]}`` — compares
@@ -203,8 +239,6 @@ class ValidationResource:
203
239
  Your own simulated runs, up to 25: paths, ``(filename, bytes)``
204
240
  pairs, or polars / pandas DataFrames in pulse format. Grouped the
205
241
  same way as ``sim_ids``. The historical side is fetched for you.
206
- ticksize : float, default 1.0
207
- Minimum price increment. Should match the instrument.
208
242
  statistical, stylised_facts, impact, volume_correlation, fid, mind : bool, optional
209
243
  Whether to run each area. ``None`` uses your tier's default.
210
244
  lob : bool, optional
@@ -249,7 +283,58 @@ class ValidationResource:
249
283
  Raises
250
284
  ------
251
285
  ValueError
252
- If the parameters are invalid.
286
+ If neither or both of ``sim_ids`` and ``sim_files`` are given,
287
+ either is empty, more than 25 runs are passed, or ``n_levels``
288
+ is below 1. Also raised if the API
289
+ accepts the request but returns no ``job_id``.
290
+ PulseAPIError
291
+ If the instrument has no historical data for ``date``, a
292
+ ``sim_id`` is unknown, or the tier does not allow a requested
293
+ area.
294
+
295
+ Examples
296
+ --------
297
+ Platform simulations against the real day:
298
+
299
+ >>> job = client.validation.run(
300
+ ... symbol="BARC",
301
+ ... date="2025-09-02",
302
+ ... provider="bmll",
303
+ ... exchange="lse",
304
+ ... sim_ids=["sim_abc", "sim_def"],
305
+ ... )
306
+ >>> job["job_id"]
307
+ 'val_01H...'
308
+
309
+ Two named populations, compared against each other and the real day:
310
+
311
+ >>> job = client.validation.run(
312
+ ... symbol="BARC", date="2025-09-02",
313
+ ... provider="bmll", exchange="lse",
314
+ ... sim_ids={"fm": ["sim_abc"], "abm": ["sim_def", "sim_ghi"]},
315
+ ... )
316
+
317
+ Your own frames, with only the areas you want and selected figures:
318
+
319
+ >>> job = client.validation.run(
320
+ ... symbol="BARC", date="2025-09-02",
321
+ ... provider="bmll", exchange="lse",
322
+ ... sim_files=[my_dataframe],
323
+ ... statistical=True,
324
+ ... stylised_facts=True,
325
+ ... impact=False,
326
+ ... plots=["statistical.radar", "stylised_facts.overall"],
327
+ ... )
328
+
329
+ L2 snapshots resampled onto a one-second grid:
330
+
331
+ >>> job = client.validation.run(
332
+ ... symbol="BARC", date="2025-09-02",
333
+ ... provider="bmll", exchange="lse",
334
+ ... sim_files=["book_snapshots.parquet"],
335
+ ... lob=True,
336
+ ... sample_period="1s",
337
+ ... )
253
338
  """
254
339
  if (sim_ids is None) == (sim_files is None):
255
340
  raise ValueError("Pass exactly one of sim_ids or sim_files")
@@ -258,15 +343,29 @@ class ValidationResource:
258
343
  if sim_files is not None and not sim_files:
259
344
  raise ValueError("sim_files must not be empty")
260
345
  given = sim_ids if sim_ids is not None else sim_files
346
+ name = "sim_ids" if sim_ids is not None else "sim_files"
347
+ if isinstance(given, dict):
348
+ # A bare string is iterable, so {"fm": "one.parquet"} used to count
349
+ # its characters as runs and fail with "Maximum 25 simulations" —
350
+ # a number the caller never wrote. Say what is actually wrong.
351
+ for group, runs in given.items():
352
+ if isinstance(runs, (str, bytes)):
353
+ raise ValueError(
354
+ f"{name}[{group!r}] must be a list of runs, not a "
355
+ f"single {type(runs).__name__} — write [{runs!r}]"
356
+ )
357
+ if not isinstance(runs, Iterable):
358
+ raise ValueError(
359
+ f"{name}[{group!r}] must be a list of runs, not "
360
+ f"{type(runs).__name__}"
361
+ )
261
362
  count = (
262
- sum(len(v) for v in given.values())
363
+ sum(len(list(v)) for v in given.values())
263
364
  if isinstance(given, dict)
264
365
  else len(given)
265
366
  )
266
367
  if count > MAX_SIM_FILES:
267
368
  raise ValueError(f"Maximum {MAX_SIM_FILES} simulations per validation job")
268
- if ticksize <= 0:
269
- raise ValueError("ticksize must be positive")
270
369
  if n_levels < 1:
271
370
  raise ValueError("n_levels must be at least 1")
272
371
 
@@ -298,7 +397,6 @@ class ValidationResource:
298
397
  if isinstance(sim_ids, dict)
299
398
  else list(sim_ids)
300
399
  ),
301
- "ticksize": ticksize,
302
400
  "config": config,
303
401
  },
304
402
  )
@@ -332,7 +430,6 @@ class ValidationResource:
332
430
  "date": date,
333
431
  "provider": provider,
334
432
  "exchange": exchange,
335
- "ticksize": str(ticksize),
336
433
  "config": json.dumps(config),
337
434
  },
338
435
  )
@@ -367,6 +464,34 @@ class ValidationResource:
367
464
  ``stylised_fact_verdicts``, ``volume_correlation``,
368
465
  ``fid_scores``, ``mind_scores`` — enough to rebuild every figure —
369
466
  plus ``plot_paths`` when figures were written.
467
+
468
+ Raises
469
+ ------
470
+ PulseAPIError
471
+ If ``job_id`` is unknown or belongs to another account — status
472
+ 404.
473
+
474
+ Examples
475
+ --------
476
+ >>> result = client.validation.get_job(job_id)
477
+ >>> result["status"]
478
+ 'running'
479
+
480
+ Poll to completion, writing any figures into a directory:
481
+
482
+ >>> import time
483
+ >>> while True:
484
+ ... result = client.validation.get_job(job_id, plot_dir="figures/")
485
+ ... if result["status"] in ("completed", "failed"):
486
+ ... break
487
+ ... time.sleep(30)
488
+ >>> print(result["plot_paths"])
489
+ ['figures/statistical.radar.png', 'figures/stylised_facts.overall.png']
490
+
491
+ With named populations, the scores come back keyed by name:
492
+
493
+ >>> result["fid_scores"]
494
+ {'fm': 0.142, 'abm': 0.318}
370
495
  """
371
496
  job = self._client._request("GET", f"{JOBS_PATH}/{job_id}")
372
497
  if (job or {}).get("plots"):
@@ -384,6 +509,24 @@ class ValidationResource:
384
509
  Returns
385
510
  -------
386
511
  dict
387
- ``{"jobs": list, "total": int}``.
512
+ Contains ``jobs``, a list of job summaries newest first, and
513
+ ``total``, the count before ``limit`` was applied.
514
+
515
+ Raises
516
+ ------
517
+ PulseAPIError
518
+ If the key is invalid, revoked or expired — status 401.
519
+
520
+ Examples
521
+ --------
522
+ >>> listing = client.validation.list_jobs(limit=10)
523
+ >>> for job in listing["jobs"]:
524
+ ... print(job["job_id"], job["status"], job["symbol"], job["date"])
525
+
526
+ Find the most recent completed job and read it back:
527
+
528
+ >>> done = [j for j in listing["jobs"] if j["status"] == "completed"]
529
+ >>> newest = done[0]
530
+ >>> result = client.validation.get_job(newest["job_id"])
388
531
  """
389
532
  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.dev12
3
+ Version: 0.7.0.dev14
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
@@ -83,7 +83,6 @@ class TestWhatToValidate:
83
83
  ({"sim_ids": SIM_IDS, "sim_files": [("a", b"x")]}, "exactly one"),
84
84
  ({"sim_ids": []}, "must not be empty"),
85
85
  ({"sim_ids": SIM_IDS * (MAX_SIM_FILES + 1)}, "Maximum 25"),
86
- ({"sim_ids": SIM_IDS, "ticksize": 0}, "ticksize must be positive"),
87
86
  ({"sim_ids": SIM_IDS, "n_levels": 0}, "at least 1"),
88
87
  ],
89
88
  )
@@ -289,3 +288,68 @@ class TestPlotEverything:
289
288
  def test_plot_all_is_gone(self):
290
289
  with pytest.raises(TypeError):
291
290
  _run([{"job_id": "v1"}], plot_all=True)
291
+
292
+
293
+ class TestGroupShape:
294
+ """A group's value is a list of runs. A bare string is not one.
295
+
296
+ ``{"fm": "one.parquet"}`` is the natural thing to write for a group of
297
+ one, and it used to count the path's characters as runs and fail with
298
+ "Maximum 25 simulations per validation job" — a number the caller never
299
+ wrote anywhere.
300
+ """
301
+
302
+ @staticmethod
303
+ def _submit(**kwargs):
304
+ return ValidationResource(FakeClient([{"job_id": "v1"}])).run(
305
+ **{**IDENTITY, **kwargs}
306
+ )
307
+
308
+ def test_a_string_names_itself_not_the_file_count(self):
309
+ with pytest.raises(ValueError, match=r"sim_files\['fm'\] must be a list"):
310
+ self._submit(sim_files={"fm": "one.parquet"})
311
+
312
+ def test_the_message_shows_the_fix(self):
313
+ with pytest.raises(ValueError, match=r"write \['one.parquet'\]"):
314
+ self._submit(sim_files={"fm": "one.parquet"})
315
+
316
+ def test_sim_ids_groups_are_checked_the_same_way(self):
317
+ with pytest.raises(ValueError, match=r"sim_ids\['abm'\] must be a list"):
318
+ self._submit(sim_ids={"abm": "sim_abc"})
319
+
320
+ def test_a_non_iterable_group_is_named_plainly(self):
321
+ with pytest.raises(ValueError, match=r"not NoneType"):
322
+ self._submit(sim_ids={"abm": None})
323
+
324
+ def test_a_real_list_still_passes(self):
325
+ client = FakeClient([{"job_id": "v1"}])
326
+ ValidationResource(client).run(sim_ids={"abm": ["a", "b"]}, **IDENTITY)
327
+ assert client.calls[0][2]["json"]["sim_ids"] == {"abm": ["a", "b"]}
328
+
329
+ def test_the_count_still_catches_too_many(self):
330
+ with pytest.raises(ValueError, match="Maximum 25"):
331
+ self._submit(sim_ids={"a": ["x"] * 13, "b": ["y"] * 13})
332
+
333
+
334
+ class TestTickSizeIsNotAsked:
335
+ """It is read off the historical day, so passing one is a mistake.
336
+
337
+ The value a caller passed never reached a computation — the impact
338
+ response has always used the one pulse-check looks up — so accepting it
339
+ only let a result disagree with itself.
340
+ """
341
+
342
+ def test_run_does_not_take_it(self):
343
+ with pytest.raises(TypeError, match="ticksize"):
344
+ ValidationResource(FakeClient([{"job_id": "v1"}])).run(
345
+ sim_ids=SIM_IDS, ticksize=0.1, **IDENTITY
346
+ )
347
+
348
+ def test_it_is_not_sent(self):
349
+ client, _ = _run([{"job_id": "v1"}])
350
+ assert "ticksize" not in client.calls[0][2]["json"]
351
+
352
+ def test_an_upload_does_not_send_it_either(self):
353
+ client = FakeClient([{"job_id": "v1"}])
354
+ ValidationResource(client).run(sim_files=[("a.parquet", b"x")], **IDENTITY)
355
+ assert "ticksize" not in client.calls[0][2]["data"]