simudyne-pulse 0.7.0.dev15__tar.gz → 0.7.0.dev16__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.7.0.dev15/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev16}/PKG-INFO +28 -1
  2. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/README.md +27 -0
  3. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/pyproject.toml +1 -1
  4. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/validation.py +114 -53
  5. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16/src/simudyne_pulse.egg-info}/PKG-INFO +28 -1
  6. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/tests/test_validation.py +55 -2
  7. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/LICENSE +0 -0
  8. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/setup.cfg +0 -0
  9. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/__init__.py +0 -0
  10. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/client.py +0 -0
  11. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/exceptions.py +0 -0
  12. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/__init__.py +0 -0
  13. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/api_keys.py +0 -0
  14. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/data.py +0 -0
  15. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/fix.py +0 -0
  16. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/fm.py +0 -0
  17. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/profile.py +0 -0
  18. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/simulation.py +0 -0
  19. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/simulator_gym.py +0 -0
  20. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -0
  21. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  22. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  23. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  24. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/tests/test_new_resources.py +0 -0
  25. {simudyne_pulse-0.7.0.dev15 → simudyne_pulse-0.7.0.dev16}/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.dev15
3
+ Version: 0.7.0.dev16
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
@@ -85,6 +85,33 @@ population. `sim_files` groups the same way.
85
85
  A plain list is one unnamed population and keeps the flat shape exactly as
86
86
  before. The historical day is measured once however many groups there are.
87
87
 
88
+ ### Your own runs beside platform runs
89
+
90
+ `sim_ids` and `sim_files` can be given together — your own model against
91
+ platform simulations, on one set of figures, 25 runs in total across the two:
92
+
93
+ ```python
94
+ job = client.validation.run(
95
+ symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
96
+ sim_files={"fm": ["my_run.parquet"]},
97
+ sim_ids={"abm": abm_ids},
98
+ plots=["statistical.radar", "stylised_facts.overall"],
99
+ )
100
+ ```
101
+
102
+ With both present each source is a population of its own, so a bare list gets
103
+ a name rather than merging into whatever else is there:
104
+
105
+ | passed | populations |
106
+ | --- | --- |
107
+ | files mapping + ids list | its names, plus `platform` |
108
+ | files list + ids mapping | `uploaded`, plus its names |
109
+ | both lists | `uploaded` and `platform` |
110
+ | both mappings | their own names |
111
+ | either one alone | exactly as before |
112
+
113
+ The uploads are measured first and the platform runs follow.
114
+
88
115
  `plots` is the only plot switch: unset draws nothing, `True` draws every
89
116
  figure the enabled areas can draw, and a list draws just those ids. An area
90
117
  switched off draws nothing either way.
@@ -59,6 +59,33 @@ population. `sim_files` groups the same way.
59
59
  A plain list is one unnamed population and keeps the flat shape exactly as
60
60
  before. The historical day is measured once however many groups there are.
61
61
 
62
+ ### Your own runs beside platform runs
63
+
64
+ `sim_ids` and `sim_files` can be given together — your own model against
65
+ platform simulations, on one set of figures, 25 runs in total across the two:
66
+
67
+ ```python
68
+ job = client.validation.run(
69
+ symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
70
+ sim_files={"fm": ["my_run.parquet"]},
71
+ sim_ids={"abm": abm_ids},
72
+ plots=["statistical.radar", "stylised_facts.overall"],
73
+ )
74
+ ```
75
+
76
+ With both present each source is a population of its own, so a bare list gets
77
+ a name rather than merging into whatever else is there:
78
+
79
+ | passed | populations |
80
+ | --- | --- |
81
+ | files mapping + ids list | its names, plus `platform` |
82
+ | files list + ids mapping | `uploaded`, plus its names |
83
+ | both lists | `uploaded` and `platform` |
84
+ | both mappings | their own names |
85
+ | either one alone | exactly as before |
86
+
87
+ The uploads are measured first and the platform runs follow.
88
+
62
89
  `plots` is the only plot switch: unset draws nothing, `True` draws every
63
90
  figure the enabled areas can draw, and a list draws just those ids. An area
64
91
  switched off draws nothing either way.
@@ -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.15"
7
+ version = "0.7.0-dev.16"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -108,6 +108,62 @@ def _frame_to_parquet(entry, index: int):
108
108
  return path.name, path.read_bytes()
109
109
 
110
110
 
