simudyne-pulse 0.7.0.dev9__tar.gz → 0.7.0.dev11__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 (30) hide show
  1. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/PKG-INFO +21 -3
  2. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/README.md +20 -2
  3. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/pyproject.toml +1 -1
  4. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/validation.py +17 -11
  5. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne_pulse.egg-info/PKG-INFO +21 -3
  6. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -4
  7. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/tests/test_validation.py +12 -6
  8. simudyne_pulse-0.7.0.dev9/tests/test_endpoint_coverage.py +0 -142
  9. simudyne_pulse-0.7.0.dev9/tests/test_fix.py +0 -35
  10. simudyne_pulse-0.7.0.dev9/tests/test_fm.py +0 -144
  11. simudyne_pulse-0.7.0.dev9/tests/test_run_lrm.py +0 -117
  12. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/LICENSE +0 -0
  13. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/setup.cfg +0 -0
  14. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/__init__.py +0 -0
  15. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/client.py +0 -0
  16. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/exceptions.py +0 -0
  17. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/__init__.py +0 -0
  18. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/api_keys.py +0 -0
  19. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/data.py +0 -0
  20. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/fix.py +0 -0
  21. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/fm.py +0 -0
  22. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/historical.py +0 -0
  23. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/profile.py +0 -0
  24. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/simulation.py +0 -0
  25. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne/resources/simulator_gym.py +0 -0
  26. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  27. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  28. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  29. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/tests/test_new_resources.py +0 -0
  30. {simudyne_pulse-0.7.0.dev9 → simudyne_pulse-0.7.0.dev11}/tests/test_simulator_gym.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev9
3
+ Version: 0.7.0.dev11
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -85,8 +85,26 @@ population. `sim_files` groups the same way.
85
85
  A plain list is one unnamed population and keeps the flat shape exactly as
86
86
  before. The historical day is measured once however many groups there are.
87
87
 
88
- `plot_all=True` draws everything the enabled areas can draw, not just the
89
- summaries.
88
+ `plots` is the only plot switch: unset draws nothing, `True` draws every
89
+ figure the enabled areas can draw, and a list draws just those ids. An area
90
+ switched off draws nothing either way.
91
+
92
+ | id | figure |
93
+ | --- | --- |
94
+ | `statistical.radar` | distance spider, one polygon per population |
95
+ | `statistical.distribution` | every metric's KDE |
96
+ | `statistical.distribution.{metric}` | one metric, e.g. `.spread` |
97
+ | `stylised_facts.overall` | the verdict table |
98
+ | `stylised_facts.fact` | every fact |
99
+ | `stylised_facts.fact.{name}` | one fact, e.g. `.heavy_tails` |
100
+ | `impact.response` | impact response by event type |
101
+ | `impact.event.{type}` | one event type |
102
+ | `volume_correlation.levels` | level correlation heatmap |
103
+ | `volume_correlation.changes` | change correlation heatmap |
104
+ | `volume_correlation.overall` | both of the above |
105
+
106
+ Each returned figure carries the `id` it was asked for — match on that, not on
107
+ the filename.
90
108
 
91
109
  ### Working from a checkout
92
110
 
@@ -59,8 +59,26 @@ population. `sim_files` groups the same way.
59
59
  A plain list is one unnamed population and keeps the flat shape exactly as
60
60
  before. The historical day is measured once however many groups there are.
61
61
 
62
- `plot_all=True` draws everything the enabled areas can draw, not just the
63
- summaries.
62
+ `plots` is the only plot switch: unset draws nothing, `True` draws every
63
+ figure the enabled areas can draw, and a list draws just those ids. An area
64
+ switched off draws nothing either way.
65
+
66
+ | id | figure |
67
+ | --- | --- |
68
+ | `statistical.radar` | distance spider, one polygon per population |
69
+ | `statistical.distribution` | every metric's KDE |
70
+ | `statistical.distribution.{metric}` | one metric, e.g. `.spread` |
71
+ | `stylised_facts.overall` | the verdict table |
72
+ | `stylised_facts.fact` | every fact |
73
+ | `stylised_facts.fact.{name}` | one fact, e.g. `.heavy_tails` |
74
+ | `impact.response` | impact response by event type |
75
+ | `impact.event.{type}` | one event type |
76
+ | `volume_correlation.levels` | level correlation heatmap |
77
+ | `volume_correlation.changes` | change correlation heatmap |
78
+ | `volume_correlation.overall` | both of the above |
79
+
80
+ Each returned figure carries the `id` it was asked for — match on that, not on
81
+ the filename.
64
82
 
65
83
  ### Working from a checkout
66
84
 
@@ -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.9"
7
+ version = "0.7.0-dev.11"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -64,8 +64,7 @@ _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",
68
- "plot_all")
67
+ _EXTRA_FIELDS = ("lob", "sample_period", "match_generated_sample", "plots")
69
68
 
70
69
 
71
70
  def _frame_to_parquet(entry, index: int):
@@ -173,7 +172,6 @@ class ValidationResource:
173
172
  match_generated_sample=None,
