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 +3 -0
- simudyne/client.py +121 -0
- simudyne/exceptions.py +7 -0
- simudyne/resources/__init__.py +0 -0
- simudyne/resources/api_keys.py +12 -0
- simudyne/resources/data.py +45 -0
- simudyne/resources/historical.py +1 -0
- simudyne/resources/profile.py +30 -0
- simudyne/resources/simulation.py +717 -0
- simudyne/resources/simulator_gym.py +118 -0
- simudyne/resources/validation.py +234 -0
- simudyne_pulse-0.6.0.dist-info/METADATA +147 -0
- simudyne_pulse-0.6.0.dist-info/RECORD +16 -0
- simudyne_pulse-0.6.0.dist-info/WHEEL +5 -0
- simudyne_pulse-0.6.0.dist-info/licenses/LICENSE +21 -0
- simudyne_pulse-0.6.0.dist-info/top_level.txt +1 -0
simudyne/__init__.py
ADDED
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
|
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")
|