111
+ def _count(sims) -> int:
112
+ """How many runs this argument names, mapping or list."""
113
+ if sims is None:
114
+ return 0
115
+ if isinstance(sims, dict):
116
+ return sum(len(list(v)) for v in sims.values())
117
+ return len(sims)
118
+
119
+
120
+ def _check_groups(sims, name: str) -> None:
121
+ """Every group's value is a list of runs, and a bare string is not one.
122
+
123
+ ``{"fm": "one.parquet"}`` is the natural thing to write for a group of
124
+ one. A string is iterable, so it used to count the path's characters as
125
+ runs and fail on the total — a number the caller never wrote.
126
+ """
127
+ if not isinstance(sims, dict):
128
+ return
129
+ for group, runs in sims.items():
130
+ if isinstance(runs, (str, bytes)):
131
+ raise ValueError(
132
+ f"{name}[{group!r}] must be a list of runs, not a "
133
+ f"single {type(runs).__name__} — write [{runs!r}]"
134
+ )
135
+ if not isinstance(runs, Iterable):
136
+ raise ValueError(
137
+ f"{name}[{group!r}] must be a list of runs, not "
138
+ f"{type(runs).__name__}"
139
+ )
140
+
141
+
142
+ def _flatten(sims, *, offset: int, unnamed):
143
+ """``(flat runs, {name: [indices]} | None)``, indices starting at offset.
144
+
145
+ A job may draw from two sources at once: the uploaded files take the first
146
+ slots and the platform runs follow, so the second source's indices start
147
+ past the first. ``unnamed`` names a bare list so it can be a population
148
+ beside a named one; None leaves it ungrouped, as a single-source job has
149
+ always been.
150
+ """
151
+ if isinstance(sims, dict):
152
+ flat, groups, at = [], {}, offset
153
+ for name, runs in sims.items():
154
+ runs = list(runs)
155
+ if not runs:
156
+ raise ValueError(f"Group {name!r} has no simulations")
157
+ groups[name] = list(range(at, at + len(runs)))
158
+ at += len(runs)
159
+ flat.extend(runs)
160
+ return flat, groups
161
+ flat = list(sims)
162
+ if unnamed is None:
163
+ return flat, None
164
+ return flat, {unnamed: list(range(offset, offset + len(flat)))}
165
+
166
+
111
167
  def _build_config(n_levels, **flags) -> dict:
112
168
  """The validation config as the API expects it.
113
169
 
@@ -234,11 +290,29 @@ class ValidationResource:
234
290
  the named populations: distances, distributions, verdicts and
235
291
  FID/MIND scores come back keyed by name, and the summary figures
236
292
  draw one series per name instead of averaging them together.
237
- Exactly one of ``sim_ids`` or ``sim_files`` is required.
238
293
  sim_files : list or dict, optional
239
- Your own simulated runs, up to 25: paths, ``(filename, bytes)``
240
- pairs, or polars / pandas DataFrames in pulse format. Grouped the
241
- same way as ``sim_ids``. The historical side is fetched for you.
294
+ Your own simulated runs: paths, ``(filename, bytes)`` pairs, or
295
+ polars / pandas DataFrames in pulse format. Grouped the same way
296
+ as ``sim_ids``. The historical side is fetched for you.
297
+
298
+ At least one of ``sim_ids`` and ``sim_files`` is required, and
299
+ both together is allowed — your own model against platform runs,
300
+ on one set of figures. 25 runs in total across the two. With both
301
+ present each source is a population of its own, so a bare list
302
+ gets a name rather than merging into whatever else is there:
303
+
304
+ =========================== ==============================
305
+ passed populations
306
+ =========================== ==============================
307
+ files mapping + ids list its names, plus ``platform``
308
+ files list + ids mapping ``uploaded``, plus its names
309
+ both lists ``uploaded`` and ``platform``
310
+ both mappings their own names
311
+ either one alone exactly as before
312
+ =========================== ==============================
313
+
314
+ Every population is pooled and averaged, and drawn as its own
315
+ radar polygon, verdict-grid column and correlation heatmap.
242
316
  statistical, stylised_facts, impact, volume_correlation, fid, mind : bool, optional
243
317
  Whether to run each area. ``None`` uses your tier's default.
244
318
  lob : bool, optional
@@ -338,36 +412,19 @@ class ValidationResource:
338
412
  ... sample_period="1s",
339
413
  ... )
340
414
  """
341
- if (sim_ids is None) == (sim_files is None):
342
- raise ValueError("Pass exactly one of sim_ids or sim_files")
415
+ if sim_ids is None and sim_files is None:
416
+ raise ValueError("Pass sim_ids, sim_files, or both")
343
417
  if sim_ids is not None and not sim_ids:
344
418
  raise ValueError("sim_ids must not be empty")
345
419
  if sim_files is not None and not sim_files:
346
420
  raise ValueError("sim_files must not be empty")
