simudyne-pulse 0.7.0.dev6__tar.gz → 0.7.0.dev8__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 (28) hide show
  1. {simudyne_pulse-0.7.0.dev6/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev8}/PKG-INFO +50 -1
  2. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/README.md +49 -0
  3. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/pyproject.toml +1 -1
  4. simudyne_pulse-0.7.0.dev8/src/simudyne/resources/validation.py +348 -0
  5. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8/src/simudyne_pulse.egg-info}/PKG-INFO +50 -1
  6. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/tests/test_new_resources.py +11 -11
  7. simudyne_pulse-0.7.0.dev8/tests/test_validation.py +232 -0
  8. simudyne_pulse-0.7.0.dev6/src/simudyne/resources/validation.py +0 -822
  9. simudyne_pulse-0.7.0.dev6/tests/test_validation.py +0 -268
  10. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/LICENSE +0 -0
  11. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/setup.cfg +0 -0
  12. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/__init__.py +0 -0
  13. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/client.py +0 -0
  14. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/exceptions.py +0 -0
  15. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/__init__.py +0 -0
  16. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/api_keys.py +0 -0
  17. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/data.py +0 -0
  18. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/fix.py +0 -0
  19. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/fm.py +0 -0
  20. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/historical.py +0 -0
  21. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/profile.py +0 -0
  22. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/simulation.py +0 -0
  23. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne/resources/simulator_gym.py +0 -0
  24. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -0
  25. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  26. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  27. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  28. {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev8}/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.dev6
3
+ Version: 0.7.0.dev8
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
@@ -59,6 +59,55 @@ pip install --pre simudyne-pulse
59
59
  Only use dev builds for testing unreleased changes; they are not guaranteed
60
60
  stable. Merge `dev` into `prod` to promote those changes to a stable release.
61
61
 
62
+ ### Working from a checkout
63
+
64
+ To run the SDK from source — editing it, or using unreleased changes — install
65
+ it editable **into the interpreter you will actually import from**. A notebook
66
+ kernel is frequently not the python on your `$PATH`:
67
+
68
+ ```bash
69
+ # in a notebook, find the right interpreter first
70
+ import sys; print(sys.executable)
71
+
72
+ # then, with that path
73
+ <that python> -m pip install -e /path/to/pulse-sdk
74
+ ```
75
+
76
+ Check which copy you loaded — this is worth doing whenever an attribute seems
77
+ to be missing:
78
+
79
+ ```python
80
+ import simudyne
81
+ from importlib.metadata import version
82
+
83
+ print(simudyne.__file__) # should be .../pulse-sdk/src/simudyne/...
84
+ print(version("simudyne-pulse"))
85
+ ```
86
+
87
+ #### If an older install shadows it
88
+
89
+ The SDK was once published under the distribution name **`simudyne`**; it is
90
+ now **`simudyne-pulse`**. Both ship a module called `simudyne`, and the older
91
+ one installs a real directory while the editable install only adds a path
92
+ entry — so the old copy wins and you get errors like
93
+ `'PulseABM' object has no attribute 'validation'` from a version that predates
94
+ the feature. `pip list` shows both. Remove the obsolete one:
95
+
96
+ ```bash
97
+ <that python> -m pip uninstall simudyne # the old distribution
98
+ <that python> -m pip install -e /path/to/pulse-sdk
99
+ ```
100
+
101
+ ### Running the tests
102
+
103
+ ```bash
104
+ uv run --with pytest --with requests --with pandas --with pyarrow \
105
+ python -m pytest tests/ -q
106
+ ```
107
+
108
+ No network: the client's `_request` is replaced with a recorder, so the tests
109
+ assert the exact payload the API would receive.
110
+
62
111
  ## Quick start
63
112
 
64
113
  ```python
@@ -33,6 +33,55 @@ pip install --pre simudyne-pulse
33
33
  Only use dev builds for testing unreleased changes; they are not guaranteed
34
34
  stable. Merge `dev` into `prod` to promote those changes to a stable release.
35
35
 
36
+ ### Working from a checkout
37
+
38
+ To run the SDK from source — editing it, or using unreleased changes — install
39
+ it editable **into the interpreter you will actually import from**. A notebook
40
+ kernel is frequently not the python on your `$PATH`:
41
+
42
+ ```bash
43
+ # in a notebook, find the right interpreter first
44
+ import sys; print(sys.executable)
45
+
46
+ # then, with that path
47
+ <that python> -m pip install -e /path/to/pulse-sdk
48
+ ```
49
+
50
+ Check which copy you loaded — this is worth doing whenever an attribute seems
51
+ to be missing:
52
+
53
+ ```python
54
+ import simudyne
55
+ from importlib.metadata import version
56
+
57
+ print(simudyne.__file__) # should be .../pulse-sdk/src/simudyne/...
58
+ print(version("simudyne-pulse"))
59
+ ```
60
+
61
+ #### If an older install shadows it
62
+
63
+ The SDK was once published under the distribution name **`simudyne`**; it is
64
+ now **`simudyne-pulse`**. Both ship a module called `simudyne`, and the older
65
+ one installs a real directory while the editable install only adds a path
66
+ entry — so the old copy wins and you get errors like
67
+ `'PulseABM' object has no attribute 'validation'` from a version that predates
68
+ the feature. `pip list` shows both. Remove the obsolete one:
69
+
70
+ ```bash
71
+ <that python> -m pip uninstall simudyne # the old distribution
72
+ <that python> -m pip install -e /path/to/pulse-sdk
73
+ ```
74
+
75
+ ### Running the tests
76
+
77
+ ```bash
78
+ uv run --with pytest --with requests --with pandas --with pyarrow \
79
+ python -m pytest tests/ -q
80
+ ```
81
+
82
+ No network: the client's `_request` is replaced with a recorder, so the tests
83
+ assert the exact payload the API would receive.
84
+
36
85
  ## Quick start
37
86
 
38
87
  ```python
