simudyne-pulse 0.7.0.dev1__tar.gz → 0.7.0.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {simudyne_pulse-0.7.0.dev1/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev2}/PKG-INFO +1 -1
  2. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/pyproject.toml +1 -1
  3. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/client.py +4 -0
  4. simudyne_pulse-0.7.0.dev2/src/simudyne/resources/fix.py +30 -0
  5. simudyne_pulse-0.7.0.dev2/src/simudyne/resources/fm.py +199 -0
  6. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/simulation.py +78 -0
  7. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/validation.py +144 -27
  8. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2/src/simudyne_pulse.egg-info}/PKG-INFO +1 -1
  9. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/SOURCES.txt +3 -0
  10. simudyne_pulse-0.7.0.dev2/tests/test_new_resources.py +150 -0
  11. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/LICENSE +0 -0
  12. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/README.md +0 -0
  13. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/setup.cfg +0 -0
  14. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/__init__.py +0 -0
  15. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/exceptions.py +0 -0
  16. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/__init__.py +0 -0
  17. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/api_keys.py +0 -0
  18. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/data.py +0 -0
  19. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/historical.py +0 -0
  20. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/profile.py +0 -0
  21. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/simulator_gym.py +0 -0
  22. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  23. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  24. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  25. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/tests/test_simulator_gym.py +0 -0
  26. {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev1
3
+ Version: 0.7.0.dev2
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simudyne-pulse"
7
- version = "0.7.0-dev.1"
7
+ version = "0.7.0-dev.2"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -36,6 +36,8 @@ class PulseABM:
36
36
  from simudyne.resources.profile import ProfileResource
37
37
  from simudyne.resources.api_keys import ApiKeysResource
38
38
  from simudyne.resources.data import DataResource
39
+ from simudyne.resources.fix import FixResource
40
+ from simudyne.resources.fm import FmResource
39
41
  from simudyne.resources.simulation import SimulationResource
40
42
  from simudyne.resources.simulator_gym import SimulatorGymResource
41
43
  from simudyne.resources.validation import ValidationResource
@@ -43,6 +45,8 @@ class PulseABM:
43
45
  self.profile = ProfileResource(self)
44
46
  self.api_keys = ApiKeysResource(self)
45
47
  self.data = DataResource(self)
48
+ self.fix = FixResource(self)
49
+ self.fm = FmResource(self)
46
50
  self.simulation = SimulationResource(self)
47
51
  self.simulator_gym = SimulatorGymResource(self)
48
52
  self.validation = ValidationResource(self)
@@ -0,0 +1,30 @@
1
+ """
2
+ FIX Resource for the Pulse SDK.
3
+
4
+ Pulse simulations can be consumed over the FIX protocol by a market-data
5
+ session instead of the REST download endpoints. The FIX connection itself is
6
+ provisioned per organisation (see the FIX docs page); this resource covers
7
+ what the SDK can usefully do about it — reporting what your account has run
8
+ over FIX.
9
+ """
10
+
11
+ USAGE_PATH = "/fix/usage"
12
+
13
+
14
+ class FixResource:
15
+ def __init__(self, client):
16
+ self._client = client
17
+
18
+ def usage(self) -> dict:
19
+ """FIX simulation statistics: what was run over FIX, and how often.
20
+
21
+ Returns:
22
+ dict with ``total_runs``, ``simulated_seconds``, and ``runs`` — one
23
+ entry per distinct configuration, each with its ``symbol``,
24
+ ``cal_date``, ``scenario`` and ``runs`` count.
25
+
26
+ Example:
27
+ >>> usage = client.fix.usage()
28
+ >>> print(f"{usage['total_runs']} FIX runs")
29
+ """
30
+ return self._client._request("GET", USAGE_PATH)
@@ -0,0 +1,199 @@
1
+ """
2
+ Foundation Models Resource for the Pulse SDK.
3
+
4
+ A foundation-model (FM) run generates limit order book activity by continuing
5
+ from real market data rather than simulating agents. The platform slices the
6
+ model's required context out of the chosen market's data, runs inference, and
7
+ writes the output using the same conventions as an agent-based run — so the
8
+ ordinary simulation results and download endpoints work unchanged.
9
+
10
+ Workflow:
11
+ 1. Discover models with models() — each entry lists the markets it
12
+ supports and the model_args it accepts
13
+ 2. Find a promptable symbol/date with available_data(model_id=...)
14
+ 3. Submit inference with run() -> returns job_id and sim_ids
15
+ 4. Poll job_status(job_id) until it reaches a TERMINAL_STATUSES value;
16
+ read job_logs(job_id) when it failed
17
+ 5. Download with client.simulation.get_sim_data(sim_id) — an FM sim_id is
18
+ an ordinary sim_id
19
+
20
+ The output file contains the model's prompt context as well as its generated
21
+ frames: the leading rows of sim_data.parquet are real market data. The split
22
+ is recorded in the parquet file-level metadata (``pulse_fm.n_historical``,
23
+ ``pulse_fm.n_generated``, ``pulse_fm.segments``) — drop the context rows
24
+ before computing statistics on the generated activity.
25
+ """
26
+
27
+ MODELS_PATH = "/fm/models"
28
+ AVAILABLE_DATA_PATH = "/fm/available-data"
29
+ RUN_PATH = "/fm/run"
30
+ LIVE_PATH = "/fm/live"
31
+ JOBS_PATH = "/fm/jobs"
32
+
33
+ #: The only statuses a job never leaves. Everything else (queued,
34
+ #: provisioning, starting, running) means "keep polling".
35
+ TERMINAL_STATUSES = {"complete", "failed"}
36
+
37
+
38
+ class FmResource:
39
+ def __init__(self, client):
40
+ self._client = client
41
+
42
+ def models(self) -> dict:
43
+ """Active foundation models in this environment.
44
+
45
+ The pre-run discovery call: each entry carries the model's id and
46
+ version, the markets its ``supported_data`` declares (empty = any),
47
+ ``min_prompt_rows``, and the ``model_args`` descriptors for the knobs
48
+ run() will accept. Pro tier.
49
+
50
+ Returns:
51
+ dict with a ``models`` list.
52
+ """
53
+ return self._client._request("GET", MODELS_PATH)
54
+
55
+ def available_data(
56
+ self,
57
+ model_id: str = None,
58
+ symbol: str = None,
59
+ q: str = None,
60
+ provider: str = None,
61
+ exchange: str = None,
62
+ date: str = None,
63
+ limit: int = 50,
64
+ offset: int = 0,
65
+ ) -> dict:
66
+ """What a foundation model can be prompted with: the raw-data registry.
67
+
68
+ An FM prompt is the first rows of a REAL trading day, not a
69
+ calibration, so this searches the raw-data catalog rather than the
70
+ calibrated list at data.get_available_symbols(). Passing ``model_id``
71
+ restricts the result to the (provider, exchange) markets that model's
72
+ ``supported_data`` declares — exactly what run() will accept.
73
+
74
+ Args:
75
+ model_id: Registered model (see models()). Unknown id -> 404.
76
+ symbol: Exact symbol, e.g. "TSCO" or "700".
77
+ q: Case-insensitive substring match over the symbol.
78
+ provider: Data provider, e.g. "bmll".
79
+ exchange: Protocol, e.g. "lse" or "hkex_securities".
80
+ date: Trading date YYYY-MM-DD; keeps only symbols with data on it,
81
+ and only that date.
82
+ limit: Max symbols to return (default 50, max 200).
83
+ offset: Symbols to skip, for paging.
84
+
85
+ Returns:
86
+ dict with ``symbols`` (each with its provider, exchange and dates)
87
+ and ``total``, paged by symbol identity.
88
+ """
89
+ params = {"limit": limit, "offset": offset}
90
+ for key, value in (
91
+ ("model_id", model_id), ("symbol", symbol), ("q", q),
92
+ ("provider", provider), ("exchange", exchange), ("date", date),
93
+ ):
94
+ if value is not None:
95
+ params[key] = value
96
+ return self._client._request("GET", AVAILABLE_DATA_PATH, params=params)
97
+
98
+ def run(
99
+ self,
100
+ model_id: str,
101
+ symbol: str,
102
+ cal_date: str,
103
+ provider: str,
104
+ exchange: str,
105
+ duration_minutes: float = None,
106
+ horizon: int = None,
107
+ n_runs: int = 1,
108
+ seed: int = 42,
109
+ model_args: dict = None,
110
+ device: str = None,
111
+ exec_algos: list = None,
112
+ ) -> dict:
113
+ """Submit a foundation-model inference job. Pro tier.
114
+
115
+ Args:
116
+ model_id: A model from models().
117
+ symbol: Symbol whose real data becomes the prompt context.
118
+ cal_date: Trading date of the prompt, YYYY-MM-DD.
119
+ provider: Data provider, e.g. "bmll".
120
+ exchange: Exchange protocol, e.g. "hkex_securities".
121
+ duration_minutes: Sim horizon as wall-clock minutes measured from
122
+ the first row of the context. The resulting frame count is
123
+ reported in the run's output rather than requested up front.
124
+ horizon: Raw frame count — the older form; applies only when
125
+ duration_minutes is unset.
126
+ n_runs: Monte Carlo runs, 1-8 (per-run seeds derived from ``seed``
127
+ exactly as the ABM derives them).
128
+ seed: Random seed (default 42).
129
+ model_args: Overrides for the model's declared knobs (see
130
+ models()); unknown or out-of-range keys are rejected with 400.
131
+ device: "cpu" or "gpu"; None uses the model's default.
132
+ exec_algos: Execution algorithms, the ABM's config shape. At most
133
+ one per job; market runs only.
134
+
135
+ Returns:
136
+ dict with job_id, the model version, and the queued sim_ids.
137
+ """
138
+ payload = {
139
+ "model_id": model_id,
140
+ "symbol": symbol,
141
+ "cal_date": cal_date,
142
+ "provider": provider,
143
+ "exchange": exchange,
144
+ "n_runs": n_runs,
145
+ "seed": seed,
146
+ }
147
+ if duration_minutes is not None:
148
+ payload["duration_minutes"] = duration_minutes
149
+ if horizon is not None:
150
+ payload["horizon"] = horizon
151
+ if model_args is not None:
152
+ payload["model_args"] = model_args
153
+ if device is not None:
154
+ payload["device"] = device
155
+ if exec_algos is not None:
156
+ payload["exec_algos"] = exec_algos
157
+ return self._client._request("POST", RUN_PATH, json=payload)
158
+
159
+ def live(
160
+ self,
161
+ model_id: str,
162
+ horizon: int = None,
163
+ seed: int = 42,
164
+ model_args: dict = None,
165
+ ) -> dict:
166
+ """Start a live streaming session instead of a batch job. Pro tier.
167
+
168
+ Returns:
169
+ dict with ``token`` (used to connect to the live chart),
170
+ ``job_id`` and ``model_id``.
171
+ """
172
+ payload = {"model_id": model_id, "seed": seed}
173
+ if horizon is not None:
174
+ payload["horizon"] = horizon
175
+ if model_args is not None:
176
+ payload["model_args"] = model_args
177
+ return self._client._request("POST", LIVE_PATH, json=payload)
178
+
179
+ def job_status(self, job_id: str) -> dict:
180
+ """Foundation-model job status.
181
+
182
+ Returns:
183
+ dict with job_id, ``status``, ``message``, ``detail``,
184
+ ``started_at`` and ``completed_at``. ``status`` is one of queued,
185
+ provisioning, starting, running, complete or failed — only the
186
+ last two are terminal (see TERMINAL_STATUSES), so poll until one
187
+ of those appears. ``message`` is the human label for the state and
188
+ ``detail`` says what the run is waiting on (a GPU stockout reads
189
+ as the scheduler's own reason).
190
+ """
191
+ return self._client._request("GET", f"{JOBS_PATH}/{job_id}/status")
192
+
193
+ def job_logs(self, job_id: str) -> dict:
194
+ """Foundation-model job pod logs.
195
+
196
+ Returns:
197
+ dict with ``logs`` — the thing to read when a job failed.
198
+ """
199
+ return self._client._request("GET", f"{JOBS_PATH}/{job_id}/logs")
@@ -21,6 +21,7 @@ RESULTS_PATH = "/simulation/results"
21
21
  CACHED_PATH = "/simulation/cached"
22
22
  SAMPLE_PATH = "/simulation/sample"
23
23
  CALIBRATE_PATH = "/calibrate"
24
+ LRM_PATH = "/simulation/lrm/run"
24
25
 
25
26
  # Available market scenarios
26
27
  SCENARIOS = {
@@ -437,6 +438,83 @@ class SimulationResource:
437
438
  """
438
439
  return self._pro_request("GET", f"{JOBS_PATH}/{job_id}/results")
439
440
 
441
+ def get_job_logs(self, job_id: str) -> str:
442
+ """Fetch the engine run log for one of your jobs, as plain text.
443
+
444
+ The worker writes a diagnostic log per job — this is the thing to read
445
+ when a run fails or finishes with nothing plottable, and the thing to
446
+ attach when sending a problem to support@simudyne.com.
447
+
448
+ Args:
449
+ job_id: The job ID from run() or get_jobs()
450
+
451
+ Returns:
452
+ str: The log text.
453
+
454
+ Raises:
455
+ PulseAPIError: 404 when the job does not exist, is not yours, or
456
+ wrote no log.
457
+
458
+ Example:
459
+ >>> status = client.simulation.get_job_status(job_id)
460
+ >>> if status["has_errors"]:
461
+ ... print(client.simulation.get_job_logs(job_id)[:2000])
462
+ """
463
+ # Plain text, not JSON — go through the retrying transport directly.
464
+ url = f"{self._client.base_url}{JOBS_PATH}/{job_id}/logs"
465
+ response = self._client._request_with_retries("GET", url)
466
+ return response.text
467
+
468
+ def run_lrm(
469
+ self,
470
+ symbol: str,
471
+ cal_date: str,
472
+ provider: str,
473
+ exchange: str,
474
+ order_sizes: list,
475
+ n_runs: int = 50,
476
+ seed: int = 42,
477
+ horizon_mins: int = 60,
478
+ strategy: str = "vwap",
479
+ side: str = None,
480
+ ):
481
+ """Run a liquidity-risk grid: market impact across a ladder of order sizes.
482
+
483
+ One execution algo is built per entry in order_sizes, and all of them
484
+ share a single baseline, so the cost is ``n_runs * (1 + len(order_sizes))``
485
+ simulations rather than one baseline per size. Poll the returned job_id
486
+ through the usual job endpoints.
487
+
488
+ Args:
489
+ symbol: Trading symbol (e.g. "700")
490
+ cal_date: Calibration date in YYYY-MM-DD format
491
+ provider: Data provider (e.g. "omd", "bmll")
492
+ exchange: Exchange protocol (e.g. "hkex_securities")
493
+ order_sizes: Order sizes in LOTS — one algo per entry
494
+ n_runs: Monte Carlo runs per arm (default 50)
495
+ seed: Random seed (default 42)
496
+ horizon_mins: Execution horizon in minutes (default 60)
497
+ strategy: "vwap" or "twap" (default "vwap")
498
+ side: "buy" or "sell"; defaults to the sign of each order size
499
+
500
+ Returns:
501
+ dict with job_id and the queued sim_ids
502
+ """
503
+ payload = {
504
+ "symbol": symbol,
505
+ "cal_date": cal_date,
506
+ "provider": provider,
507
+ "exchange": exchange,
508
+ "order_sizes": order_sizes,
509
+ "n_runs": n_runs,
510
+ "seed": seed,
511
+ "horizon_mins": horizon_mins,
512
+ "strategy": strategy,
513
+ }
514
+ if side is not None:
515
+ payload["side"] = side
516
+ return self._pro_request("POST", LRM_PATH, json=payload)
517
+
440
518
  def list_sim_files(self, sim_id: str):
441
519
  """
442
520
  List available files for a specific simulation.
@@ -27,18 +27,24 @@ pass, or ``True`` to force one on.
27
27
  ``run_inception_distances`` is the exception: it defaults to ``True``, so MIND
28
28
  and FID are computed unless you opt out. It maps to the API's ``run_fid``
29
29
  config field, which gates both metrics because they share one DeepLOB
30
- embedding pass. Note the scores are returned to the demo tier only — on any
31
- other tier a ``True`` here spends the embedding pass and the response withholds
32
- the numbers, so pass ``False`` if that is your situation.
30
+ embedding pass. As of pulse-api-pod 1.56.0 the scores are returned to every
31
+ validation tier; on older API deployments they reach the demo tier only.
33
32
  """
34
33
 
35
34
  import base64
35
+ import json
36
36
  import time
37
+ from pathlib import Path
37
38
 
38
39
 
39
40
  RUN_PATH = "/validation/run"
41
+ UPLOAD_PATH = "/validation/run/upload"
40
42
  JOBS_PATH = "/validation/jobs"
41
43
 
44
+ #: The API rejects more per job; checked client-side so a 26-file submission
45
+ #: fails before any bytes are uploaded.
46
+ MAX_SIM_FILES = 25
47
+
42
48
  #: Flags the API resolves from the caller's tier when left unset.
43
49
  _TRI_STATE_FLAGS = (
44
50
  "run_metrics",
@@ -52,6 +58,35 @@ _TRI_STATE_FLAGS = (
52
58
  _INCEPTION_WIRE_FIELD = "run_fid"
53
59
 
54
60
 
61
+ def _build_config(
62
+ run_metrics,
63
+ run_impact,
64
+ run_inception_distances,
65
+ run_stylised_facts,
66
+ plot_data,
67
+ n_levels,
68
+ l2_only,
69
+ ) -> dict:
70
+ """The validation config object as the API expects it.
71
+
72
+ Tri-state flags left as None are omitted rather than sent as null: the API
73
+ reads absence as "use my tier's default", and an explicit null would not
74
+ do that.
75
+ """
76
+ config = {
77
+ "n_levels": n_levels,
78
+ "l2_only": l2_only,
79
+ _INCEPTION_WIRE_FIELD: run_inception_distances,
80
+ }
81
+ for flag, value in zip(
82
+ _TRI_STATE_FLAGS,
83
+ (run_metrics, run_impact, run_stylised_facts, plot_data),
84
+ ):
85
+ if value is not None:
86
+ config[flag] = value
87
+ return config
88
+
89
+
55
90
  class ValidationResource:
56
91
  def __init__(self, client):
57
92
  self._client = client
@@ -88,13 +123,16 @@ class ValidationResource:
88
123
  ticksize: Tick size for the symbol
89
124
  run_metrics: Compute L1/Wasserstein distributional distances
90
125
  (None = tier default)
91
- run_impact: Compute impact response curves (None = tier default).
92
- Only computed when plot_data is on it has no verdict-only form.
126
+ run_impact: Compute Bouchaud impact response curves for each
127
+ simulated run (None = tier default). Runs on every tier as of
128
+ pulse 2.17.0 / pulse-api-pod 1.62.0; the historical curve is
129
+ additionally included on demo (plot_data) jobs. Older API
130
+ deployments only compute it when plot_data is on.
93
131
  run_inception_distances: Compute MIND *and* FID on DeepLOB
94
132
  embeddings (default True). One flag gates both — they share a
95
133
  single embedding pass. Sent as the API's ``run_fid`` field.
96
- The scores reach the demo tier only, so on another tier this
97
- spends the pass and the response withholds the numbers.
134
+ Scores are returned to every validation tier (API >= 1.56.0;
135
+ demo-only before that).
98
136
  run_stylised_facts: Compute the 11 Cont stylised facts
99
137
  (None = tier default)
100
138
  plot_data: Store the raw data behind every plot — distribution
@@ -114,19 +152,10 @@ class ValidationResource:
114
152
  Returns:
115
153
  dict with job_id, status, message
116
154
  """
117
- config = {
118
- "n_levels": n_levels,
119
- "l2_only": l2_only,
120
- _INCEPTION_WIRE_FIELD: run_inception_distances,
121
- }
122
- for flag, value in zip(
123
- _TRI_STATE_FLAGS,
124
- (run_metrics, run_impact, run_stylised_facts, plot_data),
125
- ):
126
- # Omitted rather than sent as None: the API reads absence as "use my
127
- # tier's default", and sending an explicit null would not do that.
128
- if value is not None:
129
- config[flag] = value
155
+ config = _build_config(
156
+ run_metrics, run_impact, run_inception_distances,
157
+ run_stylised_facts, plot_data, n_levels, l2_only,
158
+ )
130
159
 
131
160
  payload = {
132
161
  "symbol": symbol,
@@ -142,6 +171,85 @@ class ValidationResource:
142
171
 
143
172
  return self._client._request("POST", RUN_PATH, json=payload)
144
173
 
174
+ def run_upload(
175
+ self,
176
+ symbol: str,
177
+ date: str,
178
+ provider: str,
179
+ exchange: str,
180
+ sim_files: list,
181
+ ticksize: float = 1.0,
182
+ run_metrics: bool = None,
183
+ run_impact: bool = None,
184
+ run_inception_distances: bool = True,
185
+ run_stylised_facts: bool = None,
186
+ plot_data: bool = None,
187
+ n_levels: int = 10,
188
+ l2_only: bool = False,
189
+ ) -> dict:
190
+ """Submit a validation job from simulation files you hold yourself.
191
+
192
+ Same scoring as run(), for output that is not stored in Pulse —
193
+ parquets from your own systems, a local engine build, or a different
194
+ generator entirely. The historical side is still fetched server-side,
195
+ so only the simulated frames are uploaded.
196
+
197
+ Args:
198
+ symbol: Trading symbol (e.g. "700.HK")
199
+ date: Calibration date in YYYY-MM-DD format
200
+ provider: Data provider (e.g. "omd"). Required — with no sim_ids
201
+ to parse it from, it is the only way to identify the
202
+ historical day.
203
+ exchange: Exchange protocol (e.g. "hkex_securities"). Required,
204
+ same reason.
205
+ sim_files: 1-25 simulated frames, each either a path to a parquet
206
+ file or a ``(filename, bytes)`` pair for frames already in
207
+ memory.
208
+ ticksize: Tick size for the symbol.
209
+
210
+ The run flags mean exactly what they mean on run().
211
+
212
+ Returns:
213
+ dict with job_id, status, message
214
+
215
+ Raises:
216
+ ValueError: before any request, if sim_files is empty or has more
217
+ than 25 entries.
218
+ """
219
+ if not sim_files:
220
+ raise ValueError("sim_files is empty — supply 1 to 25 files")
221
+ if len(sim_files) > MAX_SIM_FILES:
222
+ raise ValueError(
223
+ f"{len(sim_files)} sim_files — the API accepts at most "
224
+ f"{MAX_SIM_FILES} per validation job"
225
+ )
226
+
227
+ config = _build_config(
228
+ run_metrics, run_impact, run_inception_distances,
229
+ run_stylised_facts, plot_data, n_levels, l2_only,
230
+ )
231
+
232
+ files = []
233
+ for entry in sim_files:
234
+ if isinstance(entry, tuple):
235
+ filename, content = entry
236
+ else:
237
+ path = Path(entry)
238
+ filename, content = path.name, path.read_bytes()
239
+ files.append(
240
+ ("sim_files", (filename, content, "application/octet-stream"))
241
+ )
242
+
243
+ data = {
244
+ "symbol": symbol,
245
+ "date": date,
246
+ "provider": provider,
247
+ "exchange": exchange,
248
+ "ticksize": str(ticksize),
249
+ "config": json.dumps(config),
250
+ }
251
+ return self._client._request("POST", UPLOAD_PATH, files=files, data=data)
252
+
145
253
  def get_job(self, job_id: str) -> dict:
146
254
  """Get validation job status and results.
147
255
 
@@ -155,13 +263,21 @@ class ValidationResource:
155
263
  - stylised_fact_verdicts: {fact: {historical: bool | None,
156
264
  simulated: [bool | None, ...]}} — every entitled tier
157
265
  - mind_scores: one Monge Inception Distance per sim run, in sim_ids
158
- order; None where a run could not be embedded. Demo tier only
266
+ order; None where a run could not be embedded. Every validation
267
+ tier (API >= 1.56.0)
159
268
  - fid_scores: one Frechet Inception Distance per sim run, same
160
269
  ordering and tier rule. Since pulse-check 1.8.0 this is the
161
270
  embedding-space FID — not comparable with values stored by older
162
271
  jobs
163
- - distributions / impact_response / stylised_facts: the full
164
- historical-derived payloads. Demo tier, plot_data jobs only
272
+ - impact_response: Bouchaud response curves lags and events are
273
+ the axes, and simulated holds one {ys, ci_low, ci_high} block
274
+ per run, indexed [event][lag]. Every tier (pulse-api-pod >=
275
+ 1.62.0); the historical block appears on demo plot_data jobs only
276
+ - impact_response_error: set when the impact pass was requested
277
+ but failed, so a null impact_response can be told apart from one
278
+ never asked for
279
+ - distributions / stylised_facts: the full historical-derived
280
+ payloads. Demo tier, plot_data jobs only
165
281
  - plots: {distributions: [...], distances: [...],
166
282
  impact_response: [...]} of {name, content_base64}
167
283
  - metadata: dict with run parameters
@@ -314,8 +430,9 @@ class ValidationResource:
314
430
  Raises:
315
431
  RuntimeError: If the job fails, or if the scores come back empty —
316
432
  which means the pipeline skipped them (missing torch, fewer
317
- than 10 levels, unreachable checkpoint) or the key is not on
318
- the demo tier, both of which are silent in the raw response.
433
+ than 10 levels, unreachable checkpoint), or the API predates
434
+ 1.56.0 and the key is not demo tier; both are silent in the
435
+ raw response.
319
436
 
320
437
  Interpreting the scores:
321
438
  Lower = closer to the historical day, but neither number means
@@ -346,8 +463,8 @@ class ValidationResource:
346
463
  raise RuntimeError(
347
464
  "no inception distances in the response. Either the pipeline "
348
465
  "skipped them (torch missing, fewer than 10 book levels, or "
349
- "the DeepLOB checkpoint unreachable) or this API key is not on "
350
- "the demo tier, which is the only tier they are shared with."
466
+ "the DeepLOB checkpoint unreachable), or the API predates "
467
+ "1.56.0 and this key is not demo tier."
351
468
  )
352
469
 
353
470
  return {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev1
3
+ Version: 0.7.0.dev2
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
@@ -7,6 +7,8 @@ src/simudyne/exceptions.py
7
7
  src/simudyne/resources/__init__.py
8
8
  src/simudyne/resources/api_keys.py
9
9
  src/simudyne/resources/data.py
10
+ src/simudyne/resources/fix.py
11
+ src/simudyne/resources/fm.py
10
12
  src/simudyne/resources/historical.py
11
13
  src/simudyne/resources/profile.py
12
14
  src/simudyne/resources/simulation.py
@@ -17,5 +19,6 @@ src/simudyne_pulse.egg-info/SOURCES.txt
17
19
  src/simudyne_pulse.egg-info/dependency_links.txt
18
20
  src/simudyne_pulse.egg-info/requires.txt
19
21
  src/simudyne_pulse.egg-info/top_level.txt
22
+ tests/test_new_resources.py
20
23
  tests/test_simulator_gym.py
21
24
  tests/test_validation.py
@@ -0,0 +1,150 @@
1
+ """Payload tests for the 0.8.0 surface: validation.run_upload, the fm and fix
2
+ resources, and simulation.get_job_logs / run_lrm.
3
+
4
+ No network — same recorder pattern as test_validation.py. These pin the wire
5
+ contract the docs describe: field names, which flags are omitted when unset,
6
+ and the client-side sim_files limit that must fail before any bytes move.
7
+ """
8
+
9
+ import json
10
+
11
+ import pytest
12
+
13
+ from simudyne.resources.fix import FixResource
14
+ from simudyne.resources.fm import FmResource, TERMINAL_STATUSES
15
+ from simudyne.resources.simulation import SimulationResource
16
+ from simudyne.resources.validation import ValidationResource
17
+
18
+
19
+ class FakeClient:
20
+ def __init__(self, responses=None):
21
+ self.calls = []
22
+ self._responses = list(responses or [])
23
+
24
+ def _request(self, method, path, **kwargs):
25
+ self.calls.append((method, path, kwargs))
26
+ return self._responses.pop(0) if self._responses else {}
27
+
28
+
29
+ class TestRunUpload:
30
+ def test_rejects_empty_before_any_request(self):
31
+ client = FakeClient()
32
+ with pytest.raises(ValueError, match="empty"):
33
+ ValidationResource(client).run_upload(
34
+ "700.HK", "2025-09-01", "omd", "hkex_securities", sim_files=[]
35
+ )
36
+ assert client.calls == []
37
+
38
+ def test_rejects_more_than_25_before_any_request(self):
39
+ client = FakeClient()
40
+ files = [(f"sim_{i}.parquet", b"x") for i in range(26)]
41
+ with pytest.raises(ValueError, match="25"):
42
+ ValidationResource(client).run_upload(
43
+ "700.HK", "2025-09-01", "omd", "hkex_securities", sim_files=files
44
+ )
45
+ assert client.calls == []
46
+
47
+ def test_multipart_payload_shape(self):
48
+ client = FakeClient([{"job_id": "v1", "status": "pending"}])
49
+ ValidationResource(client).run_upload(
50
+ "700.HK", "2025-09-01", "omd", "hkex_securities",
51
+ sim_files=[("sim_0000.parquet", b"PARQ")],
52
+ ticksize=0.5,
53
+ run_metrics=False,
54
+ )
55
+ method, path, kwargs = client.calls[0]
56
+ assert (method, path) == ("POST", "/validation/run/upload")
57
+ data = kwargs["data"]
58
+ assert data["provider"] == "omd" and data["exchange"] == "hkex_securities"
59
+ assert data["ticksize"] == "0.5"
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"):
64
+ assert flag not in config
65
+ assert config["run_fid"] is True
66
+ [(field, (filename, content, mime))] = kwargs["files"]
67
+ assert field == "sim_files" and filename == "sim_0000.parquet"
68
+ assert content == b"PARQ" and mime == "application/octet-stream"
69
+
70
+ def test_reads_paths_from_disk(self, tmp_path):
71
+ p = tmp_path / "sim_0001.parquet"
72
+ p.write_bytes(b"BYTES")
73
+ client = FakeClient([{}])
74
+ ValidationResource(client).run_upload(
75
+ "700.HK", "2025-09-01", "omd", "hkex_securities", sim_files=[p]
76
+ )
77
+ [(_, (filename, content, _))] = client.calls[0][2]["files"]
78
+ assert filename == "sim_0001.parquet" and content == b"BYTES"
79
+
80
+
81
+ class TestFmResource:
82
+ def test_terminal_statuses_are_the_two_documented(self):
83
+ assert TERMINAL_STATUSES == {"complete", "failed"}
84
+
85
+ def test_models_and_status_paths(self):
86
+ client = FakeClient([{}, {}])
87
+ fm = FmResource(client)
88
+ fm.models()
89
+ fm.job_status("j1")
90
+ assert client.calls[0][:2] == ("GET", "/fm/models")
91
+ assert client.calls[1][:2] == ("GET", "/fm/jobs/j1/status")
92
+
93
+ def test_available_data_omits_unset_filters(self):
94
+ client = FakeClient([{}])
95
+ FmResource(client).available_data(model_id="tradefm-hkex", limit=5)
96
+ _, path, kwargs = client.calls[0]
97
+ assert path == "/fm/available-data"
98
+ assert kwargs["params"] == {"model_id": "tradefm-hkex", "limit": 5, "offset": 0}
99
+
100
+ def test_run_sends_only_what_is_set(self):
101
+ client = FakeClient([{}])
102
+ FmResource(client).run(
103
+ "tradefm-hkex", "700", "2025-09-02", "bmll", "hkex_securities",
104
+ duration_minutes=30, n_runs=4, model_args={"temperature": 0.8},
105
+ )
106
+ _, path, kwargs = client.calls[0]
107
+ body = kwargs["json"]
108
+ assert path == "/fm/run"
109
+ assert body["duration_minutes"] == 30 and body["n_runs"] == 4
110
+ assert body["model_args"] == {"temperature": 0.8}
111
+ for absent in ("horizon", "device", "exec_algos"):
112
+ assert absent not in body
113
+
114
+
115
+ class TestFixResource:
116
+ def test_usage_path(self):
117
+ client = FakeClient([{}])
118
+ FixResource(client).usage()
119
+ assert client.calls[0][:2] == ("GET", "/fix/usage")
120
+
121
+
122
+ class TestSimulationAdditions:
123
+ def test_run_lrm_payload(self):
124
+ client = FakeClient([{}])
125
+ SimulationResource(client).run_lrm(
126
+ "700", "2025-09-02", "omd", "hkex_securities",
127
+ order_sizes=[10, 50, 100], side="buy",
128
+ )
129
+ method, path, kwargs = client.calls[0]
130
+ assert (method, path) == ("POST", "/simulation/lrm/run")
131
+ body = kwargs["json"]
132
+ assert body["order_sizes"] == [10, 50, 100]
133
+ assert body["strategy"] == "vwap" and body["side"] == "buy"
134
+
135
+ def test_get_job_logs_returns_plain_text(self):
136
+ class TextClient(FakeClient):
137
+ base_url = "https://api.test"
138
+
139
+ def _request_with_retries(self, method, url, **kwargs):
140
+ self.calls.append((method, url, kwargs))
141
+
142
+ class R:
143
+ text = "engine log line"
144
+
145
+ return R()
146
+
147
+ client = TextClient()
148
+ out = SimulationResource(client).get_job_logs("job-1")
149
+ assert out == "engine log line"
150
+ assert client.calls[0][1].endswith("/simulation/jobs/job-1/logs")