simudyne-pulse 0.7.0.dev14__tar.gz → 0.7.0.dev16__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 (31) hide show
  1. {simudyne_pulse-0.7.0.dev14/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev16}/PKG-INFO +28 -1
  2. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/README.md +27 -0
  3. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/pyproject.toml +1 -1
  4. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/client.py +79 -0
  5. simudyne_pulse-0.7.0.dev16/src/simudyne/exceptions.py +44 -0
  6. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/api_keys.py +35 -0
  7. simudyne_pulse-0.7.0.dev16/src/simudyne/resources/data.py +109 -0
  8. simudyne_pulse-0.7.0.dev16/src/simudyne/resources/fix.py +75 -0
  9. simudyne_pulse-0.7.0.dev16/src/simudyne/resources/fm.py +477 -0
  10. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/profile.py +45 -0
  11. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/simulation.py +572 -220
  12. simudyne_pulse-0.7.0.dev16/src/simudyne/resources/simulator_gym.py +284 -0
  13. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/validation.py +119 -56
  14. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16/src/simudyne_pulse.egg-info}/PKG-INFO +28 -1
  15. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/SOURCES.txt +0 -1
  16. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/tests/test_new_resources.py +1 -1
  17. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/tests/test_validation.py +55 -2
  18. simudyne_pulse-0.7.0.dev14/src/simudyne/exceptions.py +0 -7
  19. simudyne_pulse-0.7.0.dev14/src/simudyne/resources/data.py +0 -57
  20. simudyne_pulse-0.7.0.dev14/src/simudyne/resources/fix.py +0 -32
  21. simudyne_pulse-0.7.0.dev14/src/simudyne/resources/fm.py +0 -227
  22. simudyne_pulse-0.7.0.dev14/src/simudyne/resources/historical.py +0 -1
  23. simudyne_pulse-0.7.0.dev14/src/simudyne/resources/simulator_gym.py +0 -131
  24. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/LICENSE +0 -0
  25. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/setup.cfg +0 -0
  26. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/__init__.py +0 -0
  27. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne/resources/__init__.py +0 -0
  28. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
  29. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/requires.txt +0 -0
  30. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
  31. {simudyne_pulse-0.7.0.dev14 → simudyne_pulse-0.7.0.dev16}/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.dev14
3
+ Version: 0.7.0.dev16
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,6 +85,33 @@ 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
+ ### Your own runs beside platform runs
89
+
90
+ `sim_ids` and `sim_files` can be given together — your own model against
91
+ platform simulations, on one set of figures, 25 runs in total across the two:
92
+
93
+ ```python
94
+ job = client.validation.run(
95
+ symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
96
+ sim_files={"fm": ["my_run.parquet"]},
97
+ sim_ids={"abm": abm_ids},
98
+ plots=["statistical.radar", "stylised_facts.overall"],
99
+ )
100
+ ```
101
+
102
+ With both present each source is a population of its own, so a bare list gets
103
+ a name rather than merging into whatever else is there:
104
+
105
+ | passed | populations |
106
+ | --- | --- |
107
+ | files mapping + ids list | its names, plus `platform` |
108
+ | files list + ids mapping | `uploaded`, plus its names |
109
+ | both lists | `uploaded` and `platform` |
110
+ | both mappings | their own names |
111
+ | either one alone | exactly as before |
112
+
113
+ The uploads are measured first and the platform runs follow.
114
+
88
115
  `plots` is the only plot switch: unset draws nothing, `True` draws every
89
116
  figure the enabled areas can draw, and a list draws just those ids. An area
90
117
  switched off draws nothing either way.
@@ -59,6 +59,33 @@ 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
+ ### Your own runs beside platform runs
63
+
64
+ `sim_ids` and `sim_files` can be given together — your own model against
65
+ platform simulations, on one set of figures, 25 runs in total across the two:
66
+
67
+ ```python
68
+ job = client.validation.run(
69
+ symbol="TSCO", date="2026-06-23", provider="bmll", exchange="lse",
70
+ sim_files={"fm": ["my_run.parquet"]},
71
+ sim_ids={"abm": abm_ids},
72
+ plots=["statistical.radar", "stylised_facts.overall"],
73
+ )
74
+ ```
75
+
76
+ With both present each source is a population of its own, so a bare list gets
77
+ a name rather than merging into whatever else is there:
78
+
79
+ | passed | populations |
80
+ | --- | --- |
81
+ | files mapping + ids list | its names, plus `platform` |
82
+ | files list + ids mapping | `uploaded`, plus its names |
83
+ | both lists | `uploaded` and `platform` |
84
+ | both mappings | their own names |
85
+ | either one alone | exactly as before |
86
+
87
+ The uploads are measured first and the platform runs follow.
88
+
62
89
  `plots` is the only plot switch: unset draws nothing, `True` draws every