@@ -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.6"
7
+ version = "0.7.0-dev.8"
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,348 @@
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
+ # Three-tuple parts: the content type matters to the server's
280
+ # multipart parser, so it is sent explicitly.
281
+ files = [
282
+ ("sim_files", (*_frame_to_parquet(entry, i), "application/octet-stream"))
283
+ for i, entry in enumerate(sim_files)
284
+ ]
285
+ submitted = self._client._request(
286
+ "POST",
287
+ UPLOAD_PATH,
288
+ files=files,
289
+ data={
290
+ "symbol": symbol,
291
+ "date": date,
292
+ "provider": provider,
293
+ "exchange": exchange,
294
+ "ticksize": str(ticksize),
295
+ "config": json.dumps(config),
296
+ },
297
+ )
298
+
299
+ if not (submitted or {}).get("job_id"):
300
+ raise ValueError(f"Validation API returned no job_id: {submitted}")
301
+ return submitted
302
+
303
+
304
+ def get_job(self, job_id: str, plot_dir=None) -> dict:
305
+ """Fetch a validation job's status, and its result once it is done.
306
+
307
+ This is the status endpoint: call it until ``status`` is
308
+ ``"completed"`` or ``"failed"``.
309
+
310
+ Parameters
311
+ ----------
312
+ job_id : str
313
+ From :meth:`run`.
314
+ plot_dir : str or Path, optional
315
+ Where to write any figures the job rendered. Defaults to the
316
+ current directory; created if it does not exist. Only has an
317
+ effect once the job is complete and only if ``plots`` was set on
318
+ the run. The paths written come back in ``plot_paths``.
319
+
320
+ Returns
321
+ -------
322
+ dict
323
+ ``job_id``, ``status``, ``symbol``, ``date`` and, once complete:
324
+ ``metadata``, ``distances``, ``distributions``,
325
+ ``impact_response``, ``stylised_facts``,
326
+ ``stylised_fact_verdicts``, ``volume_correlation``,
327
+ ``fid_scores``, ``mind_scores`` — enough to rebuild every figure —
328
+ plus ``plot_paths`` when figures were written.
329
+ """
330
+ job = self._client._request("GET", f"{JOBS_PATH}/{job_id}")
331
+ if (job or {}).get("plots"):
332
+ job["plot_paths"] = _save_plots(job, plot_dir)
333
+ return job
334
+
335
+ def list_jobs(self, limit: int = 50) -> dict:
336
+ """List your validation jobs, newest first.
337
+
338
+ Parameters
339
+ ----------
340
+ limit : int, default 50
341
+ Maximum number of jobs to return.
342
+
343
+ Returns
344
+ -------
345
+ dict
346
+ ``{"jobs": list, "total": int}``.
347
+ """
348
+ 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.dev6
3
+ Version: 0.7.0.dev8
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
@@ -59,6 +59,55 @@ pip install --pre simudyne-pulse
59
59
  Only use dev builds for testing unreleased changes; they are not guaranteed
60
60
  stable. Merge `dev` into `prod` to promote those changes to a stable release.
61
61
 
