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.
- {simudyne_pulse-0.7.0.dev1/src/simudyne_pulse.egg-info → simudyne_pulse-0.7.0.dev2}/PKG-INFO +1 -1
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/pyproject.toml +1 -1
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/client.py +4 -0
- simudyne_pulse-0.7.0.dev2/src/simudyne/resources/fix.py +30 -0
- simudyne_pulse-0.7.0.dev2/src/simudyne/resources/fm.py +199 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/simulation.py +78 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/validation.py +144 -27
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2/src/simudyne_pulse.egg-info}/PKG-INFO +1 -1
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/SOURCES.txt +3 -0
- simudyne_pulse-0.7.0.dev2/tests/test_new_resources.py +150 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/LICENSE +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/README.md +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/setup.cfg +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/__init__.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/exceptions.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/__init__.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/api_keys.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/data.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/historical.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/profile.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/simulator_gym.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/dependency_links.txt +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/requires.txt +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/top_level.txt +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/tests/test_simulator_gym.py +0 -0
- {simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/tests/test_validation.py +0 -0
|
@@ -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")
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/simulation.py
RENAMED
|
@@ -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.
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/validation.py
RENAMED
|
@@ -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.
|
|
31
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
-
-
|
|
164
|
-
|
|
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
|
|
318
|
-
the demo tier
|
|
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
|
|
350
|
-
"
|
|
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 {
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/SOURCES.txt
RENAMED
|
@@ -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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/historical.py
RENAMED
|
File without changes
|
|
File without changes
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne/resources/simulator_gym.py
RENAMED
|
File without changes
|
|
File without changes
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/requires.txt
RENAMED
|
File without changes
|
{simudyne_pulse-0.7.0.dev1 → simudyne_pulse-0.7.0.dev2}/src/simudyne_pulse.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|