quantpad-data 0.1.0__tar.gz → 0.2.0__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.
- quantpad_data-0.2.0/PKG-INFO +159 -0
- quantpad_data-0.2.0/README.md +138 -0
- {quantpad_data-0.1.0 → quantpad_data-0.2.0}/pyproject.toml +1 -1
- {quantpad_data-0.1.0 → quantpad_data-0.2.0}/quantpad_data/__init__.py +10 -1
- {quantpad_data-0.1.0 → quantpad_data-0.2.0}/quantpad_data/client.py +13 -12
- quantpad_data-0.2.0/quantpad_data/errors.py +60 -0
- quantpad_data-0.2.0/quantpad_data/lean.py +153 -0
- quantpad_data-0.2.0/quantpad_data/v2.py +652 -0
- {quantpad_data-0.1.0 → quantpad_data-0.2.0}/tests/test_client.py +32 -0
- quantpad_data-0.2.0/tests/test_lean.py +145 -0
- quantpad_data-0.2.0/tests/test_v2.py +719 -0
- quantpad_data-0.2.0/tests/test_version.py +38 -0
- quantpad_data-0.1.0/PKG-INFO +0 -55
- quantpad_data-0.1.0/README.md +0 -34
- quantpad_data-0.1.0/quantpad_data/errors.py +0 -41
- {quantpad_data-0.1.0 → quantpad_data-0.2.0}/.gitignore +0 -0
- {quantpad_data-0.1.0 → quantpad_data-0.2.0}/quantpad_data/py.typed +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: quantpad-data
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python client for the QuantPad market-data API
|
|
5
|
+
Project-URL: Documentation, https://api.quantpad.ai/external/docs
|
|
6
|
+
Author: QuantPad
|
|
7
|
+
License: Proprietary
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: pandas<3,>=2.2
|
|
13
|
+
Requires-Dist: pyarrow>=18
|
|
14
|
+
Requires-Dist: requests>=2.32
|
|
15
|
+
Provides-Extra: rth
|
|
16
|
+
Requires-Dist: exchange-calendars>=4.5; extra == 'rth'
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: build>=1.2; extra == 'test'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# QuantPad Data Python SDK
|
|
23
|
+
|
|
24
|
+
Install into the project's existing environment/package manager:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add quantpad-data # uv project
|
|
28
|
+
poetry add quantpad-data # Poetry project
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For a plain Python project, create a local virtual environment:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python3 -m venv .venv
|
|
35
|
+
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
|
|
36
|
+
python -m pip install --upgrade pip
|
|
37
|
+
python -m pip install quantpad-data
|
|
38
|
+
export QUANTPAD_API_KEY=qp_live_...
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Do not install into Homebrew/system Python or use `--break-system-packages`.
|
|
42
|
+
When a project environment already exists, use its package manager or
|
|
43
|
+
`python -m pip` rather than creating a second environment. Keep
|
|
44
|
+
`QUANTPAD_API_KEY` in the local environment—never source code or chat.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import time
|
|
48
|
+
import quantpad_data as qpd
|
|
49
|
+
|
|
50
|
+
end = int(time.time() * 1000)
|
|
51
|
+
bars = qpd.get_bars("ES.FUT", "1m", end - 86_400_000, end)
|
|
52
|
+
|
|
53
|
+
for chunk in qpd.get_ticks(
|
|
54
|
+
"AAPL", "trades", end - 3_600_000, end, columns=["t", "price", "size"]
|
|
55
|
+
):
|
|
56
|
+
print(chunk.head())
|
|
57
|
+
|
|
58
|
+
# 10-level L2 market-by-price order-book depth. This streams chunks;
|
|
59
|
+
# project columns to avoid materializing all 60+ book fields when unnecessary.
|
|
60
|
+
for chunk in qpd.get_mbp10(
|
|
61
|
+
"ES.FUT",
|
|
62
|
+
end - 3_600_000,
|
|
63
|
+
end,
|
|
64
|
+
columns=["t", "bid_px_00", "ask_px_00", "bid_sz_00", "ask_sz_00"],
|
|
65
|
+
):
|
|
66
|
+
print(chunk.head())
|
|
67
|
+
|
|
68
|
+
matches = qpd.get_universe("apple", asset_class="equity")
|
|
69
|
+
coverage = qpd.get_coverage("AAPL")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`QuantPadClient` (also available as `Client`) accepts `api_key=`, `base_url=`,
|
|
73
|
+
and `max_retries=`. The default
|
|
74
|
+
client honors `Retry-After` and uses exponential backoff for transient failures.
|
|
75
|
+
QuantPad notebooks remain compatible through `QUANTPAD_NOTEBOOK_DATA_TOKEN`.
|
|
76
|
+
|
|
77
|
+
Bars preserve the notebook helper's OHLCV aliases and smart futures
|
|
78
|
+
back-adjustment default. Tick timestamps are epoch nanoseconds. Install
|
|
79
|
+
`quantpad-data[rth]` for XNYS regular-session helpers.
|
|
80
|
+
|
|
81
|
+
`mbp-10` is true L2 depth with bid/ask price, size, and order-count fields at
|
|
82
|
+
levels 0-9. It is available for supported CME futures, individual CME
|
|
83
|
+
options-on-futures contracts, and US equities, subject to `get_coverage()` and
|
|
84
|
+
the plan lookback window (currently 30 days).
|
|
85
|
+
|
|
86
|
+
This SDK intentionally exposes only bars, ticks, universe, coverage, and
|
|
87
|
+
symbology. It does not proxy FRED or SEC EDGAR.
|
|
88
|
+
|
|
89
|
+
## Explicit addressing: `qpd.v2`
|
|
90
|
+
|
|
91
|
+
The helpers above infer which dataset a symbol lives in and what kind of
|
|
92
|
+
symbol it is. That is convenient until it is wrong, and when it is wrong it
|
|
93
|
+
fails quietly — asking for a specific futures contract returns an empty frame
|
|
94
|
+
that looks exactly like a coverage gap.
|
|
95
|
+
|
|
96
|
+
`qpd.v2.Historical` removes the inference. It takes the same arguments as
|
|
97
|
+
`databento.Historical`, so code written against Databento ports over by
|
|
98
|
+
swapping the client:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import quantpad_data as qpd
|
|
102
|
+
|
|
103
|
+
client = qpd.v2.Historical()
|
|
104
|
+
|
|
105
|
+
bars = client.timeseries.get_range(
|
|
106
|
+
dataset="GLBX.MDP3", # required — never guessed
|
|
107
|
+
symbols="ESZ6", # a specific contract month
|
|
108
|
+
stype_in="raw_symbol", # raw_symbol | instrument_id | parent | continuous
|
|
109
|
+
schema="ohlcv-1m",
|
|
110
|
+
start="2026-01-05",
|
|
111
|
+
end="2026-01-06",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# What actually answered, so you can confirm it was the contract you meant.
|
|
115
|
+
print(bars.attrs["quantpad"])
|
|
116
|
+
# {'dataset': 'GLBX.MDP3', 'stype_in': 'raw_symbol',
|
|
117
|
+
# 'resolved': 'ESZ6=651968', 'clamped': False, ...}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Every response reports the address it used, and a window that had to be
|
|
121
|
+
shortened comes back with `clamped=True` and a warning rather than quietly
|
|
122
|
+
covering a different period than you asked for.
|
|
123
|
+
|
|
124
|
+
### Large ranges
|
|
125
|
+
|
|
126
|
+
`get_range` buffers a single response. When that would be too large, it falls
|
|
127
|
+
back automatically to reading parquet straight from object storage — the bytes
|
|
128
|
+
never traverse the data service. Force either behaviour with
|
|
129
|
+
`mode="interactive"` or `mode="bulk"`, or stream day by day:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
for day in client.timeseries.iter_range(
|
|
133
|
+
dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
|
|
134
|
+
start="2026-01-05", end="2026-02-05",
|
|
135
|
+
):
|
|
136
|
+
process(day)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
For ranges too large even for that, submit a batch job, then hydrate it into
|
|
140
|
+
the cache so ordinary `get_range` calls serve it from storage:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
job = client.batch.submit(
|
|
144
|
+
dataset="GLBX.MDP3", symbols="ESZ6", schema="mbo",
|
|
145
|
+
start="2026-01-05", end="2026-02-05", hydrate=True,
|
|
146
|
+
)
|
|
147
|
+
client.batch.hydrate(job["job_id"])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Other namespaces
|
|
151
|
+
|
|
152
|
+
`client.symbology.resolve(...)`, the `client.metadata.*` mirrors
|
|
153
|
+
(`list_datasets`, `list_schemas`, `list_fields`, `list_publishers`,
|
|
154
|
+
`get_dataset_range`, `get_dataset_condition`, `get_record_count`), and
|
|
155
|
+
`client.options.chain(...)` for enumerating strikes and expiries without
|
|
156
|
+
downloading a day's entire `definition` schema.
|
|
157
|
+
|
|
158
|
+
There is no cost or unit-price surface. QuantPad data is included in the plan
|
|
159
|
+
rather than metered per request.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# QuantPad Data Python SDK
|
|
2
|
+
|
|
3
|
+
Install into the project's existing environment/package manager:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv add quantpad-data # uv project
|
|
7
|
+
poetry add quantpad-data # Poetry project
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
For a plain Python project, create a local virtual environment:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
python3 -m venv .venv
|
|
14
|
+
source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1
|
|
15
|
+
python -m pip install --upgrade pip
|
|
16
|
+
python -m pip install quantpad-data
|
|
17
|
+
export QUANTPAD_API_KEY=qp_live_...
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Do not install into Homebrew/system Python or use `--break-system-packages`.
|
|
21
|
+
When a project environment already exists, use its package manager or
|
|
22
|
+
`python -m pip` rather than creating a second environment. Keep
|
|
23
|
+
`QUANTPAD_API_KEY` in the local environment—never source code or chat.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import time
|
|
27
|
+
import quantpad_data as qpd
|
|
28
|
+
|
|
29
|
+
end = int(time.time() * 1000)
|
|
30
|
+
bars = qpd.get_bars("ES.FUT", "1m", end - 86_400_000, end)
|
|
31
|
+
|
|
32
|
+
for chunk in qpd.get_ticks(
|
|
33
|
+
"AAPL", "trades", end - 3_600_000, end, columns=["t", "price", "size"]
|
|
34
|
+
):
|
|
35
|
+
print(chunk.head())
|
|
36
|
+
|
|
37
|
+
# 10-level L2 market-by-price order-book depth. This streams chunks;
|
|
38
|
+
# project columns to avoid materializing all 60+ book fields when unnecessary.
|
|
39
|
+
for chunk in qpd.get_mbp10(
|
|
40
|
+
"ES.FUT",
|
|
41
|
+
end - 3_600_000,
|
|
42
|
+
end,
|
|
43
|
+
columns=["t", "bid_px_00", "ask_px_00", "bid_sz_00", "ask_sz_00"],
|
|
44
|
+
):
|
|
45
|
+
print(chunk.head())
|
|
46
|
+
|
|
47
|
+
matches = qpd.get_universe("apple", asset_class="equity")
|
|
48
|
+
coverage = qpd.get_coverage("AAPL")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`QuantPadClient` (also available as `Client`) accepts `api_key=`, `base_url=`,
|
|
52
|
+
and `max_retries=`. The default
|
|
53
|
+
client honors `Retry-After` and uses exponential backoff for transient failures.
|
|
54
|
+
QuantPad notebooks remain compatible through `QUANTPAD_NOTEBOOK_DATA_TOKEN`.
|
|
55
|
+
|
|
56
|
+
Bars preserve the notebook helper's OHLCV aliases and smart futures
|
|
57
|
+
back-adjustment default. Tick timestamps are epoch nanoseconds. Install
|
|
58
|
+
`quantpad-data[rth]` for XNYS regular-session helpers.
|
|
59
|
+
|
|
60
|
+
`mbp-10` is true L2 depth with bid/ask price, size, and order-count fields at
|
|
61
|
+
levels 0-9. It is available for supported CME futures, individual CME
|
|
62
|
+
options-on-futures contracts, and US equities, subject to `get_coverage()` and
|
|
63
|
+
the plan lookback window (currently 30 days).
|
|
64
|
+
|
|
65
|
+
This SDK intentionally exposes only bars, ticks, universe, coverage, and
|
|
66
|
+
symbology. It does not proxy FRED or SEC EDGAR.
|
|
67
|
+
|
|
68
|
+
## Explicit addressing: `qpd.v2`
|
|
69
|
+
|
|
70
|
+
The helpers above infer which dataset a symbol lives in and what kind of
|
|
71
|
+
symbol it is. That is convenient until it is wrong, and when it is wrong it
|
|
72
|
+
fails quietly — asking for a specific futures contract returns an empty frame
|
|
73
|
+
that looks exactly like a coverage gap.
|
|
74
|
+
|
|
75
|
+
`qpd.v2.Historical` removes the inference. It takes the same arguments as
|
|
76
|
+
`databento.Historical`, so code written against Databento ports over by
|
|
77
|
+
swapping the client:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import quantpad_data as qpd
|
|
81
|
+
|
|
82
|
+
client = qpd.v2.Historical()
|
|
83
|
+
|
|
84
|
+
bars = client.timeseries.get_range(
|
|
85
|
+
dataset="GLBX.MDP3", # required — never guessed
|
|
86
|
+
symbols="ESZ6", # a specific contract month
|
|
87
|
+
stype_in="raw_symbol", # raw_symbol | instrument_id | parent | continuous
|
|
88
|
+
schema="ohlcv-1m",
|
|
89
|
+
start="2026-01-05",
|
|
90
|
+
end="2026-01-06",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# What actually answered, so you can confirm it was the contract you meant.
|
|
94
|
+
print(bars.attrs["quantpad"])
|
|
95
|
+
# {'dataset': 'GLBX.MDP3', 'stype_in': 'raw_symbol',
|
|
96
|
+
# 'resolved': 'ESZ6=651968', 'clamped': False, ...}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Every response reports the address it used, and a window that had to be
|
|
100
|
+
shortened comes back with `clamped=True` and a warning rather than quietly
|
|
101
|
+
covering a different period than you asked for.
|
|
102
|
+
|
|
103
|
+
### Large ranges
|
|
104
|
+
|
|
105
|
+
`get_range` buffers a single response. When that would be too large, it falls
|
|
106
|
+
back automatically to reading parquet straight from object storage — the bytes
|
|
107
|
+
never traverse the data service. Force either behaviour with
|
|
108
|
+
`mode="interactive"` or `mode="bulk"`, or stream day by day:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
for day in client.timeseries.iter_range(
|
|
112
|
+
dataset="GLBX.MDP3", symbols="ESZ6", schema="mbp-10",
|
|
113
|
+
start="2026-01-05", end="2026-02-05",
|
|
114
|
+
):
|
|
115
|
+
process(day)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
For ranges too large even for that, submit a batch job, then hydrate it into
|
|
119
|
+
the cache so ordinary `get_range` calls serve it from storage:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
job = client.batch.submit(
|
|
123
|
+
dataset="GLBX.MDP3", symbols="ESZ6", schema="mbo",
|
|
124
|
+
start="2026-01-05", end="2026-02-05", hydrate=True,
|
|
125
|
+
)
|
|
126
|
+
client.batch.hydrate(job["job_id"])
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Other namespaces
|
|
130
|
+
|
|
131
|
+
`client.symbology.resolve(...)`, the `client.metadata.*` mirrors
|
|
132
|
+
(`list_datasets`, `list_schemas`, `list_fields`, `list_publishers`,
|
|
133
|
+
`get_dataset_range`, `get_dataset_condition`, `get_record_count`), and
|
|
134
|
+
`client.options.chain(...)` for enumerating strikes and expiries without
|
|
135
|
+
downloading a day's entire `definition` schema.
|
|
136
|
+
|
|
137
|
+
There is no cost or unit-price surface. QuantPad data is included in the plan
|
|
138
|
+
rather than metered per request.
|
|
@@ -194,6 +194,14 @@ def iter_l1_mbp1_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
|
|
|
194
194
|
return iter_mbp1(symbol, start_ms, end_ms, columns=usecols, chunksize=chunksize)
|
|
195
195
|
|
|
196
196
|
|
|
197
|
+
from . import lean # noqa: E402 (submodule: LEAN backtesting data staging)
|
|
198
|
+
from . import v2 # noqa: E402 (submodule: Databento-shaped explicit API)
|
|
199
|
+
|
|
200
|
+
# Exported at the root as well as on ``v2``: the sandbox module reaches it
|
|
201
|
+
# as ``qpd.Historical()``, and one spelling across both surfaces means
|
|
202
|
+
# notebook code moves between them unchanged.
|
|
203
|
+
Historical = v2.Historical
|
|
204
|
+
|
|
197
205
|
__all__ = [
|
|
198
206
|
"Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
|
|
199
207
|
"PermissionDeniedError", "RateLimitError", "QuotaExceededError",
|
|
@@ -202,5 +210,6 @@ __all__ = [
|
|
|
202
210
|
"get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
|
|
203
211
|
"get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
|
|
204
212
|
"get_coverage", "iter_trades", "iter_mbp1", "iter_l1_df_chunks",
|
|
205
|
-
"iter_l1_trades_df_chunks", "iter_l1_mbp1_df_chunks", "
|
|
213
|
+
"iter_l1_trades_df_chunks", "iter_l1_mbp1_df_chunks", "lean", "v2",
|
|
214
|
+
"Historical", "np", "pd", "pa",
|
|
206
215
|
]
|
|
@@ -24,7 +24,7 @@ from .errors import (
|
|
|
24
24
|
ValidationError,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
-
VERSION = "0.
|
|
27
|
+
VERSION = "0.2.0"
|
|
28
28
|
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
29
29
|
TICK_SCHEMAS = (
|
|
30
30
|
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
@@ -88,20 +88,21 @@ class Client:
|
|
|
88
88
|
retry_after = float(response.headers["Retry-After"])
|
|
89
89
|
except (KeyError, TypeError, ValueError):
|
|
90
90
|
pass
|
|
91
|
-
|
|
91
|
+
status = response.status_code
|
|
92
|
+
if status == 400:
|
|
92
93
|
raise ValidationError(message)
|
|
93
|
-
if
|
|
94
|
-
raise AuthenticationError(message)
|
|
95
|
-
if
|
|
96
|
-
raise PermissionDeniedError(message)
|
|
97
|
-
if
|
|
94
|
+
if status == 401:
|
|
95
|
+
raise AuthenticationError(message, status)
|
|
96
|
+
if status == 403:
|
|
97
|
+
raise PermissionDeniedError(message, status)
|
|
98
|
+
if status == 404:
|
|
98
99
|
raise NotFoundError(message)
|
|
99
|
-
if
|
|
100
|
+
if status == 429:
|
|
100
101
|
cls = QuotaExceededError if "quota" in message.lower() else RateLimitError
|
|
101
|
-
raise cls(message, retry_after)
|
|
102
|
-
if
|
|
103
|
-
raise UpstreamError(message)
|
|
104
|
-
raise QuantPadDataError(message)
|
|
102
|
+
raise cls(message, retry_after, status)
|
|
103
|
+
if status >= 500:
|
|
104
|
+
raise UpstreamError(message, status)
|
|
105
|
+
raise QuantPadDataError(message, status)
|
|
105
106
|
|
|
106
107
|
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
107
108
|
kwargs.setdefault("timeout", self.timeout)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Typed QuantPad Data API exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class QuantPadDataError(RuntimeError):
|
|
7
|
+
"""Base error for API and transport failures.
|
|
8
|
+
|
|
9
|
+
``status_code`` is set when the failure came from an HTTP response.
|
|
10
|
+
Callers that want to branch on a specific status — the bulk-delivery
|
|
11
|
+
fallback keys on 413 — can read it without re-parsing the message.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
#: Class-level default so the attribute is always readable, including
|
|
15
|
+
#: on the subclasses that also inherit ValueError and therefore never
|
|
16
|
+
#: run this __init__.
|
|
17
|
+
status_code: int | None = None
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(QuantPadDataError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermissionDeniedError(QuantPadDataError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimitError(QuantPadDataError):
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
message: str,
|
|
36
|
+
retry_after: float | None = None,
|
|
37
|
+
status_code: int | None = None,
|
|
38
|
+
):
|
|
39
|
+
super().__init__(message, status_code)
|
|
40
|
+
self.retry_after = retry_after
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QuotaExceededError(RateLimitError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NotFoundError(ValueError, QuantPadDataError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ValidationError(ValueError, QuantPadDataError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UpstreamError(QuantPadDataError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class QuantPadDataNotReady(QuantPadDataError):
|
|
60
|
+
"""Backward-compatible error retained for older notebooks."""
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Stage QuantConnect LEAN futures data into a local data folder.
|
|
2
|
+
|
|
3
|
+
Flow (run inside the LEAN sandbox before the engine starts):
|
|
4
|
+
|
|
5
|
+
from quantpad_data import lean
|
|
6
|
+
lean.prepare_and_wait(["MNQ.FUT"], "2025-01-06", "2025-01-31", "minute")
|
|
7
|
+
lean.stage_data(["MNQ.FUT"], "2025-01-06", "2025-01-31", "minute",
|
|
8
|
+
data_folder="/home/jovyan/lean-data")
|
|
9
|
+
|
|
10
|
+
``prepare`` asks the data service to convert/cache the range (202 + job);
|
|
11
|
+
``stage_data`` fetches the manifest (authenticated) and downloads each
|
|
12
|
+
artifact directly from R2 via short-lived presigned URLs — no zip bytes flow
|
|
13
|
+
through the data service.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Sequence
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from .client import Client
|
|
26
|
+
from .errors import QuantPadDataError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LeanStageError(QuantPadDataError):
|
|
30
|
+
"""Raised when preparing or staging LEAN data fails."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _client(client: Client | None) -> Client:
|
|
34
|
+
return client if client is not None else Client()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prepare(
|
|
38
|
+
symbols: Sequence[str],
|
|
39
|
+
start: str,
|
|
40
|
+
end: str,
|
|
41
|
+
resolution: str,
|
|
42
|
+
*,
|
|
43
|
+
client: Client | None = None,
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""Kick off conversion; returns the 202 body (``job_id``, ``poll_url``)."""
|
|
46
|
+
response = _client(client)._request(
|
|
47
|
+
"POST",
|
|
48
|
+
"/v1/lean/prepare",
|
|
49
|
+
json={
|
|
50
|
+
"symbols": list(symbols),
|
|
51
|
+
"start": start,
|
|
52
|
+
"end": end,
|
|
53
|
+
"resolution": resolution,
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
return response.json()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def wait_for_job(
|
|
60
|
+
job_id: str,
|
|
61
|
+
*,
|
|
62
|
+
client: Client | None = None,
|
|
63
|
+
poll_seconds: float = 2.0,
|
|
64
|
+
timeout_seconds: float = 1800.0,
|
|
65
|
+
) -> dict:
|
|
66
|
+
"""Poll ``/v1/jobs/{job_id}`` until completed; raises on failure/timeout."""
|
|
67
|
+
bound = _client(client)
|
|
68
|
+
deadline = time.monotonic() + timeout_seconds
|
|
69
|
+
while True:
|
|
70
|
+
status = bound._request("GET", f"/v1/jobs/{job_id}").json()
|
|
71
|
+
if status.get("status") == "completed":
|
|
72
|
+
return status
|
|
73
|
+
if status.get("status") == "failed":
|
|
74
|
+
raise LeanStageError(
|
|
75
|
+
f"lean prepare job {job_id} failed: {status.get('error')}"
|
|
76
|
+
)
|
|
77
|
+
if time.monotonic() > deadline:
|
|
78
|
+
raise LeanStageError(f"lean prepare job {job_id} timed out")
|
|
79
|
+
time.sleep(poll_seconds)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def prepare_and_wait(
|
|
83
|
+
symbols: Sequence[str],
|
|
84
|
+
start: str,
|
|
85
|
+
end: str,
|
|
86
|
+
resolution: str,
|
|
87
|
+
*,
|
|
88
|
+
client: Client | None = None,
|
|
89
|
+
poll_seconds: float = 2.0,
|
|
90
|
+
timeout_seconds: float = 1800.0,
|
|
91
|
+
) -> dict:
|
|
92
|
+
accepted = prepare(symbols, start, end, resolution, client=client)
|
|
93
|
+
return wait_for_job(
|
|
94
|
+
accepted["job_id"],
|
|
95
|
+
client=client,
|
|
96
|
+
poll_seconds=poll_seconds,
|
|
97
|
+
timeout_seconds=timeout_seconds,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_manifest(
|
|
102
|
+
symbols: Sequence[str],
|
|
103
|
+
start: str,
|
|
104
|
+
end: str,
|
|
105
|
+
resolution: str,
|
|
106
|
+
*,
|
|
107
|
+
client: Client | None = None,
|
|
108
|
+
) -> dict:
|
|
109
|
+
response = _client(client)._request(
|
|
110
|
+
"GET",
|
|
111
|
+
"/v1/lean/manifest",
|
|
112
|
+
params={
|
|
113
|
+
"symbols": ",".join(symbols),
|
|
114
|
+
"start": start,
|
|
115
|
+
"end": end,
|
|
116
|
+
"resolution": resolution,
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
return response.json()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def stage_data(
|
|
123
|
+
symbols: Sequence[str],
|
|
124
|
+
start: str,
|
|
125
|
+
end: str,
|
|
126
|
+
resolution: str,
|
|
127
|
+
*,
|
|
128
|
+
data_folder: str | os.PathLike[str],
|
|
129
|
+
client: Client | None = None,
|
|
130
|
+
download_session: requests.Session | None = None,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Download a prepared range into ``data_folder`` laid out for LEAN.
|
|
133
|
+
|
|
134
|
+
Returns the manifest (including ``mapping_modes``, which the run guard
|
|
135
|
+
validates against the algorithm's requested mapping mode before the
|
|
136
|
+
engine starts).
|
|
137
|
+
"""
|
|
138
|
+
manifest = get_manifest(symbols, start, end, resolution, client=client)
|
|
139
|
+
session = download_session if download_session is not None else requests.Session()
|
|
140
|
+
root = Path(data_folder)
|
|
141
|
+
for entry in manifest["files"]:
|
|
142
|
+
relative = Path(entry["path"])
|
|
143
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
144
|
+
raise LeanStageError(f"unsafe manifest path: {entry['path']}")
|
|
145
|
+
target = root / relative
|
|
146
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
response = session.get(entry["url"], timeout=120)
|
|
148
|
+
if response.status_code != 200:
|
|
149
|
+
raise LeanStageError(
|
|
150
|
+
f"download failed ({response.status_code}) for {entry['path']}"
|
|
151
|
+
)
|
|
152
|
+
target.write_bytes(response.content)
|
|
153
|
+
return manifest
|