62
+ ### Working from a checkout
63
+
64
+ To run the SDK from source — editing it, or using unreleased changes — install
65
+ it editable **into the interpreter you will actually import from**. A notebook
66
+ kernel is frequently not the python on your `$PATH`:
67
+
68
+ ```bash
69
+ # in a notebook, find the right interpreter first
70
+ import sys; print(sys.executable)
71
+
72
+ # then, with that path
73
+ <that python> -m pip install -e /path/to/pulse-sdk
74
+ ```
75
+
76
+ Check which copy you loaded — this is worth doing whenever an attribute seems
77
+ to be missing:
78
+
79
+ ```python
80
+ import simudyne
81
+ from importlib.metadata import version
82
+
83
+ print(simudyne.__file__) # should be .../pulse-sdk/src/simudyne/...
84
+ print(version("simudyne-pulse"))
85
+ ```
86
+
87
+ #### If an older install shadows it
88
+
89
+ The SDK was once published under the distribution name **`simudyne`**; it is
90
+ now **`simudyne-pulse`**. Both ship a module called `simudyne`, and the older
91
+ one installs a real directory while the editable install only adds a path
92
+ entry — so the old copy wins and you get errors like
93
+ `'PulseABM' object has no attribute 'validation'` from a version that predates
94
+ the feature. `pip list` shows both. Remove the obsolete one:
95
+
96
+ ```bash
97
+ <that python> -m pip uninstall simudyne # the old distribution
98
+ <that python> -m pip install -e /path/to/pulse-sdk
99
+ ```
100
+
101
+ ### Running the tests
102
+
103
+ ```bash
104
+ uv run --with pytest --with requests --with pandas --with pyarrow \
105
+ python -m pytest tests/ -q
106
+ ```
107
+
108
+ No network: the client's `_request` is replaced with a recorder, so the tests
109
+ assert the exact payload the API would receive.
110
+
62
111
  ## Quick start
63
112
 
64
113
  ```python
@@ -1,4 +1,4 @@
1
- """Payload tests for the 0.8.0 surface: validation.run_upload, the fm and fix
1
+ """Payload tests for the 0.8.0 surface: validation.run, the fm and fix
2
2
  resources, and simulation.get_job_logs / run_lrm.
3
3
 
4
4
  No network — same recorder pattern as test_validation.py. These pin the wire
@@ -30,7 +30,7 @@ class TestRunUpload:
30
30
  def test_rejects_empty_before_any_request(self):
31
31
  client = FakeClient()
32
32
  with pytest.raises(ValueError, match="empty"):
33
- ValidationResource(client).run_upload(
33
+ ValidationResource(client).run(
34
34
  "700.HK", "2025-09-01", "omd", "hkex_securities", sim_files=[]
35
35
  )
36
36
  assert client.calls == []
@@ -39,18 +39,18 @@ class TestRunUpload:
39
39
  client = FakeClient()
40
40
  files = [(f"sim_{i}.parquet", b"x") for i in range(26)]
41
41
  with pytest.raises(ValueError, match="25"):
42
- ValidationResource(client).run_upload(
42
+ ValidationResource(client).run(
43
43
  "700.HK", "2025-09-01", "omd", "hkex_securities", sim_files=files
44
44
  )
45
45
  assert client.calls == []
46
46
 
47
47
  def test_multipart_payload_shape(self):
48
48
  client = FakeClient([{"job_id": "v1", "status": "pending"}])
49
- ValidationResource(client).run_upload(
49
+ ValidationResource(client).run(
50
50
  "700.HK", "2025-09-01", "omd", "hkex_securities",
51
51
  sim_files=[("sim_0000.parquet", b"PARQ")],
52
52
  ticksize=0.5,
53
- run_metrics=False,
53
+ statistical=False,
54
54
  )
55
55
  method, path, kwargs = client.calls[0]
56
56
  assert (method, path) == ("POST", "/validation/run/upload")
@@ -58,11 +58,11 @@ class TestRunUpload:
58
58
  assert data["provider"] == "omd" and data["exchange"] == "hkex_securities"
59
59
  assert data["ticksize"] == "0.5"
60
60
  config = json.loads(data["config"])
61
- # explicit False sent; unset tri-state flags omitted (tier default)
62
- assert config["run_metrics"] is False
63
- for flag in ("run_impact", "run_stylised_facts", "plot_data"):
61
+ # explicit False sent; unset area flags omitted (tier default)
62
+ assert config["statistical"] is False
63
+ for flag in ("impact", "stylised_facts", "fid", "mind",
64
+ "run_metrics", "plot_data"):
64
65
  assert flag not in config
65
- assert config["run_fid"] is True
66
66
  [(field, (filename, content, mime))] = kwargs["files"]
67
67
  assert field == "sim_files" and filename == "sim_0000.parquet"
68
68
  assert content == b"PARQ" and mime == "application/octet-stream"
@@ -70,8 +70,8 @@ class TestRunUpload:
70
70
  def test_reads_paths_from_disk(self, tmp_path):
71
71
  p = tmp_path / "sim_0001.parquet"
72
72
  p.write_bytes(b"BYTES")
73
- client = FakeClient([{}])
74
- ValidationResource(client).run_upload(
73
+ client = FakeClient([{"job_id": "v1"}])
74
+ ValidationResource(client).run(
75
75
  "700.HK", "2025-09-01", "omd", "hkex_securities", sim_files=[p]
76
76
  )
77
77
  [(_, (filename, content, _))] = client.calls[0][2]["files"]