simudyne-pulse 0.7.0.dev7__tar.gz → 0.7.0.dev9__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.
- {simudyne_pulse-0.7.0.dev7/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev9}/PKG-INFO +79 -1
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/README.md +78 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/pyproject.toml +1 -1
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/validation.py +47 -11
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9/src/simudyne_pulse.egg-info}/PKG-INFO +79 -1
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/SOURCES.txt +4 -0
- simudyne_pulse-0.7.0.dev9/tests/test_endpoint_coverage.py +142 -0
- simudyne_pulse-0.7.0.dev9/tests/test_fix.py +35 -0
- simudyne_pulse-0.7.0.dev9/tests/test_fm.py +144 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/tests/test_new_resources.py +11 -11
- simudyne_pulse-0.7.0.dev9/tests/test_run_lrm.py +117 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/tests/test_validation.py +57 -2
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/LICENSE +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/setup.cfg +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/__init__.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/client.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/exceptions.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/__init__.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/api_keys.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/data.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/fix.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/fm.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/historical.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/profile.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/simulation.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/simulator_gym.py +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/requires.txt +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
- {simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/tests/test_simulator_gym.py +0 -0
{simudyne_pulse-0.7.0.dev7/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev9}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simudyne-pulse
|
|
3
|
-
Version: 0.7.0.
|
|
3
|
+
Version: 0.7.0.dev9
|
|
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,84 @@ 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
|
+
### Comparing named populations
|
|
63
|
+
|
|
64
|
+
Simulations are often not one population but several — a foundation model and
|
|
65
|
+
an ABM, or two versions of a model. Name them and the results compare them
|
|
66
|
+
instead of averaging them together:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
job = client.validation.run(
|
|
70
|
+
symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
|
|
71
|
+
sim_ids={"fm": fm_ids, "abm": abm_ids},
|
|
72
|
+
plots=["statistical.radar", "stylised_facts.overall",
|
|
73
|
+
"volume_correlation.overall"],
|
|
74
|
+
)
|
|
75
|
+
result = client.validation.get_job(job["job_id"], plot_dir="figs")
|
|
76
|
+
result["distances"]["fm"]["spread"]["l1"]
|
|
77
|
+
result["stylised_fact_verdicts"]["heavy_tails"]["simulated"]["abm"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`distances`, `distributions`, the verdicts and the FID/MIND scores come back
|
|
81
|
+
keyed by name; the radar draws a polygon per population, the verdict table a
|
|
82
|
+
column each, and volume correlation puts historical first then a heatmap per
|
|
83
|
+
population. `sim_files` groups the same way.
|
|
84
|
+
|
|
85
|
+
A plain list is one unnamed population and keeps the flat shape exactly as
|
|
86
|
+
before. The historical day is measured once however many groups there are.
|
|
87
|
+
|
|
88
|
+
`plot_all=True` draws everything the enabled areas can draw, not just the
|
|
89
|
+
summaries.
|
|
90
|
+
|
|
91
|
+
### Working from a checkout
|
|
92
|
+
|
|
93
|
+
To run the SDK from source — editing it, or using unreleased changes — install
|
|
94
|
+
it editable **into the interpreter you will actually import from**. A notebook
|
|
95
|
+
kernel is frequently not the python on your `$PATH`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# in a notebook, find the right interpreter first
|
|
99
|
+
import sys; print(sys.executable)
|
|
100
|
+
|
|
101
|
+
# then, with that path
|
|
102
|
+
<that python> -m pip install -e /path/to/pulse-sdk
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Check which copy you loaded — this is worth doing whenever an attribute seems
|
|
106
|
+
to be missing:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import simudyne
|
|
110
|
+
from importlib.metadata import version
|
|
111
|
+
|
|
112
|
+
print(simudyne.__file__) # should be .../pulse-sdk/src/simudyne/...
|
|
113
|
+
print(version("simudyne-pulse"))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
#### If an older install shadows it
|
|
117
|
+
|
|
118
|
+
The SDK was once published under the distribution name **`simudyne`**; it is
|
|
119
|
+
now **`simudyne-pulse`**. Both ship a module called `simudyne`, and the older
|
|
120
|
+
one installs a real directory while the editable install only adds a path
|
|
121
|
+
entry — so the old copy wins and you get errors like
|
|
122
|
+
`'PulseABM' object has no attribute 'validation'` from a version that predates
|
|
123
|
+
the feature. `pip list` shows both. Remove the obsolete one:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
<that python> -m pip uninstall simudyne # the old distribution
|
|
127
|
+
<that python> -m pip install -e /path/to/pulse-sdk
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Running the tests
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv run --with pytest --with requests --with pandas --with pyarrow \
|
|
134
|
+
python -m pytest tests/ -q
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
No network: the client's `_request` is replaced with a recorder, so the tests
|
|
138
|
+
assert the exact payload the API would receive.
|
|
139
|
+
|
|
62
140
|
## Quick start
|
|
63
141
|
|
|
64
142
|
```python
|
|
@@ -33,6 +33,84 @@ 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
|
+
### Comparing named populations
|
|
37
|
+
|
|
38
|
+
Simulations are often not one population but several — a foundation model and
|
|
39
|
+
an ABM, or two versions of a model. Name them and the results compare them
|
|
40
|
+
instead of averaging them together:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
job = client.validation.run(
|
|
44
|
+
symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
|
|
45
|
+
sim_ids={"fm": fm_ids, "abm": abm_ids},
|
|
46
|
+
plots=["statistical.radar", "stylised_facts.overall",
|
|
47
|
+
"volume_correlation.overall"],
|
|
48
|
+
)
|
|
49
|
+
result = client.validation.get_job(job["job_id"], plot_dir="figs")
|
|
50
|
+
result["distances"]["fm"]["spread"]["l1"]
|
|
51
|
+
result["stylised_fact_verdicts"]["heavy_tails"]["simulated"]["abm"]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`distances`, `distributions`, the verdicts and the FID/MIND scores come back
|
|
55
|
+
keyed by name; the radar draws a polygon per population, the verdict table a
|
|
56
|
+
column each, and volume correlation puts historical first then a heatmap per
|
|
57
|
+
population. `sim_files` groups the same way.
|
|
58
|
+
|
|
59
|
+
A plain list is one unnamed population and keeps the flat shape exactly as
|
|
60
|
+
before. The historical day is measured once however many groups there are.
|
|
61
|
+
|
|
62
|
+
`plot_all=True` draws everything the enabled areas can draw, not just the
|
|
63
|
+
summaries.
|
|
64
|
+
|
|
65
|
+
### Working from a checkout
|
|
66
|
+
|
|
67
|
+
To run the SDK from source — editing it, or using unreleased changes — install
|
|
68
|
+
it editable **into the interpreter you will actually import from**. A notebook
|
|
69
|
+
kernel is frequently not the python on your `$PATH`:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# in a notebook, find the right interpreter first
|
|
73
|
+
import sys; print(sys.executable)
|
|
74
|
+
|
|
75
|
+
# then, with that path
|
|
76
|
+
<that python> -m pip install -e /path/to/pulse-sdk
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Check which copy you loaded — this is worth doing whenever an attribute seems
|
|
80
|
+
to be missing:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import simudyne
|
|
84
|
+
from importlib.metadata import version
|
|
85
|
+
|
|
86
|
+
print(simudyne.__file__) # should be .../pulse-sdk/src/simudyne/...
|
|
87
|
+
print(version("simudyne-pulse"))
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### If an older install shadows it
|
|
91
|
+
|
|
92
|
+
The SDK was once published under the distribution name **`simudyne`**; it is
|
|
93
|
+
now **`simudyne-pulse`**. Both ship a module called `simudyne`, and the older
|
|
94
|
+
one installs a real directory while the editable install only adds a path
|
|
95
|
+
entry — so the old copy wins and you get errors like
|
|
96
|
+
`'PulseABM' object has no attribute 'validation'` from a version that predates
|
|
97
|
+
the feature. `pip list` shows both. Remove the obsolete one:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
<that python> -m pip uninstall simudyne # the old distribution
|
|
101
|
+
<that python> -m pip install -e /path/to/pulse-sdk
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Running the tests
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv run --with pytest --with requests --with pandas --with pyarrow \
|
|
108
|
+
python -m pytest tests/ -q
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
No network: the client's `_request` is replaced with a recorder, so the tests
|
|
112
|
+
assert the exact payload the API would receive.
|
|
113
|
+
|
|
36
114
|
## Quick start
|
|
37
115
|
|
|
38
116
|
```python
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/validation.py
RENAMED
|
@@ -64,7 +64,8 @@ _AREA_FLAGS = (
|
|
|
64
64
|
#: needing the message stream; ``sample_period`` and ``match_generated_sample``
|
|
65
65
|
#: set the grid the book is resampled onto; ``plots`` is False, True, or a list
|
|
66
66
|
#: of plot ids.
|
|
67
|
-
_EXTRA_FIELDS = ("lob", "sample_period", "match_generated_sample", "plots"
|
|
67
|
+
_EXTRA_FIELDS = ("lob", "sample_period", "match_generated_sample", "plots",
|
|
68
|
+
"plot_all")
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
def _frame_to_parquet(entry, index: int):
|
|
@@ -172,6 +173,7 @@ class ValidationResource:
|
|
|
172
173
|
match_generated_sample=None,
|
|
173
174
|
n_levels: int = 10,
|
|
174
175
|
plots=None,
|
|
176
|
+
plot_all: bool = False,
|
|
175
177
|
) -> dict:
|
|
176
178
|
"""Submit a validation job.
|
|
177
179
|
|
|
@@ -192,13 +194,17 @@ class ValidationResource:
|
|
|
192
194
|
``(symbol, provider, exchange)`` is the identity, not the symbol.
|
|
193
195
|
exchange : str
|
|
194
196
|
Exchange, e.g. ``"lse"`` or ``"hkex_securities"``.
|
|
195
|
-
sim_ids : list of str, optional
|
|
196
|
-
Platform simulation IDs, 1 to 25.
|
|
197
|
-
``
|
|
198
|
-
|
|
197
|
+
sim_ids : list of str or dict, optional
|
|
198
|
+
Platform simulation IDs, 1 to 25. A flat list is one unnamed
|
|
199
|
+
population. A mapping — ``{"fm": [...], "abm": [...]}`` — compares
|
|
200
|
+
the named populations: distances, distributions, verdicts and
|
|
201
|
+
FID/MIND scores come back keyed by name, and the summary figures
|
|
202
|
+
draw one series per name instead of averaging them together.
|
|
203
|
+
Exactly one of ``sim_ids`` or ``sim_files`` is required.
|
|
204
|
+
sim_files : list or dict, optional
|
|
199
205
|
Your own simulated runs, up to 25: paths, ``(filename, bytes)``
|
|
200
|
-
pairs, or polars / pandas DataFrames in pulse format.
|
|
201
|
-
historical side is fetched for you.
|
|
206
|
+
pairs, or polars / pandas DataFrames in pulse format. Grouped the
|
|
207
|
+
same way as ``sim_ids``. The historical side is fetched for you.
|
|
202
208
|
ticksize : float, default 1.0
|
|
203
209
|
Minimum price increment. Should match the instrument.
|
|
204
210
|
statistical, stylised_facts, impact, volume_correlation, fid, mind : bool, optional
|
|
@@ -221,6 +227,10 @@ class ValidationResource:
|
|
|
221
227
|
``None`` or ``False`` for none, ``True`` for every figure, or plot
|
|
222
228
|
ids such as ``["statistical.radar", "stylised_facts.overall"]``.
|
|
223
229
|
Rendered server-side; :meth:`get_job` writes them to disk.
|
|
230
|
+
plot_all : bool, default False
|
|
231
|
+
Draw everything the enabled areas can draw — every distribution and
|
|
232
|
+
every stylised fact, not just the summaries. An area switched off
|
|
233
|
+
still draws nothing, so this means "all of what ran".
|
|
224
234
|
|
|
225
235
|
Returns
|
|
226
236
|
-------
|
|
@@ -239,7 +249,12 @@ class ValidationResource:
|
|
|
239
249
|
raise ValueError("sim_ids must not be empty")
|
|
240
250
|
if sim_files is not None and not sim_files:
|
|
241
251
|
raise ValueError("sim_files must not be empty")
|
|
242
|
-
|
|
252
|
+
given = sim_ids if sim_ids is not None else sim_files
|
|
253
|
+
count = (
|
|
254
|
+
sum(len(v) for v in given.values())
|
|
255
|
+
if isinstance(given, dict)
|
|
256
|
+
else len(given)
|
|
257
|
+
)
|
|
243
258
|
if count > MAX_SIM_FILES:
|
|
244
259
|
raise ValueError(f"Maximum {MAX_SIM_FILES} simulations per validation job")
|
|
245
260
|
if ticksize <= 0:
|
|
@@ -259,6 +274,7 @@ class ValidationResource:
|
|
|
259
274
|
sample_period=sample_period,
|
|
260
275
|
match_generated_sample=match_generated_sample,
|
|
261
276
|
plots=plots,
|
|
277
|
+
plot_all=plot_all or None,
|
|
262
278
|
)
|
|
263
279
|
|
|
264
280
|
if sim_ids is not None:
|
|
@@ -270,16 +286,36 @@ class ValidationResource:
|
|
|
270
286
|
"date": date,
|
|
271
287
|
"provider": provider,
|
|
272
288
|
"exchange": exchange,
|
|
273
|
-
"sim_ids":
|
|
289
|
+
"sim_ids": (
|
|
290
|
+
{k: list(v) for k, v in sim_ids.items()}
|
|
291
|
+
if isinstance(sim_ids, dict)
|
|
292
|
+
else list(sim_ids)
|
|
293
|
+
),
|
|
274
294
|
"ticksize": ticksize,
|
|
275
295
|
"config": config,
|
|
276
296
|
},
|
|
277
297
|
)
|
|
278
298
|
else:
|
|
299
|
+
# Three-tuple parts: the content type matters to the server's
|
|
300
|
+
# multipart parser, so it is sent explicitly.
|
|
301
|
+
# Grouped uploads travel flat with the grouping beside them, the
|
|
302
|
+
# same way sim_ids do — multipart has no nesting.
|
|
303
|
+
if isinstance(sim_files, dict):
|
|
304
|
+
flat, groups, offset = [], {}, 0
|
|
305
|
+
for name, runs in sim_files.items():
|
|
306
|
+
runs = list(runs)
|
|
307
|
+
groups[name] = list(range(offset, offset + len(runs)))
|
|
308
|
+
offset += len(runs)
|
|
309
|
+
flat.extend(runs)
|
|
310
|
+
else:
|
|
311
|
+
flat, groups = list(sim_files), None
|
|
312
|
+
|
|
279
313
|
files = [
|
|
280
|
-
("sim_files", _frame_to_parquet(entry, i))
|
|
281
|
-
for i, entry in enumerate(
|
|
314
|
+
("sim_files", (*_frame_to_parquet(entry, i), "application/octet-stream"))
|
|
315
|
+
for i, entry in enumerate(flat)
|
|
282
316
|
]
|
|
317
|
+
if groups:
|
|
318
|
+
config = {**config, "sim_groups": groups}
|
|
283
319
|
submitted = self._client._request(
|
|
284
320
|
"POST",
|
|
285
321
|
UPLOAD_PATH,
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9/src/simudyne_pulse.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simudyne-pulse
|
|
3
|
-
Version: 0.7.0.
|
|
3
|
+
Version: 0.7.0.dev9
|
|
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,84 @@ 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
|
+
### Comparing named populations
|
|
63
|
+
|
|
64
|
+
Simulations are often not one population but several — a foundation model and
|
|
65
|
+
an ABM, or two versions of a model. Name them and the results compare them
|
|
66
|
+
instead of averaging them together:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
job = client.validation.run(
|
|
70
|
+
symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
|
|
71
|
+
sim_ids={"fm": fm_ids, "abm": abm_ids},
|
|
72
|
+
plots=["statistical.radar", "stylised_facts.overall",
|
|
73
|
+
"volume_correlation.overall"],
|
|
74
|
+
)
|
|
75
|
+
result = client.validation.get_job(job["job_id"], plot_dir="figs")
|
|
76
|
+
result["distances"]["fm"]["spread"]["l1"]
|
|
77
|
+
result["stylised_fact_verdicts"]["heavy_tails"]["simulated"]["abm"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`distances`, `distributions`, the verdicts and the FID/MIND scores come back
|
|
81
|
+
keyed by name; the radar draws a polygon per population, the verdict table a
|
|
82
|
+
column each, and volume correlation puts historical first then a heatmap per
|
|
83
|
+
population. `sim_files` groups the same way.
|
|
84
|
+
|
|
85
|
+
A plain list is one unnamed population and keeps the flat shape exactly as
|
|
86
|
+
before. The historical day is measured once however many groups there are.
|
|
87
|
+
|
|
88
|
+
`plot_all=True` draws everything the enabled areas can draw, not just the
|
|
89
|
+
summaries.
|
|
90
|
+
|
|
91
|
+
### Working from a checkout
|
|
92
|
+
|
|
93
|
+
To run the SDK from source — editing it, or using unreleased changes — install
|
|
94
|
+
it editable **into the interpreter you will actually import from**. A notebook
|
|
95
|
+
kernel is frequently not the python on your `$PATH`:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# in a notebook, find the right interpreter first
|
|
99
|
+
import sys; print(sys.executable)
|
|
100
|
+
|
|
101
|
+
# then, with that path
|
|
102
|
+
<that python> -m pip install -e /path/to/pulse-sdk
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Check which copy you loaded — this is worth doing whenever an attribute seems
|
|
106
|
+
to be missing:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import simudyne
|
|
110
|
+
from importlib.metadata import version
|
|
111
|
+
|
|
112
|
+
print(simudyne.__file__) # should be .../pulse-sdk/src/simudyne/...
|
|
113
|
+
print(version("simudyne-pulse"))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
#### If an older install shadows it
|
|
117
|
+
|
|
118
|
+
The SDK was once published under the distribution name **`simudyne`**; it is
|
|
119
|
+
now **`simudyne-pulse`**. Both ship a module called `simudyne`, and the older
|
|
120
|
+
one installs a real directory while the editable install only adds a path
|
|
121
|
+
entry — so the old copy wins and you get errors like
|
|
122
|
+
`'PulseABM' object has no attribute 'validation'` from a version that predates
|
|
123
|
+
the feature. `pip list` shows both. Remove the obsolete one:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
<that python> -m pip uninstall simudyne # the old distribution
|
|
127
|
+
<that python> -m pip install -e /path/to/pulse-sdk
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Running the tests
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv run --with pytest --with requests --with pandas --with pyarrow \
|
|
134
|
+
python -m pytest tests/ -q
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
No network: the client's `_request` is replaced with a recorder, so the tests
|
|
138
|
+
assert the exact payload the API would receive.
|
|
139
|
+
|
|
62
140
|
## Quick start
|
|
63
141
|
|
|
64
142
|
```python
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/SOURCES.txt
RENAMED
|
@@ -19,6 +19,10 @@ src/simudyne_pulse.egg-info/SOURCES.txt
|
|
|
19
19
|
src/simudyne_pulse.egg-info/dependency_links.txt
|
|
20
20
|
src/simudyne_pulse.egg-info/requires.txt
|
|
21
21
|
src/simudyne_pulse.egg-info/top_level.txt
|
|
22
|
+
tests/test_endpoint_coverage.py
|
|
23
|
+
tests/test_fix.py
|
|
24
|
+
tests/test_fm.py
|
|
22
25
|
tests/test_new_resources.py
|
|
26
|
+
tests/test_run_lrm.py
|
|
23
27
|
tests/test_simulator_gym.py
|
|
24
28
|
tests/test_validation.py
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Tests for the wrappers added to close the gap against pulse-api-pod.
|
|
2
|
+
|
|
3
|
+
Each of these existed as an endpoint the SDK could not reach, so the point of
|
|
4
|
+
the test is that the SDK now targets the right path with the right shape --
|
|
5
|
+
including the two that do not return JSON or do not send JSON.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from simudyne.resources.data import AVAILABLE_SYMBOLS_PATH, DataResource
|
|
13
|
+
from simudyne.resources.simulation import JOBS_PATH, SimulationResource
|
|
14
|
+
from simudyne.resources.validation import UPLOAD_PATH, ValidationResource
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FakeClient:
|
|
18
|
+
base_url = "https://api.example.test"
|
|
19
|
+
|
|
20
|
+
def __init__(self, text="", json_body=None):
|
|
21
|
+
self.calls = []
|
|
22
|
+
self._text = text
|
|
23
|
+
self._json = json_body or {}
|
|
24
|
+
|
|
25
|
+
def _request(self, method, path, **kwargs):
|
|
26
|
+
self.calls.append((method, path, kwargs))
|
|
27
|
+
return self._json
|
|
28
|
+
|
|
29
|
+
def _request_text(self, method, path, **kwargs):
|
|
30
|
+
self.calls.append((method, path, kwargs))
|
|
31
|
+
return self._text
|
|
32
|
+
|
|
33
|
+
def _request_with_retries(self, method, url, **kwargs):
|
|
34
|
+
self.calls.append((method, url, kwargs))
|
|
35
|
+
|
|
36
|
+
class _Resp:
|
|
37
|
+
@staticmethod
|
|
38
|
+
def json():
|
|
39
|
+
return {"job_id": "v1", "status": "pending", "message": "ok"}
|
|
40
|
+
|
|
41
|
+
return _Resp()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def last(self):
|
|
45
|
+
return self.calls[-1]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestJobLogs:
|
|
49
|
+
def test_returns_text_not_json(self):
|
|
50
|
+
client = FakeClient(text="engine boot\nsim 0000 complete\n")
|
|
51
|
+
res = SimulationResource(client)
|
|
52
|
+
out = res.get_job_logs("job-1")
|
|
53
|
+
# text/plain endpoint: _request would raise on .json(), so the resource
|
|
54
|
+
# must route through _request_text
|
|
55
|
+
assert isinstance(out, str)
|
|
56
|
+
assert "sim 0000 complete" in out
|
|
57
|
+
assert client.last[:2] == ("GET", f"{JOBS_PATH}/job-1/logs")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestGetJobsLimit:
|
|
61
|
+
def test_limit_is_omitted_by_default(self):
|
|
62
|
+
client = FakeClient()
|
|
63
|
+
SimulationResource(client).get_jobs()
|
|
64
|
+
assert client.last[2]["params"] is None
|
|
65
|
+
|
|
66
|
+
def test_limit_is_forwarded(self):
|
|
67
|
+
client = FakeClient()
|
|
68
|
+
SimulationResource(client).get_jobs(limit=250)
|
|
69
|
+
assert client.last[2]["params"] == {"limit": 250}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestSymbolSearch:
|
|
73
|
+
def test_q_is_sent_and_is_separate_from_symbol(self):
|
|
74
|
+
client = FakeClient(json_body=[])
|
|
75
|
+
DataResource(client).get_available_symbols(q="tenc")
|
|
76
|
+
method, path, kwargs = client.last
|
|
77
|
+
assert (method, path) == ("GET", AVAILABLE_SYMBOLS_PATH)
|
|
78
|
+
assert kwargs["params"] == {"q": "tenc"}
|
|
79
|
+
|
|
80
|
+
def test_no_filters_sends_no_params(self):
|
|
81
|
+
client = FakeClient(json_body=[])
|
|
82
|
+
DataResource(client).get_available_symbols()
|
|
83
|
+
assert client.last[2]["params"] is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestValidationUpload:
|
|
87
|
+
def _files(self, tmp_path, n):
|
|
88
|
+
paths = []
|
|
89
|
+
for i in range(n):
|
|
90
|
+
p = tmp_path / f"sim_{i:04d}.parquet"
|
|
91
|
+
p.write_bytes(b"PAR1fake")
|
|
92
|
+
paths.append(p)
|
|
93
|
+
return paths
|
|
94
|
+
|
|
95
|
+
def test_posts_multipart_with_config_as_json_string(self, tmp_path):
|
|
96
|
+
client = FakeClient(json_body={"job_id": "v1"})
|
|
97
|
+
res = ValidationResource(client)
|
|
98
|
+
out = res.run(
|
|
99
|
+
symbol="700.HK", date="2025-09-02",
|
|
100
|
+
provider="omd", exchange="hkex_securities",
|
|
101
|
+
sim_files=self._files(tmp_path, 2),
|
|
102
|
+
)
|
|
103
|
+
method, url, kwargs = client.last
|
|
104
|
+
assert method == "POST" and url.endswith(UPLOAD_PATH)
|
|
105
|
+
assert len(kwargs["files"]) == 2
|
|
106
|
+
assert all(f[0] == "sim_files" for f in kwargs["files"])
|
|
107
|
+
# multipart form fields must be strings, and config a JSON string
|
|
108
|
+
data = kwargs["data"]
|
|
109
|
+
assert data["ticksize"] == "1.0"
|
|
110
|
+
assert "run_fid" not in json.loads(data["config"])
|
|
111
|
+
assert out["job_id"] == "v1"
|
|
112
|
+
|
|
113
|
+
def test_tri_state_flags_are_omitted_from_the_config_string(self, tmp_path):
|
|
114
|
+
client = FakeClient(json_body={"job_id": "v1"})
|
|
115
|
+
ValidationResource(client).run(
|
|
116
|
+
symbol="700.HK", date="2025-09-02",
|
|
117
|
+
provider="omd", exchange="hkex_securities",
|
|
118
|
+
sim_files=self._files(tmp_path, 1),
|
|
119
|
+
)
|
|
120
|
+
config = json.loads(client.last[2]["data"]["config"])
|
|
121
|
+
for flag in ("run_metrics", "run_impact", "run_stylised_facts",
|
|
122
|
+
"plot_data", "run_fid", "statistical", "fid", "mind"):
|
|
123
|
+
assert flag not in config
|
|
124
|
+
|
|
125
|
+
def test_accepts_in_memory_pairs(self, tmp_path):
|
|
126
|
+
client = FakeClient(json_body={"job_id": "v1"})
|
|
127
|
+
ValidationResource(client).run(
|
|
128
|
+
symbol="700.HK", date="2025-09-02",
|
|
129
|
+
provider="omd", exchange="hkex_securities",
|
|
130
|
+
sim_files=[("sim_0000.parquet", b"PAR1fake")],
|
|
131
|
+
)
|
|
132
|
+
name, content, mime = client.last[2]["files"][0][1]
|
|
133
|
+
assert name == "sim_0000.parquet" and content == b"PAR1fake"
|
|
134
|
+
|
|
135
|
+
def test_rejects_empty_and_oversized_batches(self, tmp_path):
|
|
136
|
+
res = ValidationResource(FakeClient())
|
|
137
|
+
common = dict(symbol="700.HK", date="2025-09-02",
|
|
138
|
+
provider="omd", exchange="hkex_securities")
|
|
139
|
+
with pytest.raises(ValueError, match="must not be empty"):
|
|
140
|
+
res.run(sim_files=[], **common)
|
|
141
|
+
with pytest.raises(ValueError, match="25"):
|
|
142
|
+
res.run(sim_files=self._files(tmp_path, 26), **common)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Tests for the FIX usage wrapper.
|
|
2
|
+
|
|
3
|
+
The venue is raw TCP and not reachable from this SDK; this is the one HTTP
|
|
4
|
+
endpoint a FIX client needs, so all there is to pin is the path and the
|
|
5
|
+
admin-only user_id passthrough.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from simudyne.resources.fix import USAGE_PATH, FixResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeClient:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.calls = []
|
|
14
|
+
|
|
15
|
+
def _request(self, method, path, **kwargs):
|
|
16
|
+
self.calls.append((method, path, kwargs))
|
|
17
|
+
return {"total_runs": 3, "runs": []}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def last(self):
|
|
21
|
+
return self.calls[-1]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_own_usage_sends_no_params():
|
|
25
|
+
client = FakeClient()
|
|
26
|
+
out = FixResource(client).usage()
|
|
27
|
+
assert client.last[:2] == ("GET", USAGE_PATH)
|
|
28
|
+
assert client.last[2]["params"] is None
|
|
29
|
+
assert out["total_runs"] == 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_user_id_is_forwarded_for_admin_reads():
|
|
33
|
+
client = FakeClient()
|
|
34
|
+
FixResource(client).usage(user_id="user_abc")
|
|
35
|
+
assert client.last[2]["params"] == {"user_id": "user_abc"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tests for the foundation-model resource's requests.
|
|
2
|
+
|
|
3
|
+
No network: the client's _request is a recorder, so these assert the exact
|
|
4
|
+
query and body the API receives. The FM contract is easy to get subtly wrong
|
|
5
|
+
-- duration_minutes supersedes horizon, n_runs is capped server-side, and
|
|
6
|
+
model_args keys are rejected rather than ignored -- so the payload shape is
|
|
7
|
+
what is worth pinning.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from simudyne.resources.fm import (
|
|
13
|
+
AVAILABLE_DATA_PATH,
|
|
14
|
+
JOBS_PATH,
|
|
15
|
+
LIVE_PATH,
|
|
16
|
+
MODELS_PATH,
|
|
17
|
+
RUN_PATH,
|
|
18
|
+
TERMINAL_STATUSES,
|
|
19
|
+
FmResource,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeClient:
|
|
24
|
+
def __init__(self, responses=None):
|
|
25
|
+
self.calls = []
|
|
26
|
+
self._responses = list(responses or [])
|
|
27
|
+
|
|
28
|
+
def _request(self, method, path, **kwargs):
|
|
29
|
+
self.calls.append((method, path, kwargs))
|
|
30
|
+
return self._responses.pop(0) if self._responses else {}
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def last(self):
|
|
34
|
+
return self.calls[-1]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def fm():
|
|
39
|
+
res = FmResource(FakeClient())
|
|
40
|
+
return res
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestDiscovery:
|
|
44
|
+
def test_models_hits_the_models_path(self, fm):
|
|
45
|
+
fm.models()
|
|
46
|
+
method, path, _ = fm._client.last
|
|
47
|
+
assert (method, path) == ("GET", MODELS_PATH)
|
|
48
|
+
|
|
49
|
+
def test_models_returns_the_api_shape_unchanged(self):
|
|
50
|
+
# The endpoint answers {"models": [...]}, not a bare list. Returning it
|
|
51
|
+
# verbatim keeps every resource method consistent -- raw JSON through.
|
|
52
|
+
client = FakeClient(responses=[{"models": [{"model_id": "m1"}]}])
|
|
53
|
+
out = FmResource(client).models()
|
|
54
|
+
assert out["models"][0]["model_id"] == "m1"
|
|
55
|
+
|
|
56
|
+
def test_available_data_omits_unset_filters(self, fm):
|
|
57
|
+
fm.available_data()
|
|
58
|
+
_, path, kwargs = fm._client.last
|
|
59
|
+
assert path == AVAILABLE_DATA_PATH
|
|
60
|
+
# limit/offset have real defaults and are always sent; nothing else is
|
|
61
|
+
assert kwargs["params"] == {"limit": 50, "offset": 0}
|
|
62
|
+
|
|
63
|
+
def test_available_data_passes_model_id_so_markets_are_filtered(self, fm):
|
|
64
|
+
fm.available_data(model_id="tradefm-hkex", q="700", date="2025-09-02")
|
|
65
|
+
params = fm._client.last[2]["params"]
|
|
66
|
+
assert params["model_id"] == "tradefm-hkex"
|
|
67
|
+
assert params["q"] == "700"
|
|
68
|
+
assert params["date"] == "2025-09-02"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestRunPayload:
|
|
72
|
+
def _run(self, fm, **kw):
|
|
73
|
+
base = dict(
|
|
74
|
+
model_id="tradefm-hkex",
|
|
75
|
+
symbol="700",
|
|
76
|
+
cal_date="2025-09-02",
|
|
77
|
+
provider="bmll",
|
|
78
|
+
exchange="hkex_securities",
|
|
79
|
+
)
|
|
80
|
+
base.update(kw)
|
|
81
|
+
fm.run(**base)
|
|
82
|
+
return fm._client.last[2]["json"]
|
|
83
|
+
|
|
84
|
+
def test_targets_run_path_with_required_defaults(self, fm):
|
|
85
|
+
body = self._run(fm)
|
|
86
|
+
assert fm._client.last[:2] == ("POST", RUN_PATH)
|
|
87
|
+
assert body["model_id"] == "tradefm-hkex"
|
|
88
|
+
assert body["n_runs"] == 1
|
|
89
|
+
assert body["seed"] == 42
|
|
90
|
+
|
|
91
|
+
def test_unset_optionals_are_omitted_not_nulled(self, fm):
|
|
92
|
+
body = self._run(fm)
|
|
93
|
+
for field in ("duration_minutes", "horizon", "device", "prompt",
|
|
94
|
+
"model_args", "exec_algos"):
|
|
95
|
+
assert field not in body, f"{field} must be omitted when unset"
|
|
96
|
+
|
|
97
|
+
def test_duration_and_horizon_are_both_forwarded_when_given(self, fm):
|
|
98
|
+
# The API decides precedence (duration_minutes wins); the SDK must not
|
|
99
|
+
# silently drop one, or a caller cannot express the legacy form.
|
|
100
|
+
body = self._run(fm, duration_minutes=30, horizon=500)
|
|
101
|
+
assert body["duration_minutes"] == 30
|
|
102
|
+
assert body["horizon"] == 500
|
|
103
|
+
|
|
104
|
+
def test_model_args_pass_through(self, fm):
|
|
105
|
+
body = self._run(fm, model_args={"temperature": 0.8})
|
|
106
|
+
assert body["model_args"] == {"temperature": 0.8}
|
|
107
|
+
|
|
108
|
+
def test_empty_model_args_is_omitted(self, fm):
|
|
109
|
+
body = self._run(fm, model_args={})
|
|
110
|
+
assert "model_args" not in body
|
|
111
|
+
|
|
112
|
+
def test_css_exec_algo_orders_are_serialized_like_the_abm(self, fm):
|
|
113
|
+
pd = pytest.importorskip("pandas")
|
|
114
|
+
idx = pd.date_range("2025-09-02 09:30", periods=3, freq="1min")
|
|
115
|
+
orders = pd.Series([10, 20, 30], index=idx)
|
|
116
|
+
body = self._run(fm, exec_algos=[{"type": "css", "orders": orders}])
|
|
117
|
+
sent = body["exec_algos"][0]["orders"]
|
|
118
|
+
# A pd.Series is not JSON-serializable; it must arrive as str -> int
|
|
119
|
+
assert isinstance(sent, dict)
|
|
120
|
+
assert all(isinstance(k, str) and isinstance(v, int) for k, v in sent.items())
|
|
121
|
+
assert sum(sent.values()) == 60
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestJobsAndLive:
|
|
125
|
+
def test_job_status_path(self, fm):
|
|
126
|
+
fm.job_status("job-1")
|
|
127
|
+
assert fm._client.last[:2] == ("GET", f"{JOBS_PATH}/job-1/status")
|
|
128
|
+
|
|
129
|
+
def test_job_logs_path(self, fm):
|
|
130
|
+
fm.job_logs("job-1")
|
|
131
|
+
assert fm._client.last[:2] == ("GET", f"{JOBS_PATH}/job-1/logs")
|
|
132
|
+
|
|
133
|
+
def test_live_targets_live_path_and_omits_unset(self, fm):
|
|
134
|
+
fm.live(model_id="tradefm-hkex")
|
|
135
|
+
method, path, kwargs = fm._client.last
|
|
136
|
+
assert (method, path) == ("POST", LIVE_PATH)
|
|
137
|
+
assert kwargs["json"] == {"model_id": "tradefm-hkex", "seed": 42}
|
|
138
|
+
|
|
139
|
+
def test_only_complete_and_failed_are_terminal(self):
|
|
140
|
+
# A poller loops on "not terminal", so a new waiting state must not
|
|
141
|
+
# accidentally read as finished.
|
|
142
|
+
assert set(TERMINAL_STATUSES) == {"complete", "failed"}
|
|
143
|
+
for waiting in ("queued", "provisioning", "starting", "running"):
|
|
144
|
+
assert waiting not in TERMINAL_STATUSES
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Payload tests for the 0.8.0 surface: validation.
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
-
|
|
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
|
|
62
|
-
assert config["
|
|
63
|
-
for flag in ("
|
|
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).
|
|
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"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Tests for SimulationResource.run_lrm.
|
|
2
|
+
|
|
3
|
+
The HTTP layer is stubbed, so these check the payload the SDK builds and the
|
|
4
|
+
endpoint it targets, not a live service.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from simudyne.resources.simulation import LRM_RUN_PATH, SimulationResource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _StubClient:
|
|
13
|
+
"""Captures the request the resource makes."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.calls = []
|
|
17
|
+
|
|
18
|
+
def _request(self, method, endpoint, **kwargs):
|
|
19
|
+
self.calls.append((method, endpoint, kwargs))
|
|
20
|
+
return {"job_id": "job-abc", "total_jobs": 250}
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def last(self):
|
|
24
|
+
return self.calls[-1]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def resource():
|
|
29
|
+
client = _StubClient()
|
|
30
|
+
res = SimulationResource(client)
|
|
31
|
+
res._stub = client
|
|
32
|
+
return res
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(resource, **kw):
|
|
36
|
+
base = dict(
|
|
37
|
+
symbol="700.HK",
|
|
38
|
+
cal_date="2025-09-02",
|
|
39
|
+
provider="omd",
|
|
40
|
+
exchange="hkex_securities",
|
|
41
|
+
order_sizes=[500, 1000, 2000, 4000],
|
|
42
|
+
)
|
|
43
|
+
base.update(kw)
|
|
44
|
+
return resource.run_lrm(**base)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestEndpoint:
|
|
48
|
+
|
|
49
|
+
def test_posts_to_the_lrm_path(self, resource):
|
|
50
|
+
_run(resource)
|
|
51
|
+
method, endpoint, _ = resource._stub.last
|
|
52
|
+
assert method == "POST"
|
|
53
|
+
assert endpoint == LRM_RUN_PATH == "/simulation/lrm/run"
|
|
54
|
+
|
|
55
|
+
def test_returns_the_server_response(self, resource):
|
|
56
|
+
assert _run(resource)["job_id"] == "job-abc"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestPayload:
|
|
60
|
+
|
|
61
|
+
def test_carries_the_symbol_identity_and_ladder(self, resource):
|
|
62
|
+
_run(resource)
|
|
63
|
+
payload = resource._stub.last[2]["json"]
|
|
64
|
+
assert payload["symbol"] == "700.HK"
|
|
65
|
+
assert payload["cal_date"] == "2025-09-02"
|
|
66
|
+
assert payload["provider"] == "omd"
|
|
67
|
+
assert payload["exchange"] == "hkex_securities"
|
|
68
|
+
assert payload["order_sizes"] == [500, 1000, 2000, 4000]
|
|
69
|
+
|
|
70
|
+
def test_defaults(self, resource):
|
|
71
|
+
_run(resource)
|
|
72
|
+
payload = resource._stub.last[2]["json"]
|
|
73
|
+
assert payload["n_runs"] == 50
|
|
74
|
+
assert payload["seed"] == 42
|
|
75
|
+
assert payload["horizon_mins"] == 60
|
|
76
|
+
assert payload["strategy"] == "vwap"
|
|
77
|
+
assert payload["scenario"] == "normal"
|
|
78
|
+
|
|
79
|
+
def test_unset_optionals_are_omitted(self, resource):
|
|
80
|
+
# A null side would override the server default instead of letting the
|
|
81
|
+
# sign of each order size pick the direction.
|
|
82
|
+
_run(resource)
|
|
83
|
+
payload = resource._stub.last[2]["json"]
|
|
84
|
+
assert "side" not in payload
|
|
85
|
+
assert "start_time" not in payload
|
|
86
|
+
assert "scenario_params" not in payload
|
|
87
|
+
|
|
88
|
+
def test_optionals_are_sent_when_given(self, resource):
|
|
89
|
+
_run(resource, side="sell", start_time="09:30",
|
|
90
|
+
scenario="flash_crash", scenario_params={"start_time": "10:00"})
|
|
91
|
+
payload = resource._stub.last[2]["json"]
|
|
92
|
+
assert payload["side"] == "sell"
|
|
93
|
+
assert payload["start_time"] == "09:30"
|
|
94
|
+
assert payload["scenario"] == "flash_crash"
|
|
95
|
+
assert payload["scenario_params"] == {"start_time": "10:00"}
|
|
96
|
+
|
|
97
|
+
def test_order_sizes_are_copied_not_aliased(self, resource):
|
|
98
|
+
sizes = [500, 1000]
|
|
99
|
+
_run(resource, order_sizes=sizes)
|
|
100
|
+
sizes.append(9999)
|
|
101
|
+
assert resource._stub.last[2]["json"]["order_sizes"] == [500, 1000]
|
|
102
|
+
|
|
103
|
+
def test_negative_sizes_pass_through_for_buys(self, resource):
|
|
104
|
+
_run(resource, order_sizes=[-500, -1000])
|
|
105
|
+
assert resource._stub.last[2]["json"]["order_sizes"] == [-500, -1000]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestSharedBaselineCost:
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("n_sizes,n_runs,expected", [(4, 50, 250), (8, 50, 450), (8, 25, 225)])
|
|
111
|
+
def test_documented_job_count_matches_the_shared_baseline_shape(
|
|
112
|
+
self, resource, n_sizes, n_runs, expected
|
|
113
|
+
):
|
|
114
|
+
# n_runs * (1 + n_sizes), the figure the docstring quotes.
|
|
115
|
+
_run(resource, order_sizes=list(range(100, 100 * (n_sizes + 1), 100)), n_runs=n_runs)
|
|
116
|
+
payload = resource._stub.last[2]["json"]
|
|
117
|
+
assert payload["n_runs"] * (1 + len(payload["order_sizes"])) == expected
|
|
@@ -216,15 +216,70 @@ class TestSimulatedFrameShapes:
|
|
|
216
216
|
return client.calls[0][2]["files"]
|
|
217
217
|
|
|
218
218
|
def test_pair(self):
|
|
219
|
-
|
|
219
|
+
part = self._files_sent([("a.parquet", b"x")])[0][1]
|
|
220
|
+
assert part == ("a.parquet", b"x", "application/octet-stream")
|
|
220
221
|
|
|
221
222
|
def test_path(self, tmp_path):
|
|
222
223
|
p = tmp_path / "sim.parquet"
|
|
223
224
|
p.write_bytes(b"data")
|
|
224
|
-
|
|
225
|
+
part = self._files_sent([str(p)])[0][1]
|
|
226
|
+
assert part == ("sim.parquet", b"data", "application/octet-stream")
|
|
225
227
|
|
|
226
228
|
def test_each_frame_gets_its_own_name(self):
|
|
227
229
|
pd = pytest.importorskip("pandas")
|
|
228
230
|
frames = [pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [2]})]
|
|
229
231
|
names = [f[1][0] for f in self._files_sent(frames)]
|
|
230
232
|
assert names == ["sim_0.parquet", "sim_1.parquet"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestNamedPopulations:
|
|
236
|
+
"""Runs can be named so the results compare populations, not an average."""
|
|
237
|
+
|
|
238
|
+
def test_grouped_sim_ids_go_as_a_mapping(self):
|
|
239
|
+
client = FakeClient([{"job_id": "v1"}])
|
|
240
|
+
ValidationResource(client).run(
|
|
241
|
+
sim_ids={"fm": ["a"], "abm": ["b", "c"]}, **IDENTITY
|
|
242
|
+
)
|
|
243
|
+
assert client.calls[0][2]["json"]["sim_ids"] == {
|
|
244
|
+
"fm": ["a"],
|
|
245
|
+
"abm": ["b", "c"],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def test_the_total_is_what_is_capped(self):
|
|
249
|
+
client = FakeClient()
|
|
250
|
+
with pytest.raises(ValueError, match="Maximum 25"):
|
|
251
|
+
ValidationResource(client).run(
|
|
252
|
+
sim_ids={"a": ["x"] * 13, "b": ["y"] * 13}, **IDENTITY
|
|
253
|
+
)
|
|
254
|
+
assert client.calls == []
|
|
255
|
+
|
|
256
|
+
def test_grouped_uploads_travel_flat_with_the_grouping_beside_them(self):
|
|
257
|
+
"""Multipart has no nesting, so the groups go in the config."""
|
|
258
|
+
client = FakeClient([{"job_id": "v1"}])
|
|
259
|
+
ValidationResource(client).run(
|
|
260
|
+
sim_files={"fm": [("a.parquet", b"x")],
|
|
261
|
+
"abm": [("b.parquet", b"y"), ("c.parquet", b"z")]},
|
|
262
|
+
**IDENTITY,
|
|
263
|
+
)
|
|
264
|
+
kwargs = client.calls[0][2]
|
|
265
|
+
assert len(kwargs["files"]) == 3
|
|
266
|
+
assert json.loads(kwargs["data"]["config"])["sim_groups"] == {
|
|
267
|
+
"fm": [0], "abm": [1, 2]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
def test_an_ungrouped_upload_carries_no_grouping(self):
|
|
271
|
+
client = FakeClient([{"job_id": "v1"}])
|
|
272
|
+
ValidationResource(client).run(
|
|
273
|
+
sim_files=[("a.parquet", b"x")], **IDENTITY
|
|
274
|
+
)
|
|
275
|
+
assert "sim_groups" not in json.loads(client.calls[0][2]["data"]["config"])
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TestPlotAll:
|
|
279
|
+
def test_it_is_forwarded(self):
|
|
280
|
+
client, _ = _run([{"job_id": "v1"}], plot_all=True)
|
|
281
|
+
assert client.calls[0][2]["json"]["config"]["plot_all"] is True
|
|
282
|
+
|
|
283
|
+
def test_it_is_omitted_when_not_asked_for(self):
|
|
284
|
+
client, _ = _run([{"job_id": "v1"}])
|
|
285
|
+
assert "plot_all" not in client.calls[0][2]["json"]["config"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/historical.py
RENAMED
|
File without changes
|
|
File without changes
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/simulation.py
RENAMED
|
File without changes
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne/resources/simulator_gym.py
RENAMED
|
File without changes
|
|
File without changes
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/requires.txt
RENAMED
|
File without changes
|
{simudyne_pulse-0.7.0.dev7 → simudyne_pulse-0.7.0.dev9}/src/simudyne_pulse.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|