347
- given = sim_ids if sim_ids is not None else sim_files
348
- name = "sim_ids" if sim_ids is not None else "sim_files"
349
- if isinstance(given, dict):
350
- # A bare string is iterable, so {"fm": "one.parquet"} used to count
351
- # its characters as runs and fail with "Maximum 25 simulations" —
352
- # a number the caller never wrote. Say what is actually wrong.
353
- for group, runs in given.items():
354
- if isinstance(runs, (str, bytes)):
355
- raise ValueError(
356
- f"{name}[{group!r}] must be a list of runs, not a "
357
- f"single {type(runs).__name__} — write [{runs!r}]"
358
- )
359
- if not isinstance(runs, Iterable):
360
- raise ValueError(
361
- f"{name}[{group!r}] must be a list of runs, not "
362
- f"{type(runs).__name__}"
363
- )
364
- count = (
365
- sum(len(list(v)) for v in given.values())
366
- if isinstance(given, dict)
367
- else len(given)
368
- )
369
- if count > MAX_SIM_FILES:
370
- raise ValueError(f"Maximum {MAX_SIM_FILES} simulations per validation job")
421
+ _check_groups(sim_files, "sim_files")
422
+ _check_groups(sim_ids, "sim_ids")
423
+ if _count(sim_files) + _count(sim_ids) > MAX_SIM_FILES:
424
+ raise ValueError(
425
+ f"Maximum {MAX_SIM_FILES} simulations per validation job, "
426
+ "sim_files and sim_ids together"
427
+ )
371
428
  if n_levels < 1:
372
429
  raise ValueError("n_levels must be at least 1")
373
430
 
@@ -385,7 +442,10 @@ class ValidationResource:
385
442
  plots=plots,
386
443
  )
387
444
 
388
- if sim_ids is not None:
445
+ # Platform runs alone go as JSON; anything with files of your own
446
+ # goes multipart, because bytes cannot travel in the JSON body. The
447
+ # upload endpoint takes sim_ids too, so a mixed job is one request.
448
+ if sim_files is None:
389
449
  submitted = self._client._request(
390
450
  "POST",
391
451
  RUN_PATH,
@@ -403,37 +463,38 @@ class ValidationResource:
403
463
  },
404
464
  )
405
465
  else:
466
+ # The uploads take the first slots of the combined run list and
467
+ # the platform runs follow. Only the uploads' own grouping is sent:
468
+ # the server offsets the platform runs past the files and names a
469
+ # bare list on either side, so the offset has one implementation
470
+ # rather than two that can disagree.
471
+ flat, groups = _flatten(sim_files, offset=0, unnamed=None)
472
+
406
473
  # Three-tuple parts: the content type matters to the server's
407
474
  # multipart parser, so it is sent explicitly.
408
- # Grouped uploads travel flat with the grouping beside them, the
409
- # same way sim_ids do — multipart has no nesting.
410
- if isinstance(sim_files, dict):
411
- flat, groups, offset = [], {}, 0
412
- for name, runs in sim_files.items():
413
- runs = list(runs)
414
- groups[name] = list(range(offset, offset + len(runs)))
415
- offset += len(runs)
416
- flat.extend(runs)
417
- else:
418
- flat, groups = list(sim_files), None
419
-
420
475
  files = [
421
476
  ("sim_files", (*_frame_to_parquet(entry, i), "application/octet-stream"))
422
477
  for i, entry in enumerate(flat)
423
478
  ]
479
+ # Multipart has no nesting, so the grouping rides in the config
480
+ # and the ids in a JSON string field.
424
481
  if groups:
425
482
  config = {**config, "sim_groups": groups}
483
+ data = {
484
+ "symbol": symbol,
485
+ "date": date,
486
+ "provider": provider,
487
+ "exchange": exchange,
488
+ "config": json.dumps(config),
489
+ }
490
+ if sim_ids is not None:
491
+ data["sim_ids"] = json.dumps(
492
+ {k: list(v) for k, v in sim_ids.items()}
493
+ if isinstance(sim_ids, dict)
494
+ else list(sim_ids)
495
+ )
426
496
  submitted = self._client._request(
427
- "POST",
428
- UPLOAD_PATH,
429
- files=files,
430
- data={
431
- "symbol": symbol,
432
- "date": date,
433
- "provider": provider,
434
- "exchange": exchange,
435
- "config": json.dumps(config),
436
- },
497
+ "POST", UPLOAD_PATH, files=files, data=data
437
498
  )
438
499
 