63
90
  figure the enabled areas can draw, and a list draws just those ids. An area
64
91
  switched off draws nothing either way.
@@ -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.14"
7
+ version = "0.7.0-dev.16"
8
8
  description = "Python SDK for the Simudyne Pulse synthetic market data API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,6 +12,75 @@ from simudyne.exceptions import PulseAPIError
12
12
 
13
13
 
14
14
  class PulseABM:
15
+ """Client for the Simudyne Pulse synthetic market data API.
16
+
17
+ The client holds the API key and HTTP session, and exposes every endpoint
18
+ through a resource attribute (``client.simulation``, ``client.data``, ...).
19
+ Construct it once and reuse it; the underlying :class:`requests.Session`
20
+ keeps connections alive across calls.
21
+
22
+ Transient failures (HTTP 429, 502, 503, 504) and network timeouts are
23
+ retried automatically with exponential backoff. Any other non-2xx response
24
+ raises :class:`~simudyne.exceptions.PulseAPIError`.
25
+
26
+ Parameters
27
+ ----------
28
+ api_key : str, optional
29
+ Pulse API key (``pk_live_...``). Falls back to the ``SIMUDYNE_API_KEY``
30
+ environment variable when omitted.
31
+ base_url : str, optional
32
+ API root, with no trailing slash. Falls back to the
33
+ ``SIMUDYNE_BASE_URL`` environment variable, then to
34
+ ``https://pulse-api.simudyne.com``.
35
+ timeout : int, optional
36
+ Per-request timeout in seconds. Defaults to 30.
37
+ max_retries : int, default 3
38
+ Retry attempts for transient errors. Backoff is ``2 ** attempt``
39
+ seconds, so the default waits 1s, 2s then 4s before giving up.
40
+
41
+ Attributes
42
+ ----------
43
+ profile : ProfileResource
44
+ Account details, usage and download history.
45
+ api_keys : ApiKeysResource
46
+ Create, list and revoke API keys.
47
+ data : DataResource
48
+ The catalog of calibrated symbols available to simulate.
49
+ simulation : SimulationResource
50
+ Submit agent-based simulations and retrieve their results.
51
+ validation : ValidationResource
52
+ Score synthetic data against real data.
53
+ fm : FmResource
54
+ Foundation-model generation and its job lifecycle.
55
+ fix : FixResource
56
+ FIX gateway usage.
57
+ simulator_gym : SimulatorGymResource
58
+ Gym-style reinforcement-learning environment over a WebSocket.
59
+
60
+ Raises
61
+ ------
62
+ ValueError
63
+ If no API key is given and ``SIMUDYNE_API_KEY`` is unset.
64
+
65
+ Examples
66
+ --------
67
+ Key taken from the environment:
68
+
69
+ >>> import os
70
+ >>> os.environ["SIMUDYNE_API_KEY"] = "pk_live_..."
71
+ >>> client = PulseABM()
72
+
73
+ Key passed explicitly, against a non-default deployment:
74
+
75
+ >>> client = PulseABM(
76
+ ... api_key="pk_live_...",
77
+ ... base_url="https://pulse-api-dev.simudyne.com",
78
+ ... timeout=60,
79
+ ... )
80
+ >>> client.profile.get()["email"]
81
+ 'you@example.com'
82
+ """
83
+
15
84
  DEFAULT_BASE_URL = "https://pulse-api.simudyne.com"
16
85
  DEFAULT_TIMEOUT = 30
17
86
  RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
@@ -41,6 +110,8 @@ class PulseABM:
41
110
  from simudyne.resources.simulation import SimulationResource
42
111
  from simudyne.resources.simulator_gym import SimulatorGymResource
