simudyne-pulse 0.6.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from simudyne.client import PulseABM, PulseAPIError
2
+
3
+ __all__ = ["PulseABM", "PulseAPIError"]
simudyne/client.py ADDED
@@ -0,0 +1,121 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ import requests
5
+ import io
6
+ import polars as pl
7
+ from tqdm import tqdm
8
+ import tempfile
9
+ import math
10
+
11
+ from simudyne.exceptions import PulseAPIError
12
+
13
+
14
+ class PulseABM:
15
+ DEFAULT_BASE_URL = "https://pulse-api.simudyne.com"
16
+ DEFAULT_TIMEOUT = 30
17
+ RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: str = None,
22
+ base_url: str = None,
23
+ timeout: int = None,
24
+ max_retries: int = 3,
25
+ ):
26
+ self.api_key = api_key or os.getenv("SIMUDYNE_API_KEY")
27
+ if not self.api_key:
28
+ raise ValueError("SIMUDYNE_API_KEY is not set -> pass api_key or set SIMUDYNE_API_KEY in environment")
29
+ self.base_url = base_url or os.getenv("SIMUDYNE_BASE_URL", self.DEFAULT_BASE_URL)
30
+ self.timeout = timeout or self.DEFAULT_TIMEOUT
31
+ self.max_retries = max_retries
32
+ self.session = requests.Session()
33
+ self.session.headers.update({"X-API-Key": self.api_key})
34
+ self.session.verify = True
35
+
36
+ from simudyne.resources.profile import ProfileResource
37
+ from simudyne.resources.api_keys import ApiKeysResource
38
+ from simudyne.resources.data import DataResource
39
+ from simudyne.resources.simulation import SimulationResource
40
+ from simudyne.resources.simulator_gym import SimulatorGymResource
41
+ from simudyne.resources.validation import ValidationResource
42
+
43
+ self.profile = ProfileResource(self)
44
+ self.api_keys = ApiKeysResource(self)
45
+ self.data = DataResource(self)
46
+ self.simulation = SimulationResource(self)
47
+ self.simulator_gym = SimulatorGymResource(self)
48
+ self.validation = ValidationResource(self)
49
+
50
+ def _request_with_retries(self, method: str, url: str, **kwargs):
51
+ """Execute request with timeout and exponential backoff for transient errors."""
52
+ kwargs.setdefault("timeout", self.timeout)
53
+ last_exception = None
54
+
55
+ for attempt in range(self.max_retries + 1):
56
+ try:
57
+ response = self.session.request(method, url, **kwargs)
58
+
59
+ if response.ok:
60
+ return response
61
+
62
+ if response.status_code in self.RETRYABLE_STATUS_CODES and attempt < self.max_retries:
63
+ delay = 2 ** attempt
64
+ time.sleep(delay)
65
+ continue
66
+
67
+ try:
68
+ detail = response.json().get("detail", response.text)
69
+ except ValueError:
70
+ detail = response.text
71
+ raise PulseAPIError(response.status_code, detail)
72
+
73
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
74
+ last_exception = e
75
+ if attempt < self.max_retries:
76
+ delay = 2 ** attempt
77
+ time.sleep(delay)
78
+ continue
79
+ raise
80
+
81
+ raise last_exception
82
+
83
+ def _request(self, method: str, endpoint: str, **kwargs):
84
+ url = f"{self.base_url}{endpoint}"
85
+ response = self._request_with_retries(method, url, **kwargs)
86
+ return response.json()
87
+
88
+ def _request_csv(self, method, endpoint, **kwargs):
89
+ PAGE_SIZE = 100000
90
+ params = kwargs.get("params", {})
91
+
92
+ params["limit"] = PAGE_SIZE
93
+ params["offset"] = 0
94
+ kwargs["params"] = params
95
+
96
+ url = f"{self.base_url}{endpoint}"
97
+ response = self._request_with_retries(method, url, **kwargs)
98
+
99
+ total_rows = int(response.headers.get("X-Total-Rows", 0))
100
+ total_pages = math.ceil(total_rows / PAGE_SIZE) if total_rows > 0 else 1
101
+
102
+ frames = []
103
+ df = pl.read_csv(response.text.encode(), infer_schema_length=10000)
104
+ frames.append(df)
105
+ schema = df.schema
106
+
107
+ if total_pages > 1:
108
+ with tqdm(total=total_pages, initial=1, unit="page", desc=f"Downloading ({total_rows:,} rows)") as pbar:
109
+ for page in range(1, total_pages):
110
+ params["offset"] = page * PAGE_SIZE
111
+ kwargs["params"] = params
112
+ response = self._request_with_retries(method, url, **kwargs)
113
+ df = pl.read_csv(response.text.encode(), schema_overrides=schema, infer_schema_length=10000)
114
+ if df.is_empty():
115
+ break
116
+ frames.append(df)
117
+ pbar.update(1)
118
+
119
+ result = pl.concat(frames) if len(frames) > 1 else frames[0]
120
+ print(f"Done: {result.shape[0]:,} rows, {result.shape[1]} columns", file=sys.stderr)
121
+ return result
simudyne/exceptions.py ADDED
@@ -0,0 +1,7 @@
1
+ class PulseAPIError(Exception):
2
+ """Exception raised for Pulse API errors."""
3
+
4
+ def __init__(self, status_code: int, detail: str):
5
+ self.status_code = status_code
6
+ self.detail = detail
7
+ super().__init__(f"API Error ({status_code}): {detail}")
File without changes
@@ -0,0 +1,12 @@
1
+ class ApiKeysResource:
2
+ def __init__(self, client):
3
+ self._client = client
4
+
5
+ def create(self, name: str):
6
+ return self._client._request("POST", "/api-keys", json={"name": name})
7
+
8
+ def list(self):
9
+ return self._client._request("GET", "/api-keys")
10
+
11
+ def revoke(self, key_id: str):
12
+ return self._client._request("DELETE", f"/api-keys/{key_id}")
@@ -0,0 +1,45 @@
1
+ AVAILABLE_SYMBOLS_PATH = "/data/available-symbols"
2
+
3
+
4
+ class DataResource:
5
+ def __init__(self, client):
6
+ self._client = client
7
+
8
+ def get_available_symbols(
9
+ self,
10
+ symbol: str | None = None,
11
+ exchange: str | None = None,
12
+ provider: str | None = None,
13
+ date: str | None = None,
14
+ limit: int | None = None,
15
+ offset: int | None = None,
16
+ ):
17
+ """List calibrated instruments and the dates available for each.
18
+
19
+ All arguments are optional filters; with none passed the whole catalog
20
+ comes back, as before.
21
+
22
+ Args:
23
+ symbol: Exact symbol, e.g. "700.HK".
24
+ exchange: Exchange protocol, e.g. "hkex_securities".
25
+ provider: Data provider, e.g. "omd" or "bmll".
26
+ date: Calibration date "YYYY-MM-DD". Keeps only instruments
27
+ calibrated on that date, and narrows each instrument's
28
+ available_dates to it.
29
+ limit: Max instruments to return.
30
+ offset: Instruments to skip (for paging alongside limit).
31
+
32
+ Returns:
33
+ list of instrument dicts, each with available_dates.
34
+ """
35
+ params = {
36
+ k: v for k, v in {
37
+ "symbol": symbol,
38
+ "exchange": exchange,
39
+ "provider": provider,
40
+ "date": date,
41
+ "limit": limit,
42
+ "offset": offset,
43
+ }.items() if v is not None
44
+ }
45
+ return self._client._request("GET", AVAILABLE_SYMBOLS_PATH, params=params or None)
@@ -0,0 +1 @@
1
+ # Historical endpoints have been removed. Use data.get_available() instead.
@@ -0,0 +1,30 @@
1
+ class ProfileResource:
2
+ def __init__(self, client):
3
+ self._client = client
4
+
5
+ def get(self):
6
+ return self._client._request("GET", "/profile")
7
+
8
+ def usage(self):
9
+ return self._client._request("GET", "/profile/usage")
10
+
11
+ def downloads(self):
12
+ """Download quota usage for the current rolling 24-hour window.
13
+
14
+ Returns a dict with ``limit``, ``used``, ``remaining``,
15
+ ``window_hours`` and ``downloaded_groups``. ``limit`` and
16
+ ``remaining`` are ``None`` on the pro and demo tiers (unlimited); on
17
+ the free tier they reflect the account's daily download allowance.
18
+
19
+ A "download" is one simulation group (all Monte Carlo runs of one
20
+ scenario). ``used`` counts NEW groups added in the window;
21
+ ``downloaded_groups`` lists every group you have ever downloaded —
22
+ membership is permanent, so those are free to re-fetch forever in any
23
+ run or file format.
24
+
25
+ Example:
26
+ >>> quota = client.profile.downloads()
27
+ >>> print(f"{quota['used']}/{quota['limit']} used, {quota['remaining']} left")
28
+ >>> print(f"{len(quota['downloaded_groups'])} groups owned")
29
+ """
30
+ return self._client._request("GET", "/profile/downloads")