174
173
  n_levels: int = 10,
175
174
  plots=None,
176
- plot_all: bool = False,
177
175
  ) -> dict:
178
176
  """Submit a validation job.
179
177
 
@@ -224,13 +222,22 @@ class ValidationResource:
224
222
  n_levels : int, default 10
225
223
  Book levels to measure over.
226
224
  plots : bool or list of str, optional
227
- ``None`` or ``False`` for none, ``True`` for every figure, or plot
228
- ids such as ``["statistical.radar", "stylised_facts.overall"]``.
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".
225
+ ``None`` or ``False`` draws nothing, ``True`` draws every figure
226
+ the enabled areas can draw, and a list draws just those ids.
227
+ An area switched off draws nothing either way. Rendered
228
+ server-side; :meth:`get_job` writes them to disk. The ids are
229
+
230
+ - ``statistical.radar`` — the distance spider, one polygon per
231
+ population
232
+ - ``statistical.distribution`` — every metric's KDE;
233
+ ``statistical.distribution.{metric}`` for one, e.g. ``.spread``
234
+ - ``stylised_facts.overall`` — the verdict table
235
+ - ``stylised_facts.fact`` — every fact;
236
+ ``stylised_facts.fact.{name}`` for one, e.g. ``.heavy_tails``
237
+ - ``impact.response`` — impact response by event type;
238
+ ``impact.event.{type}`` for one
239
+ - ``volume_correlation.levels``, ``volume_correlation.changes``,
240
+ or ``volume_correlation.overall`` for both
234
241
 
235
242
  Returns
236
243
  -------
@@ -274,7 +281,6 @@ class ValidationResource:
274
281
  sample_period=sample_period,
275
282
  match_generated_sample=match_generated_sample,
276
283
  plots=plots,
277
- plot_all=plot_all or None,
278
284
  )
279
285
 
280
286
  if sim_ids is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simudyne-pulse
3
- Version: 0.7.0.dev9
3
+ Version: 0.7.0.dev11
4
4
  Summary: Python SDK for the Simudyne Pulse synthetic market data API
5
5
  Author-email: Simudyne <support@simudyne.com>
6
6
  License-Expression: MIT
@@ -85,8 +85,26 @@ population. `sim_files` groups the same way.
85
85
  A plain list is one unnamed population and keeps the flat shape exactly as
86
86
  before. The historical day is measured once however many groups there are.
87
87
 
88
- `plot_all=True` draws everything the enabled areas can draw, not just the
89
- summaries.
88
+ `plots` is the only plot switch: unset draws nothing, `True` draws every
89
+ figure the enabled areas can draw, and a list draws just those ids. An area
90
+ switched off draws nothing either way.
91
+
92
+ | id | figure |
93
+ | --- | --- |
94
+ | `statistical.radar` | distance spider, one polygon per population |
95
+ | `statistical.distribution` | every metric's KDE |
96
+ | `statistical.distribution.{metric}` | one metric, e.g. `.spread` |
97
+ | `stylised_facts.overall` | the verdict table |
98
+ | `stylised_facts.fact` | every fact |
99
+ | `stylised_facts.fact.{name}` | one fact, e.g. `.heavy_tails` |
100
+ | `impact.response` | impact response by event type |
101
+ | `impact.event.{type}` | one event type |
102
+ | `volume_correlation.levels` | level correlation heatmap |
103
+ | `volume_correlation.changes` | change correlation heatmap |
104
+ | `volume_correlation.overall` | both of the above |
105
+
106
+ Each returned figure carries the `id` it was asked for — match on that, not on
107
+ the filename.
90
108
 
91
109
  ### Working from a checkout
92
110
 
@@ -19,10 +19,6 @@ 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
25
22
  tests/test_new_resources.py
26
- tests/test_run_lrm.py
27
23
  tests/test_simulator_gym.py
28
24
  tests/test_validation.py
@@ -275,11 +275,17 @@ class TestNamedPopulations:
275
275
  assert "sim_groups" not in json.loads(client.calls[0][2]["data"]["config"])
276
276
 
277
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
278
+ class TestPlotEverything:
279
+ """plots says it all: there is no second way to ask for every figure."""
282
280
 
283
- def test_it_is_omitted_when_not_asked_for(self):
281
+ def test_true_is_forwarded(self):
282
+ client, _ = _run([{"job_id": "v1"}], plots=True)
283
+ assert client.calls[0][2]["json"]["config"]["plots"] is True
284
+
285
+ def test_nothing_is_sent_when_not_asked_for(self):
284
286
  client, _ = _run([{"job_id": "v1"}])
285
- assert "plot_all" not in client.calls[0][2]["json"]["config"]
287
+ assert "plots" not in client.calls[0][2]["json"]["config"]
288
+
289
+ def test_plot_all_is_gone(self):
290
+ with pytest.raises(TypeError):
291
+ _run([{"job_id": "v1"}], plot_all=True)
@@ -1,142 +0,0 @@
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)
@@ -1,35 +0,0 @@
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"}
@@ -1,144 +0,0 @@
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,117 +0,0 @@
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