43
112
  from simudyne.resources.validation import ValidationResource
113
+ from simudyne.resources.fm import FmResource
114
+ from simudyne.resources.fix import FixResource
44
115
 
45
116
  self.profile = ProfileResource(self)
46
117
  self.api_keys = ApiKeysResource(self)
@@ -50,6 +121,8 @@ class PulseABM:
50
121
  self.simulation = SimulationResource(self)
51
122
  self.simulator_gym = SimulatorGymResource(self)
52
123
  self.validation = ValidationResource(self)
124
+ self.fm = FmResource(self)
125
+ self.fix = FixResource(self)
53
126
 
54
127
  def _request_with_retries(self, method: str, url: str, **kwargs):
55
128
  """Execute request with timeout and exponential backoff for transient errors."""
@@ -89,6 +162,12 @@ class PulseABM:
89
162
  response = self._request_with_retries(method, url, **kwargs)
90
163
  return response.json()
91
164
 
165
+ def _request_text(self, method: str, endpoint: str, **kwargs):
166
+ """Like _request, for endpoints that answer text/plain not JSON."""
167
+ url = f"{self.base_url}{endpoint}"
168
+ response = self._request_with_retries(method, url, **kwargs)
169
+ return response.text
170
+
92
171
  def _request_csv(self, method, endpoint, **kwargs):
93
172
  PAGE_SIZE = 100000
94
173
  params = kwargs.get("params", {})
@@ -0,0 +1,44 @@
1
+ class PulseAPIError(Exception):
2
+ """Raised when the Pulse API returns a non-2xx response.
3
+
4
+ Every resource method raises this on API-level failure. Transient statuses
5
+ (429, 502, 503, 504) are retried by the client first, so receiving this
6
+ means the request failed after all retries were exhausted, or failed with a
7
+ status that is not worth retrying.
8
+
9
+ Parameters
10
+ ----------
11
+ status_code : int
12
+ HTTP status code returned by the API.
13
+ detail : str
14
+ Message from the response body's ``detail`` field, or the raw response
15
+ text when the body is not JSON.
16
+
17
+ Attributes
18
+ ----------
19
+ status_code : int
20
+ HTTP status code returned by the API.
21
+ detail : str
22
+ Human-readable explanation of the failure.
23
+
24
+ Examples
25
+ --------
26
+ >>> try:
27
+ ... client.simulation.get_job_status("does-not-exist")
28
+ ... except PulseAPIError as exc:
29
+ ... print(exc.status_code, exc.detail)
30
+ 404 Job not found
31
+
32
+ Retry only on rate limiting, and let anything else surface:
33
+
34
+ >>> try:
35
+ ... client.simulation.run(...)
36
+ ... except PulseAPIError as exc:
37
+ ... if exc.status_code != 429:
38
+ ... raise
39
+ """
40
+
41
+ def __init__(self, status_code: int, detail: str):
42
+ self.status_code = status_code
43
+ self.detail = detail
44
+ super().__init__(f"API Error ({status_code}): {detail}")
@@ -1,4 +1,18 @@
1
1
  class ApiKeysResource:
2
+ """Create, list and revoke the API keys on your account.
3
+
4
+ Reached as ``client.api_keys``. Keys are the only credential the SDK
5
+ accepts, and key material is returned exactly once, by :meth:`create`.
6
+
7
+ Examples
8
+ --------
9
+ Mint a key for a job, use it, then revoke it:
10
+
11
+ >>> key = client.api_keys.create(name="Nightly backtest")
12
+ >>> run_client = PulseABM(api_key=key["api_key"])
13
+ >>> client.api_keys.revoke(key_id=key["api_key_id"])
14
+ """
15
+
2
16
  def __init__(self, client):
3
17
  self._client = client
4
18
 
@@ -25,6 +39,11 @@ class ApiKeysResource:
25
39
  warning : str
26
40
  A reminder that the key will not be shown again.
27
41
 
42
+ Raises
43
+ ------
44
+ PulseAPIError
45
+ If the account has reached its key limit, or ``name`` is rejected.
46
+
28
47
  Examples
29
48
  --------
30
49
  >>> new_key = client.api_keys.create(name="Training script")