439
500
  if not (submitted or {}).get("job_id"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev15
3
+ Version: 0.7.0.dev16
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
@@ -85,6 +85,33 @@ population. `sim_files` groups the same way.
85
85
  A plain list is one unnamed population and keeps the flat shape exactly as
86
86
  before. The historical day is measured once however many groups there are.
87
87
 
88
+ ### Your own runs beside platform runs
89
+
90
+ `sim_ids` and `sim_files` can be given together — your own model against
91
+ platform simulations, on one set of figures, 25 runs in total across the two:
92
+
93
+ ```python
94
+ job = client.validation.run(
95
+ symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
96
+ sim_files={"fm": ["my_run.parquet"]},
97
+ sim_ids={"abm": abm_ids},
98
+ plots=["statistical.radar", "stylised_facts.overall"],
99
+ )
100
+ ```
101
+
102
+ With both present each source is a population of its own, so a bare list gets
103
+ a name rather than merging into whatever else is there:
104
+
105
+ | passed | populations |
106
+ | --- | --- |
107
+ | files mapping + ids list | its names, plus `platform` |
108
+ | files list + ids mapping | `uploaded`, plus its names |
109
+ | both lists | `uploaded` and `platform` |
110
+ | both mappings | their own names |
111
+ | either one alone | exactly as before |
112
+
113
+ The uploads are measured first and the platform runs follow.
114
+
88
115
  `plots` is the only plot switch: unset draws nothing, `True` draws every
89
116
  figure the enabled areas can draw, and a list draws just those ids. An area
90
117
  switched off draws nothing either way.
@@ -79,8 +79,7 @@ class TestWhatToValidate:
79
79
  @pytest.mark.parametrize(
80
80
  "kwargs,match",
81
81
  [
82
- ({}, "exactly one"),
83
- ({"sim_ids": SIM_IDS, "sim_files": [("a", b"x")]}, "exactly one"),
82
+ ({}, "Pass sim_ids, sim_files, or both"),
84
83
  ({"sim_ids": []}, "must not be empty"),
85
84
  ({"sim_ids": SIM_IDS * (MAX_SIM_FILES + 1)}, "Maximum 25"),
86
85
  ({"sim_ids": SIM_IDS, "n_levels": 0}, "at least 1"),
@@ -353,3 +352,57 @@ class TestTickSizeIsNotAsked:
353
352
  client = FakeClient([{"job_id": "v1"}])
354
353
  ValidationResource(client).run(sim_files=[("a.parquet", b"x")], **IDENTITY)
355
354
  assert "ticksize" not in client.calls[0][2]["data"]
355
+
356
+
357
+ class TestBothSourcesAtOnce:
358
+ """Your own parquet and platform runs in one job.
359
+
360
+ Anything with files goes multipart, because bytes cannot travel in a JSON
361
+ body — so a mixed job is an upload that also names sim_ids. Only the
362
+ uploads' own grouping is sent: the server offsets the platform runs past
363
+ the files, so the offset has one implementation rather than two.
364
+ """
365
+
366
+ @staticmethod
367
+ def _submit(**kwargs):
368
+ client = FakeClient([{"job_id": "v1"}])
369
+ ValidationResource(client).run(**{**IDENTITY, **kwargs})
370
+ call = client.calls[0][2]
371
+ return call, json.loads(call["data"]["config"])
372
+
373
+ def test_a_mixed_job_goes_to_the_upload_endpoint(self):
374
+ call, _ = self._submit(
375
+ sim_files=[("a.parquet", b"x")], sim_ids=["sim_a"]
376
+ )
377
+ assert "files" in call and call["data"]["sim_ids"] == json.dumps(["sim_a"])
378
+
379
+ def test_only_the_uploads_grouping_is_sent(self):
380
+ """The server owns the offset; sending both would double-count it."""
381
+ _, config = self._submit(
382
+ sim_files={"fm": [("a.parquet", b"x")]}, sim_ids={"abm": ["s"]}
383
+ )
384
+ assert config["sim_groups"] == {"fm": [0]}
385
+
386
+ def test_a_mapping_of_ids_travels_as_json(self):
387
+ call, _ = self._submit(
388
+ sim_files=[("a.parquet", b"x")], sim_ids={"abm": ["s1", "s2"]}
389
+ )
390
+ assert json.loads(call["data"]["sim_ids"]) == {"abm": ["s1", "s2"]}
391
+
392
+ def test_ids_alone_still_go_as_json(self):
393
+ client = FakeClient([{"job_id": "v1"}])
394
+ ValidationResource(client).run(sim_ids=SIM_IDS, **IDENTITY)
395
+ assert "json" in client.calls[0][2]
396
+ assert "sim_ids" not in client.calls[0][2].get("data", {})
397
+
398
+ def test_neither_is_refused(self):
399
+ with pytest.raises(ValueError, match="Pass sim_ids, sim_files, or both"):
400
+ ValidationResource(FakeClient([])).run(**IDENTITY)
401
+
402
+ def test_the_cap_counts_both_sources(self):
403
+ with pytest.raises(ValueError, match="together"):
404
+ ValidationResource(FakeClient([])).run(
405
+ sim_files=[("a.parquet", b"x")] * 13,
406
+ sim_ids=["s"] * 13,
407
+ **IDENTITY,
408
+ )