simudyne-pulse 0.7.0.dev6__tar.gz → 0.7.0.dev7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simudyne_pulse-0.7.0.dev6/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev7}/PKG-INFO +1 -1
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/pyproject.toml +1 -1
- simudyne_pulse-0.7.0.dev7/src/simudyne/resources/validation.py +346 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7/src/simudyne_pulse.egg-info}/PKG-INFO +1 -1
- simudyne_pulse-0.7.0.dev7/tests/test_validation.py +230 -0
- simudyne_pulse-0.7.0.dev6/src/simudyne/resources/validation.py +0 -822
- simudyne_pulse-0.7.0.dev6/tests/test_validation.py +0 -268
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/LICENSE +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/README.md +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/setup.cfg +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/__init__.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/client.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/exceptions.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/__init__.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/api_keys.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/data.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/fix.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/fm.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/historical.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/profile.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/simulation.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne/resources/simulator_gym.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/requires.txt +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/tests/test_new_resources.py +0 -0
- {simudyne_pulse-0.7.0.dev6 → simudyne_pulse-0.7.0.dev7}/tests/test_simulator_gym.py +0 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation Resource for the Pulse SDK.
|
|
3
|
+
|
|
4
|
+
Compares simulated LOB data against a historical day using distributional
|
|
5
|
+
metrics, impact response, Cont's stylised facts, cross-level volume
|
|
6
|
+
correlation, and the MIND/FID inception distances on DeepLOB embeddings.
|
|
7
|
+
|
|
8
|
+
Submit, then poll::
|
|
9
|
+
|
|
10
|
+
job = client.validation.run(
|
|
11
|
+
symbol="BARC", date="2026-06-17", provider="bmll", exchange="lse",
|
|
12
|
+
sim_ids=[...], ticksize=0.05,
|
|
13
|
+
)
|
|
14
|
+
result = client.validation.get_job(job["job_id"])
|
|
15
|
+
result["distances"]["spread"]["l1"] # per simulation
|
|
16
|
+
result["stylised_fact_verdicts"]["heavy_tails"]
|
|
17
|
+
|
|
18
|
+
``run`` submits and returns; a validation of a real day takes minutes, so
|
|
19
|
+
``get_job`` is the status endpoint you call until it is done. ``list_jobs``
|
|
20
|
+
reaches past runs.
|
|
21
|
+
|
|
22
|
+
Selecting metrics
|
|
23
|
+
-----------------
|
|
24
|
+
One flag per metric — ``statistical``, ``stylised_facts``, ``impact``,
|
|
25
|
+
``volume_correlation``, ``fid``, ``mind``. Left as ``None`` each takes the
|
|
26
|
+
default for your tier, resolved server-side, so naming none behaves exactly as
|
|
27
|
+
your account is entitled to. Pass ``False`` to skip an expensive pass or
|
|
28
|
+
``True`` to force one on.
|
|
29
|
+
|
|
30
|
+
The historical gate
|
|
31
|
+
-------------------
|
|
32
|
+
Whether the historical half of the comparison comes back — the series in the
|
|
33
|
+
JSON *and* the historical trace on any rendered plot — is decided by your tier,
|
|
34
|
+
not by a parameter here. What is never gated: the L1 and Wasserstein
|
|
35
|
+
``distances``, the FID and MIND scores, and the true/false
|
|
36
|
+
``stylised_fact_verdicts`` for both the historical day and each simulation.
|
|
37
|
+
Those are derived scalars and booleans, not series, so every tier receives
|
|
38
|
+
them.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
RUN_PATH = "/validation/run"
|
|
46
|
+
UPLOAD_PATH = "/validation/run/upload"
|
|
47
|
+
JOBS_PATH = "/validation/jobs"
|
|
48
|
+
|
|
49
|
+
#: The API rejects more per job; checked client-side so a 26-file submission
|
|
50
|
+
#: fails before any bytes are uploaded.
|
|
51
|
+
MAX_SIM_FILES = 25
|
|
52
|
+
|
|
53
|
+
#: One flag per area of checking. Left unset each takes the server's default.
|
|
54
|
+
_AREA_FLAGS = (
|
|
55
|
+
"statistical",
|
|
56
|
+
"stylised_facts",
|
|
57
|
+
"impact",
|
|
58
|
+
"volume_correlation",
|
|
59
|
+
"fid",
|
|
60
|
+
"mind",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: ``lob`` marks the frames as L2 snapshots, which switches off anything
|
|
64
|
+
#: needing the message stream; ``sample_period`` and ``match_generated_sample``
|
|
65
|
+
#: set the grid the book is resampled onto; ``plots`` is False, True, or a list
|
|
66
|
+
#: of plot ids.
|
|
67
|
+
_EXTRA_FIELDS = ("lob", "sample_period", "match_generated_sample", "plots")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _frame_to_parquet(entry, index: int):
|
|
71
|
+
"""Normalise one simulated run to ``(filename, parquet bytes)``.
|
|
72
|
+
|
|
73
|
+
Accepts a path, a ``(filename, bytes)`` pair, or a polars / pandas
|
|
74
|
+
DataFrame, which is written to parquet in memory. The frame must be pulse
|
|
75
|
+
format -- the same shape the engine writes to ``sim_data.parquet`` -- which
|
|
76
|
+
the server validates; sending something else fails there, not here.
|
|
77
|
+
"""
|
|
78
|
+
import io
|
|
79
|
+
|
|
80
|
+
if isinstance(entry, tuple):
|
|
81
|
+
return entry
|
|
82
|
+
|
|
83
|
+
# Duck-typed rather than imported: neither polars nor pandas is a hard
|
|
84
|
+
# dependency of the SDK, and importing one to test for the other would
|
|
85
|
+
# make it one.
|
|
86
|
+
writer = getattr(entry, "write_parquet", None) # polars
|
|
87
|
+
if writer is not None:
|
|
88
|
+
buf = io.BytesIO()
|
|
89
|
+
writer(buf)
|
|
90
|
+
return f"sim_{index}.parquet", buf.getvalue()
|
|
91
|
+
|
|
92
|
+
writer = getattr(entry, "to_parquet", None) # pandas
|
|
93
|
+
if writer is not None:
|
|
94
|
+
buf = io.BytesIO()
|
|
95
|
+
writer(buf, index=False)
|
|
96
|
+
return f"sim_{index}.parquet", buf.getvalue()
|
|
97
|
+
|
|
98
|
+
path = Path(entry)
|
|
99
|
+
return path.name, path.read_bytes()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_config(n_levels, **flags) -> dict:
|
|
103
|
+
"""The validation config as the API expects it.
|
|
104
|
+
|
|
105
|
+
Flags left as ``None`` are omitted rather than sent as null: the API reads
|
|
106
|
+
absence as "use my tier's default", and an explicit null would not do that.
|
|
107
|
+
"""
|
|
108
|
+
config = {"n_levels": n_levels}
|
|
109
|
+
for name in _AREA_FLAGS + _EXTRA_FIELDS:
|
|
110
|
+
value = flags.get(name)
|
|
111
|
+
if value is not None:
|
|
112
|
+
config[name] = value
|
|
113
|
+
|
|
114
|
+
unknown = set(flags) - set(_AREA_FLAGS) - set(_EXTRA_FIELDS)
|
|
115
|
+
if unknown:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Unknown validation option(s): {sorted(unknown)}. "
|
|
118
|
+
f"Valid: {sorted(_AREA_FLAGS + _EXTRA_FIELDS)}"
|
|
119
|
+
)
|
|
120
|
+
return config
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _save_plots(result: dict, plot_dir=None) -> list:
|
|
124
|
+
"""Write the returned figures to disk and return the paths written.
|
|
125
|
+
|
|
126
|
+
The API sends rendered plots base64-encoded in the response. Left to
|
|
127
|
+
itself that is bytes you cannot look at, so when plots were asked for they
|
|
128
|
+
are written out: to ``plot_dir`` when given, otherwise the current
|
|
129
|
+
directory. The directory is created if it does not exist.
|
|
130
|
+
"""
|
|
131
|
+
import base64
|
|
132
|
+
|
|
133
|
+
target = Path(plot_dir) if plot_dir is not None else Path.cwd()
|
|
134
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
|
|
136
|
+
written = []
|
|
137
|
+
for category, entries in (result.get("plots") or {}).items():
|
|
138
|
+
for entry in entries or []:
|
|
139
|
+
content = entry.get("content_base64")
|
|
140
|
+
if not content:
|
|
141
|
+
continue
|
|
142
|
+
path = target / f"{entry.get('name') or category}.png"
|
|
143
|
+
path.write_bytes(base64.b64decode(content))
|
|
144
|
+
written.append(str(path))
|
|
145
|
+
return written
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ValidationResource:
|
|
149
|
+
"""Validate simulated market data against a historical day."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, client):
|
|
152
|
+
self._client = client
|
|
153
|
+
|
|
154
|
+
def run(
|
|
155
|
+
self,
|
|
156
|
+
symbol: str,
|
|
157
|
+
date: str,
|
|
158
|
+
provider: str,
|
|
159
|
+
exchange: str,
|
|
160
|
+
*,
|
|
161
|
+
sim_ids=None,
|
|
162
|
+
sim_files=None,
|
|
163
|
+
ticksize: float = 1.0,
|
|
164
|
+
statistical=None,
|
|
165
|
+
stylised_facts=None,
|
|
166
|
+
impact=None,
|
|
167
|
+
volume_correlation=None,
|
|
168
|
+
fid=None,
|
|
169
|
+
mind=None,
|
|
170
|
+
lob=None,
|
|
171
|
+
sample_period=None,
|
|
172
|
+
match_generated_sample=None,
|
|
173
|
+
n_levels: int = 10,
|
|
174
|
+
plots=None,
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Submit a validation job.
|
|
177
|
+
|
|
178
|
+
Returns as soon as the job is accepted. Poll :meth:`get_job` for
|
|
179
|
+
status and, once it is complete, the result — a validation of a real
|
|
180
|
+
day takes minutes, which is far too long to hold a request open.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
symbol : str
|
|
185
|
+
Trading symbol, e.g. ``"BARC"`` or ``"700.HK"``.
|
|
186
|
+
date : str
|
|
187
|
+
Calibration date, ``"YYYY-MM-DD"``. The historical day compared
|
|
188
|
+
against.
|
|
189
|
+
provider : str
|
|
190
|
+
Data provider, ``"bmll"`` or ``"omd"``. Required: the same symbol
|
|
191
|
+
exists under both with different dates and tick sizes, so
|
|
192
|
+
``(symbol, provider, exchange)`` is the identity, not the symbol.
|
|
193
|
+
exchange : str
|
|
194
|
+
Exchange, e.g. ``"lse"`` or ``"hkex_securities"``.
|
|
195
|
+
sim_ids : list of str, optional
|
|
196
|
+
Platform simulation IDs, 1 to 25. Exactly one of ``sim_ids`` or
|
|
197
|
+
``sim_files`` is required.
|
|
198
|
+
sim_files : list, optional
|
|
199
|
+
Your own simulated runs, up to 25: paths, ``(filename, bytes)``
|
|
200
|
+
pairs, or polars / pandas DataFrames in pulse format. The
|
|
201
|
+
historical side is fetched for you.
|
|
202
|
+
ticksize : float, default 1.0
|
|
203
|
+
Minimum price increment. Should match the instrument.
|
|
204
|
+
statistical, stylised_facts, impact, volume_correlation, fid, mind : bool, optional
|
|
205
|
+
Whether to run each area. ``None`` uses your tier's default.
|
|
206
|
+
lob : bool, optional
|
|
207
|
+
The frames are L2 snapshots. Anything needing the message stream
|
|
208
|
+
is skipped, with the reason reported in
|
|
209
|
+
``metadata["areas_skipped"]``.
|
|
210
|
+
sample_period : str, optional
|
|
211
|
+
The grid the book is resampled onto in lob mode, e.g. ``"1s"``.
|
|
212
|
+
Omitted, the cadence is read off your generated frames; if those
|
|
213
|
+
are event-level there is no grid to match and nothing is
|
|
214
|
+
resampled.
|
|
215
|
+
match_generated_sample : bool, optional
|
|
216
|
+
Match the generated frames' grid even when ``sample_period`` is
|
|
217
|
+
given.
|
|
218
|
+
n_levels : int, default 10
|
|
219
|
+
Book levels to measure over.
|
|
220
|
+
plots : bool or list of str, optional
|
|
221
|
+
``None`` or ``False`` for none, ``True`` for every figure, or plot
|
|
222
|
+
ids such as ``["statistical.radar", "stylised_facts.overall"]``.
|
|
223
|
+
Rendered server-side; :meth:`get_job` writes them to disk.
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
dict
|
|
228
|
+
``{"job_id": str, "status": str, "message": str}``. Pass the
|
|
229
|
+
job_id to :meth:`get_job`.
|
|
230
|
+
|
|
231
|
+
Raises
|
|
232
|
+
------
|
|
233
|
+
ValueError
|
|
234
|
+
If the parameters are invalid.
|
|
235
|
+
"""
|
|
236
|
+
if (sim_ids is None) == (sim_files is None):
|
|
237
|
+
raise ValueError("Pass exactly one of sim_ids or sim_files")
|
|
238
|
+
if sim_ids is not None and not sim_ids:
|
|
239
|
+
raise ValueError("sim_ids must not be empty")
|
|
240
|
+
if sim_files is not None and not sim_files:
|
|
241
|
+
raise ValueError("sim_files must not be empty")
|
|
242
|
+
count = len(sim_ids if sim_ids is not None else sim_files)
|
|
243
|
+
if count > MAX_SIM_FILES:
|
|
244
|
+
raise ValueError(f"Maximum {MAX_SIM_FILES} simulations per validation job")
|
|
245
|
+
if ticksize <= 0:
|
|
246
|
+
raise ValueError("ticksize must be positive")
|
|
247
|
+
if n_levels < 1:
|
|
248
|
+
raise ValueError("n_levels must be at least 1")
|
|
249
|
+
|
|
250
|
+
config = _build_config(
|
|
251
|
+
n_levels,
|
|
252
|
+
statistical=statistical,
|
|
253
|
+
stylised_facts=stylised_facts,
|
|
254
|
+
impact=impact,
|
|
255
|
+
volume_correlation=volume_correlation,
|
|
256
|
+
fid=fid,
|
|
257
|
+
mind=mind,
|
|
258
|
+
lob=lob,
|
|
259
|
+
sample_period=sample_period,
|
|
260
|
+
match_generated_sample=match_generated_sample,
|
|
261
|
+
plots=plots,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if sim_ids is not None:
|
|
265
|
+
submitted = self._client._request(
|
|
266
|
+
"POST",
|
|
267
|
+
RUN_PATH,
|
|
268
|
+
json={
|
|
269
|
+
"symbol": symbol,
|
|
270
|
+
"date": date,
|
|
271
|
+
"provider": provider,
|
|
272
|
+
"exchange": exchange,
|
|
273
|
+
"sim_ids": list(sim_ids),
|
|
274
|
+
"ticksize": ticksize,
|
|
275
|
+
"config": config,
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
files = [
|
|
280
|
+
("sim_files", _frame_to_parquet(entry, i))
|
|
281
|
+
for i, entry in enumerate(sim_files)
|
|
282
|
+
]
|
|
283
|
+
submitted = self._client._request(
|
|
284
|
+
"POST",
|
|
285
|
+
UPLOAD_PATH,
|
|
286
|
+
files=files,
|
|
287
|
+
data={
|
|
288
|
+
"symbol": symbol,
|
|
289
|
+
"date": date,
|
|
290
|
+
"provider": provider,
|
|
291
|
+
"exchange": exchange,
|
|
292
|
+
"ticksize": str(ticksize),
|
|
293
|
+
"config": json.dumps(config),
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not (submitted or {}).get("job_id"):
|
|
298
|
+
raise ValueError(f"Validation API returned no job_id: {submitted}")
|
|
299
|
+
return submitted
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_job(self, job_id: str, plot_dir=None) -> dict:
|
|
303
|
+
"""Fetch a validation job's status, and its result once it is done.
|
|
304
|
+
|
|
305
|
+
This is the status endpoint: call it until ``status`` is
|
|
306
|
+
``"completed"`` or ``"failed"``.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
job_id : str
|
|
311
|
+
From :meth:`run`.
|
|
312
|
+
plot_dir : str or Path, optional
|
|
313
|
+
Where to write any figures the job rendered. Defaults to the
|
|
314
|
+
current directory; created if it does not exist. Only has an
|
|
315
|
+
effect once the job is complete and only if ``plots`` was set on
|
|
316
|
+
the run. The paths written come back in ``plot_paths``.
|
|
317
|
+
|
|
318
|
+
Returns
|
|
319
|
+
-------
|
|
320
|
+
dict
|
|
321
|
+
``job_id``, ``status``, ``symbol``, ``date`` and, once complete:
|
|
322
|
+
``metadata``, ``distances``, ``distributions``,
|
|
323
|
+
``impact_response``, ``stylised_facts``,
|
|
324
|
+
``stylised_fact_verdicts``, ``volume_correlation``,
|
|
325
|
+
``fid_scores``, ``mind_scores`` — enough to rebuild every figure —
|
|
326
|
+
plus ``plot_paths`` when figures were written.
|
|
327
|
+
"""
|
|
328
|
+
job = self._client._request("GET", f"{JOBS_PATH}/{job_id}")
|
|
329
|
+
if (job or {}).get("plots"):
|
|
330
|
+
job["plot_paths"] = _save_plots(job, plot_dir)
|
|
331
|
+
return job
|
|
332
|
+
|
|
333
|
+
def list_jobs(self, limit: int = 50) -> dict:
|
|
334
|
+
"""List your validation jobs, newest first.
|
|
335
|
+
|
|
336
|
+
Parameters
|
|
337
|
+
----------
|
|
338
|
+
limit : int, default 50
|
|
339
|
+
Maximum number of jobs to return.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
dict
|
|
344
|
+
``{"jobs": list, "total": int}``.
|
|
345
|
+
"""
|
|
346
|
+
return self._client._request("GET", JOBS_PATH, params={"limit": limit})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Tests for the validation resource.
|
|
2
|
+
|
|
3
|
+
No network: the client's ``_request`` is replaced with a recorder, so these
|
|
4
|
+
assert the exact payload the API receives and the exact result handed back.
|
|
5
|
+
|
|
6
|
+
The surface is one call — ``run`` submits, waits and returns the finished
|
|
7
|
+
result — so most of what is worth pinning is what it sends, what it refuses to
|
|
8
|
+
send, and what it does with the response.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from simudyne.resources.validation import MAX_SIM_FILES, ValidationResource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeClient:
|
|
20
|
+
"""Records calls and replays queued responses."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, responses=None):
|
|
23
|
+
self.calls = []
|
|
24
|
+
self._responses = list(responses or [])
|
|
25
|
+
|
|
26
|
+
def _request(self, method, path, **kwargs):
|
|
27
|
+
self.calls.append((method, path, kwargs))
|
|
28
|
+
return self._responses.pop(0) if self._responses else {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SIM_IDS = ["omd:hkex_securities:700.HK:2025-12-22:abc:normal:baseline:0000"]
|
|
32
|
+
IDENTITY = dict(symbol="700.HK", date="2025-12-22", provider="omd",
|
|
33
|
+
exchange="hkex_securities")
|
|
34
|
+
|
|
35
|
+
PNG = base64.b64encode(b"\x89PNG\r\n\x1a\nfake").decode()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run(responses, **kwargs):
|
|
39
|
+
client = FakeClient(responses)
|
|
40
|
+
result = ValidationResource(client).run(
|
|
41
|
+
sim_ids=SIM_IDS, **{**IDENTITY, **kwargs}
|
|
42
|
+
)
|
|
43
|
+
return client, result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _done(**extra):
|
|
47
|
+
return {"job_id": "v1", "status": "completed", **extra}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestIdentityIsRequired:
|
|
51
|
+
"""The same symbol exists under two providers with different tick sizes,
|
|
52
|
+
so the symbol alone never identified an instrument."""
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("missing", ["symbol", "date", "provider", "exchange"])
|
|
55
|
+
def test_every_identity_field_is_required(self, missing):
|
|
56
|
+
kwargs = {k: v for k, v in IDENTITY.items() if k != missing}
|
|
57
|
+
with pytest.raises(TypeError):
|
|
58
|
+
ValidationResource(FakeClient()).run(sim_ids=SIM_IDS, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestWhatToValidate:
|
|
62
|
+
def test_sim_ids_go_as_json(self):
|
|
63
|
+
client, _ = _run([{"job_id": "v1"}])
|
|
64
|
+
method, path, kwargs = client.calls[0]
|
|
65
|
+
assert (method, path) == ("POST", "/validation/run")
|
|
66
|
+
assert kwargs["json"]["sim_ids"] == SIM_IDS
|
|
67
|
+
assert kwargs["json"]["provider"] == "omd"
|
|
68
|
+
|
|
69
|
+
def test_sim_files_go_as_multipart_to_the_upload_route(self):
|
|
70
|
+
client = FakeClient([{"job_id": "v1"}])
|
|
71
|
+
ValidationResource(client).run(
|
|
72
|
+
sim_files=[("a.parquet", b"x"), ("b.parquet", b"y")], **IDENTITY
|
|
73
|
+
)
|
|
74
|
+
method, path, kwargs = client.calls[0]
|
|
75
|
+
assert (method, path) == ("POST", "/validation/run/upload")
|
|
76
|
+
assert len(kwargs["files"]) == 2
|
|
77
|
+
assert json.loads(kwargs["data"]["config"])["n_levels"] == 10
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize(
|
|
80
|
+
"kwargs,match",
|
|
81
|
+
[
|
|
82
|
+
({}, "exactly one"),
|
|
83
|
+
({"sim_ids": SIM_IDS, "sim_files": [("a", b"x")]}, "exactly one"),
|
|
84
|
+
({"sim_ids": []}, "must not be empty"),
|
|
85
|
+
({"sim_ids": SIM_IDS * (MAX_SIM_FILES + 1)}, "Maximum 25"),
|
|
86
|
+
({"sim_ids": SIM_IDS, "ticksize": 0}, "ticksize must be positive"),
|
|
87
|
+
({"sim_ids": SIM_IDS, "n_levels": 0}, "at least 1"),
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
def test_bad_input_is_refused_before_any_request(self, kwargs, match):
|
|
91
|
+
client = FakeClient()
|
|
92
|
+
with pytest.raises(ValueError, match=match):
|
|
93
|
+
ValidationResource(client).run(**{**IDENTITY, **kwargs})
|
|
94
|
+
assert client.calls == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TestMetricFlags:
|
|
98
|
+
def test_unset_flags_are_omitted_so_the_tier_default_applies(self):
|
|
99
|
+
client, _ = _run([{"job_id": "v1"}])
|
|
100
|
+
config = client.calls[0][2]["json"]["config"]
|
|
101
|
+
# Sending these as null would override the server-side tier default.
|
|
102
|
+
for flag in ("statistical", "stylised_facts", "impact",
|
|
103
|
+
"volume_correlation", "fid", "mind"):
|
|
104
|
+
assert flag not in config, f"{flag} must be omitted when unset"
|
|
105
|
+
assert config == {"n_levels": 10}
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize(
|
|
108
|
+
"flag,value",
|
|
109
|
+
[("statistical", False), ("stylised_facts", True), ("impact", False),
|
|
110
|
+
("volume_correlation", True), ("fid", False), ("mind", True),
|
|
111
|
+
("lob", True), ("sample_period", "100ms"),
|
|
112
|
+
("match_generated_sample", True),
|
|
113
|
+
("plots", ["statistical.radar"])],
|
|
114
|
+
)
|
|
115
|
+
def test_explicit_options_are_forwarded(self, flag, value):
|
|
116
|
+
client, _ = _run([{"job_id": "v1"}], **{flag: value})
|
|
117
|
+
assert client.calls[0][2]["json"]["config"][flag] == value
|
|
118
|
+
|
|
119
|
+
def test_the_legacy_aliases_are_gone(self):
|
|
120
|
+
"""run_* and l2_only/plot_data were two names for one thing."""
|
|
121
|
+
for dead in ("run_metrics", "run_impact", "run_stylised_facts",
|
|
122
|
+
"run_inception_distances", "l2_only", "plot_data",
|
|
123
|
+
"historical_output"):
|
|
124
|
+
with pytest.raises((TypeError, ValueError)):
|
|
125
|
+
ValidationResource(FakeClient()).run(
|
|
126
|
+
sim_ids=SIM_IDS, **IDENTITY, **{dead: True}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestSubmitOnly:
|
|
131
|
+
"""run submits and returns. A real day takes minutes, which is far too
|
|
132
|
+
long to hold a request open, so status is get_job's job."""
|
|
133
|
+
|
|
134
|
+
def test_it_returns_the_submission_without_waiting(self):
|
|
135
|
+
client, result = _run([{"job_id": "v1", "status": "pending"}])
|
|
136
|
+
assert result == {"job_id": "v1", "status": "pending"}
|
|
137
|
+
assert [c[0] for c in client.calls] == ["POST"]
|
|
138
|
+
|
|
139
|
+
def test_a_missing_job_id_is_an_error(self):
|
|
140
|
+
with pytest.raises(ValueError, match="no job_id"):
|
|
141
|
+
_run([{"detail": "nope"}])
|
|
142
|
+
|
|
143
|
+
def test_it_does_not_poll(self):
|
|
144
|
+
assert not hasattr(ValidationResource, "_await")
|
|
145
|
+
import inspect
|
|
146
|
+
|
|
147
|
+
params = inspect.signature(ValidationResource.run).parameters
|
|
148
|
+
assert "poll_interval" not in params
|
|
149
|
+
assert "timeout" not in params
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TestPlots:
|
|
153
|
+
"""Figures arrive with the result, so get_job is what writes them."""
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _fetch(responses, **kwargs):
|
|
157
|
+
client = FakeClient(responses)
|
|
158
|
+
return ValidationResource(client).get_job("v1", **kwargs)
|
|
159
|
+
|
|
160
|
+
def test_plots_requested_on_the_run_go_in_the_config(self):
|
|
161
|
+
client, _ = _run([{"job_id": "v1"}], plots=["statistical.radar"])
|
|
162
|
+
assert client.calls[0][2]["json"]["config"]["plots"] == ["statistical.radar"]
|
|
163
|
+
|
|
164
|
+
def test_nothing_is_written_when_the_job_has_no_plots(self, tmp_path):
|
|
165
|
+
job = self._fetch([_done()], plot_dir=str(tmp_path))
|
|
166
|
+
assert "plot_paths" not in job
|
|
167
|
+
assert list(tmp_path.iterdir()) == []
|
|
168
|
+
|
|
169
|
+
def test_plots_are_written_to_plot_dir(self, tmp_path):
|
|
170
|
+
target = tmp_path / "figs"
|
|
171
|
+
job = self._fetch(
|
|
172
|
+
[_done(plots={"distances": [{"name": "l1", "content_base64": PNG}]})],
|
|
173
|
+
plot_dir=str(target),
|
|
174
|
+
)
|
|
175
|
+
assert job["plot_paths"] == [str(target / "l1.png")]
|
|
176
|
+
assert (target / "l1.png").read_bytes().startswith(b"\x89PNG")
|
|
177
|
+
|
|
178
|
+
def test_plot_dir_is_created_if_absent(self, tmp_path):
|
|
179
|
+
target = tmp_path / "a" / "b"
|
|
180
|
+
self._fetch(
|
|
181
|
+
[_done(plots={"distances": [{"name": "l1", "content_base64": PNG}]})],
|
|
182
|
+
plot_dir=str(target),
|
|
183
|
+
)
|
|
184
|
+
assert (target / "l1.png").is_file()
|
|
185
|
+
|
|
186
|
+
def test_without_plot_dir_they_land_in_the_current_directory(self, tmp_path, monkeypatch):
|
|
187
|
+
monkeypatch.chdir(tmp_path)
|
|
188
|
+
job = self._fetch(
|
|
189
|
+
[_done(plots={"distances": [{"name": "radar", "content_base64": PNG}]})]
|
|
190
|
+
)
|
|
191
|
+
assert job["plot_paths"] == [str(tmp_path / "radar.png")]
|
|
192
|
+
assert (tmp_path / "radar.png").is_file()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TestOneEntryPoint:
|
|
196
|
+
def test_the_extra_run_methods_are_gone(self):
|
|
197
|
+
for dead in ("run_pipeline", "run_upload", "display_plots",
|
|
198
|
+
"inception_distances"):
|
|
199
|
+
assert not hasattr(ValidationResource, dead)
|
|
200
|
+
|
|
201
|
+
def test_retrieval_still_works(self):
|
|
202
|
+
client = FakeClient([{"job_id": "v1"}, {"jobs": [], "total": 0}])
|
|
203
|
+
res = ValidationResource(client)
|
|
204
|
+
res.get_job("v1")
|
|
205
|
+
res.list_jobs(limit=5)
|
|
206
|
+
assert client.calls[0][:2] == ("GET", "/validation/jobs/v1")
|
|
207
|
+
assert client.calls[1][2]["params"] == {"limit": 5}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class TestSimulatedFrameShapes:
|
|
211
|
+
"""sim_files takes a path, a (name, bytes) pair, or a DataFrame."""
|
|
212
|
+
|
|
213
|
+
def _files_sent(self, entries):
|
|
214
|
+
client = FakeClient([{"job_id": "v1"}])
|
|
215
|
+
ValidationResource(client).run(sim_files=entries, **IDENTITY)
|
|
216
|
+
return client.calls[0][2]["files"]
|
|
217
|
+
|
|
218
|
+
def test_pair(self):
|
|
219
|
+
assert self._files_sent([("a.parquet", b"x")])[0][1] == ("a.parquet", b"x")
|
|
220
|
+
|
|
221
|
+
def test_path(self, tmp_path):
|
|
222
|
+
p = tmp_path / "sim.parquet"
|
|
223
|
+
p.write_bytes(b"data")
|
|
224
|
+
assert self._files_sent([str(p)])[0][1] == ("sim.parquet", b"data")
|
|
225
|
+
|
|
226
|
+
def test_each_frame_gets_its_own_name(self):
|
|
227
|
+
pd = pytest.importorskip("pandas")
|
|
228
|
+
frames = [pd.DataFrame({"a": [1]}), pd.DataFrame({"a": [2]})]
|
|
229
|
+
names = [f[1][0] for f in self._files_sent(frames)]
|
|
230
|
+
assert names == ["sim_0.parquet", "sim_1.parquet"]
|