@@ -52,6 +71,11 @@ class ApiKeysResource:
52
71
  - last_used_at (str or None): None until the key is first used
53
72
  - expires_at (str or None): None for keys that do not expire
54
73
 
74
+ Raises
75
+ ------
76
+ PulseAPIError
77
+ If the calling key is invalid or has been revoked.
78
+
55
79
  Examples
56
80
  --------
57
81
  >>> for key in client.api_keys.list():
@@ -75,9 +99,20 @@ class ApiKeysResource:
75
99
  dict
76
100
  The revocation result.
77
101
 
102
+ Raises
103
+ ------
104
+ PulseAPIError
105
+ If ``key_id`` does not exist or does not belong to your account.
106
+
78
107
  Examples
79
108
  --------
80
109
  >>> new_key = client.api_keys.create(name="Training script")
81
110
  >>> client.api_keys.revoke(key_id=new_key["api_key_id"])
111
+
112
+ Revoking the key you are currently authenticating with locks you out
113
+ of the API, so mint the replacement first:
114
+
115
+ >>> replacement = client.api_keys.create(name="Rotated")
116
+ >>> client.api_keys.revoke(key_id=old_key_id)
82
117
  """
83
118
  return self._client._request("DELETE", f"/api-keys/{key_id}")
@@ -0,0 +1,109 @@
1
+ AVAILABLE_SYMBOLS_PATH = "/data/available-symbols"
2
+
3
+
4
+ class DataResource:
5
+ """Browse the catalog of calibrated instruments you can simulate.
6
+
7
+ Reached as ``client.data``. Every simulation is pinned to a symbol that has
8
+ already been calibrated, so this is the resource that tells you which
9
+ ``provider`` / ``exchange`` / ``symbol`` / ``cal_date`` combinations exist.
10
+
11
+ Examples
12
+ --------
13
+ >>> catalog = client.data.get_available_symbols(exchange="hkex_securities")
14
+ >>> for instrument in catalog[:3]:
15
+ ... dates = instrument["available_dates"]
16
+ ... print(instrument["symbol"], dates[:2])
17
+ """
18
+
19
+ def __init__(self, client):
20
+ self._client = client
21
+
22
+ def get_available_symbols(
23
+ self,
24
+ symbol: str | None = None,
25
+ exchange: str | None = None,
26
+ provider: str | None = None,
27
+ date: str | None = None,
28
+ q: str | None = None,
29
+ limit: int | None = None,
30
+ offset: int | None = None,
31
+ ):
32
+ """List calibrated instruments and the dates available for each.
33
+
34
+ All arguments are optional filters; with none passed the whole catalog
35
+ comes back, as before.
36
+
37
+ Parameters
38
+ ----------
39
+ symbol : str, optional
40
+ Exact symbol, e.g. "700.HK".
41
+ exchange : str, optional
42
+ Exchange protocol, e.g. "hkex_securities".
43
+ provider : str, optional
44
+ Data provider, e.g. "omd" or "bmll".
45
+ date : str, optional
46
+ Calibration date "YYYY-MM-DD". Keeps only instruments
47
+ calibrated on that date, and narrows each instrument's
48
+ available_dates to it.
49
+ q : str, optional
50
+ Case-insensitive substring match over ticker and company name.
51
+ limit : int, optional
52
+ Max instruments to return.
53
+ offset : int, optional
54
+ Instruments to skip (for paging alongside limit).
55
+
56
+ Returns
57
+ -------
58
+ list of dict
59
+ One entry per instrument, each with:
60
+
61
+ - symbol (str): ticker, e.g. "700.HK"
62
+ - exchange (str): exchange protocol, e.g. "hkex_securities"
63
+ - provider (str): data provider, e.g. "omd"
64
+ - available_dates (list of str): calibration dates, "YYYY-MM-DD",
65
+ any of which is a valid ``cal_date`` for
66
+ :meth:`~simudyne.resources.simulation.SimulationResource.run`
67
+
68
+ Raises
69
+ ------
70
+ PulseAPIError
71
+ If a filter is malformed — most often ``date`` not in
72
+ ``YYYY-MM-DD`` form.
73
+
74
+ Examples
75
+ --------
76
+ The whole catalog:
77
+
78
+ >>> everything = client.data.get_available_symbols()
79
+ >>> len(everything)
80
+ 412
81
+
82
+ One instrument, to see which dates it can be run for:
83
+
84
+ >>> hits = client.data.get_available_symbols(symbol="700.HK")
85
+ >>> first = hits[0]
86
+ >>> print(first["available_dates"])
87
+ ['2025-09-01', '2025-09-02', '2025-09-03']
88
+
89
+ Free-text search over ticker and company name, paged:
90
+
91
+ >>> page = client.data.get_available_symbols(q="tencent", limit=10)
92
+
93
+ Everything calibrated on one date, which is the usual way to pick a
94
+ date that is valid across several instruments:
95
+
96
+ >>> on_date = client.data.get_available_symbols(date="2025-09-01")
97
+ """
98
+ params = {
99
+ k: v for k, v in {
100
+ "symbol": symbol,
101
+ "exchange": exchange,
102
+ "provider": provider,
103
+ "date": date,
104
+ "q": q,
105
+ "limit": limit,
106
+ "offset": offset,
107
+ }.items() if v is not None
108
+ }
109
+ return self._client._request("GET", AVAILABLE_SYMBOLS_PATH, params=params or None)
@@ -0,0 +1,75 @@
1
+ """FIX venue usage: what you have run over a FIX session.
2
+
3
+ The FIX venue itself is a raw-TCP FIX 4.4 acceptor, not an HTTP API — you reach
4
+ it with your own FIX engine, not with this SDK. What lives here is the one
5
+ HTTP endpoint a FIX client wants: a record of what it has run.
6
+
7
+ Runs are written by the venue rather than by the client, because the venue is
8
+ what knows the session, the account and the start parameters. Only the shape of
9
+ a run is kept -- symbol, date, scenario, speed, horizon. No orders, no
10
+ schedules, no fills: that is your strategy, and it is not ours to store.
11
+ """
12
+
13
+ USAGE_PATH = "/fix/usage"
14
+
15
+
16
+ class FixResource:
17
+ """Usage records for simulations started over the FIX venue.
18
+
19
+ Reached as ``client.fix``. The venue itself is raw-TCP FIX 4.4 and is not
20
+ driven from this SDK — you connect to it with your own FIX engine. This
21
+ resource only reads back what the venue recorded.
22
+
23
+ Examples
24
+ --------
25
+ >>> usage = client.fix.usage()
26
+ >>> print(usage["total_runs"], "runs,", usage["simulated_seconds"], "s")
27
+ """
28
+
29
+ def __init__(self, client):
30
+ self._client = client
31
+
32
+ def usage(self, user_id: str = None):
33
+ """Simulations you have started over FIX, and how many times.
34
+
35
+ Parameters
36
+ ----------
37
+ user_id : str, optional
38
+ Another account to read, which requires an admin key.
39
+ Omit it for your own usage.
40
+
41
+ Returns
42
+ -------
43
+ dict
44
+ Usage summary containing:
45
+
46
+ - total_runs (int): FIX sessions recorded
47
+ - simulated_seconds (float): total horizon across those runs
48
+ - by_symbol / by_scenario / by_cal_date (dict): run counts
49
+ - runs (list of dict): one ``{symbol, cal_date, scenario, runs}``
50
+ per distinct combination, most-run first
51
+ - last_run_at (str or None): when the most recent one started,
52
+ None if nothing has been run
53
+ - truncated (bool): True if the history hit the server's cap
54
+
55
+ Raises
56
+ ------
57
+ PulseAPIError
58
+ If ``user_id`` is given without an admin key, or names an account
59
+ that does not exist.
60
+
61
+ Examples
62
+ --------
63
+ >>> usage = client.fix.usage()
64
+ >>> print(f"{usage['total_runs']} FIX runs")
65
+ >>> runs = usage["runs"]
66
+ >>> for run in runs[:5]:
67
+ ... print(f" {run['symbol']} {run['cal_date']} "
68
+ ... f"{run['scenario']}: {run['runs']}x")
69
+
70
+ Read another account, which needs an admin key:
71
+
72
+ >>> client.fix.usage(user_id="usr_01H...")
73
+ """
74
+ params = {"user_id": user_id} if user_id else None
75
+ return self._client._request("GET", USAGE_PATH, params=params)