quantpad-data 0.1.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.1.0/.gitignore +11 -0
- quantpad_data-0.1.0/PKG-INFO +55 -0
- quantpad_data-0.1.0/README.md +34 -0
- quantpad_data-0.1.0/pyproject.toml +35 -0
- quantpad_data-0.1.0/quantpad_data/__init__.py +206 -0
- quantpad_data-0.1.0/quantpad_data/client.py +273 -0
- quantpad_data-0.1.0/quantpad_data/errors.py +41 -0
- quantpad_data-0.1.0/quantpad_data/py.typed +1 -0
- quantpad_data-0.1.0/tests/test_client.py +92 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quantpad-data
|
|
3
|
+
Version: 0.1.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
|
+
```bash
|
|
25
|
+
pip install quantpad-data
|
|
26
|
+
export QUANTPAD_API_KEY=qp_live_...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import time
|
|
31
|
+
import quantpad_data as qpd
|
|
32
|
+
|
|
33
|
+
end = int(time.time() * 1000)
|
|
34
|
+
bars = qpd.get_bars("ES.FUT", "1m", end - 86_400_000, end)
|
|
35
|
+
|
|
36
|
+
for chunk in qpd.get_ticks(
|
|
37
|
+
"AAPL", "trades", end - 3_600_000, end, columns=["t", "price", "size"]
|
|
38
|
+
):
|
|
39
|
+
print(chunk.head())
|
|
40
|
+
|
|
41
|
+
matches = qpd.get_universe("apple", asset_class="equity")
|
|
42
|
+
coverage = qpd.get_coverage("AAPL")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`QuantPadClient` (also available as `Client`) accepts `api_key=`, `base_url=`,
|
|
46
|
+
and `max_retries=`. The default
|
|
47
|
+
client honors `Retry-After` and uses exponential backoff for transient failures.
|
|
48
|
+
QuantPad notebooks remain compatible through `QUANTPAD_NOTEBOOK_DATA_TOKEN`.
|
|
49
|
+
|
|
50
|
+
Bars preserve the notebook helper's OHLCV aliases and smart futures
|
|
51
|
+
back-adjustment default. Tick timestamps are epoch nanoseconds. Install
|
|
52
|
+
`quantpad-data[rth]` for XNYS regular-session helpers.
|
|
53
|
+
|
|
54
|
+
This SDK intentionally exposes only bars, ticks, universe, coverage, and
|
|
55
|
+
symbology. It does not proxy FRED or SEC EDGAR.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# QuantPad Data Python SDK
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install quantpad-data
|
|
5
|
+
export QUANTPAD_API_KEY=qp_live_...
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import time
|
|
10
|
+
import quantpad_data as qpd
|
|
11
|
+
|
|
12
|
+
end = int(time.time() * 1000)
|
|
13
|
+
bars = qpd.get_bars("ES.FUT", "1m", end - 86_400_000, end)
|
|
14
|
+
|
|
15
|
+
for chunk in qpd.get_ticks(
|
|
16
|
+
"AAPL", "trades", end - 3_600_000, end, columns=["t", "price", "size"]
|
|
17
|
+
):
|
|
18
|
+
print(chunk.head())
|
|
19
|
+
|
|
20
|
+
matches = qpd.get_universe("apple", asset_class="equity")
|
|
21
|
+
coverage = qpd.get_coverage("AAPL")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`QuantPadClient` (also available as `Client`) accepts `api_key=`, `base_url=`,
|
|
25
|
+
and `max_retries=`. The default
|
|
26
|
+
client honors `Retry-After` and uses exponential backoff for transient failures.
|
|
27
|
+
QuantPad notebooks remain compatible through `QUANTPAD_NOTEBOOK_DATA_TOKEN`.
|
|
28
|
+
|
|
29
|
+
Bars preserve the notebook helper's OHLCV aliases and smart futures
|
|
30
|
+
back-adjustment default. Tick timestamps are epoch nanoseconds. Install
|
|
31
|
+
`quantpad-data[rth]` for XNYS regular-session helpers.
|
|
32
|
+
|
|
33
|
+
This SDK intentionally exposes only bars, ticks, universe, coverage, and
|
|
34
|
+
symbology. It does not proxy FRED or SEC EDGAR.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quantpad-data"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the QuantPad market-data API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "Proprietary"}
|
|
12
|
+
authors = [{name = "QuantPad"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"pandas>=2.2,<3",
|
|
15
|
+
"pyarrow>=18",
|
|
16
|
+
"requests>=2.32",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
rth = ["exchange-calendars>=4.5"]
|
|
26
|
+
test = ["pytest>=8", "build>=1.2"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Documentation = "https://api.quantpad.ai/external/docs"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["quantpad_data"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""QuantPad market-data SDK and notebook-compatible function API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Iterator, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import pyarrow as pa
|
|
10
|
+
|
|
11
|
+
from .client import Client, ROLL_ADJUST_MODES, TICK_SCHEMAS, USER_AGENT, VERSION
|
|
12
|
+
from .errors import (
|
|
13
|
+
AuthenticationError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
QuantPadDataError,
|
|
17
|
+
QuantPadDataNotReady,
|
|
18
|
+
QuotaExceededError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
UpstreamError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__ = VERSION
|
|
25
|
+
QuantPadClient = Client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client(**kwargs) -> Client:
|
|
29
|
+
return Client(**kwargs)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_bars(symbol, timeframe, start_ms, end_ms, *, max_rows=None, as_df=True,
|
|
33
|
+
roll_adjust=None):
|
|
34
|
+
return _client().get_bars(
|
|
35
|
+
symbol, timeframe, start_ms, end_ms, max_rows=max_rows,
|
|
36
|
+
as_df=as_df, roll_adjust=roll_adjust,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_bars_with_retry(symbol, timeframe, start_ms, end_ms, *, max_rows=None,
|
|
41
|
+
retries=3, sleep_seconds=10, roll_adjust=None):
|
|
42
|
+
# Client retries use Retry-After and exponential jitter. ``sleep_seconds``
|
|
43
|
+
# remains accepted so existing notebooks do not break.
|
|
44
|
+
return Client(max_retries=max(0, int(retries) - 1)).get_bars(
|
|
45
|
+
symbol, timeframe, start_ms, end_ms, max_rows=max_rows,
|
|
46
|
+
roll_adjust=roll_adjust,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_ticks(symbol, schema, start_ms, end_ms, *, columns=None, chunksize=250_000):
|
|
51
|
+
return _client().get_ticks(
|
|
52
|
+
symbol, schema, start_ms, end_ms, columns=columns, chunksize=chunksize
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_trades(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
57
|
+
return get_ticks(
|
|
58
|
+
symbol, "trades", start_ms, end_ms, columns=columns, chunksize=chunk_rows
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_mbp1(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
63
|
+
return get_ticks(
|
|
64
|
+
symbol, "mbp-1", start_ms, end_ms, columns=columns, chunksize=chunk_rows
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_mbp10(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
69
|
+
return get_ticks(
|
|
70
|
+
symbol, "mbp-10", start_ms, end_ms, columns=columns, chunksize=chunk_rows
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def symbology_resolve(dataset=None, symbols=None, stype_in=None, stype_out=None,
|
|
75
|
+
start_date=None, end_date=None):
|
|
76
|
+
return _client().symbology_resolve(
|
|
77
|
+
dataset, symbols, stype_in, stype_out, start_date, end_date
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_universe(query: str, *, limit: int = 20, asset_class: str | None = None):
|
|
82
|
+
return _client().get_universe(query, limit=limit, asset_class=asset_class)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_coverage(symbol: str):
|
|
86
|
+
return _client().get_coverage(symbol)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _iter_rth_windows_ms(start_ms: int, end_ms: int):
|
|
90
|
+
try:
|
|
91
|
+
import exchange_calendars as ec
|
|
92
|
+
except ImportError as exc:
|
|
93
|
+
raise RuntimeError(
|
|
94
|
+
"RTH helpers require: pip install 'quantpad-data[rth]'"
|
|
95
|
+
) from exc
|
|
96
|
+
cal = ec.get_calendar("XNYS")
|
|
97
|
+
start_day = pd.Timestamp(start_ms, unit="ms", tz="UTC").tz_convert(
|
|
98
|
+
"America/New_York"
|
|
99
|
+
).date()
|
|
100
|
+
end_day = pd.Timestamp(end_ms - 1, unit="ms", tz="UTC").tz_convert(
|
|
101
|
+
"America/New_York"
|
|
102
|
+
).date()
|
|
103
|
+
for session in cal.sessions_in_range(pd.Timestamp(start_day), pd.Timestamp(end_day)):
|
|
104
|
+
open_ms = int(cal.session_open(session).timestamp() * 1000)
|
|
105
|
+
close_ms = int(cal.session_close(session).timestamp() * 1000)
|
|
106
|
+
left, right = max(open_ms, int(start_ms)), min(close_ms, int(end_ms))
|
|
107
|
+
if left < right:
|
|
108
|
+
yield left, right
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _stream_ticks_rth(symbol, schema, start_ms, end_ms, *, columns, chunk_rows):
|
|
112
|
+
for open_ms, close_ms in _iter_rth_windows_ms(start_ms, end_ms):
|
|
113
|
+
for frame in get_ticks(
|
|
114
|
+
symbol, schema, open_ms, close_ms,
|
|
115
|
+
columns=columns, chunksize=chunk_rows,
|
|
116
|
+
):
|
|
117
|
+
if "t" in frame:
|
|
118
|
+
frame = frame.loc[
|
|
119
|
+
(frame["t"] >= open_ms * 1_000_000)
|
|
120
|
+
& (frame["t"] < close_ms * 1_000_000)
|
|
121
|
+
]
|
|
122
|
+
if not frame.empty:
|
|
123
|
+
yield frame
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_trades_rth(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
127
|
+
return _stream_ticks_rth(
|
|
128
|
+
symbol, "trades", start_ms, end_ms, columns=columns, chunk_rows=chunk_rows
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_mbp1_rth(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
133
|
+
return _stream_ticks_rth(
|
|
134
|
+
symbol, "mbp-1", start_ms, end_ms, columns=columns, chunk_rows=chunk_rows
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_mbp10_rth(symbol, start_ms, end_ms, *, columns=None, chunk_rows=250_000):
|
|
139
|
+
return _stream_ticks_rth(
|
|
140
|
+
symbol, "mbp-10", start_ms, end_ms, columns=columns, chunk_rows=chunk_rows
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_spread_1m_rth(symbol, start_ms, end_ms, *, chunk_rows=250_000):
|
|
145
|
+
pieces = []
|
|
146
|
+
for frame in get_mbp1_rth(
|
|
147
|
+
symbol, start_ms, end_ms,
|
|
148
|
+
columns=["t", "bid_px", "ask_px"], chunk_rows=chunk_rows,
|
|
149
|
+
):
|
|
150
|
+
spread = frame["ask_px"] - frame["bid_px"]
|
|
151
|
+
valid = spread.notna() & (spread >= 0)
|
|
152
|
+
if valid.any():
|
|
153
|
+
index = pd.to_datetime(frame.loc[valid, "t"], unit="ns", utc=True)
|
|
154
|
+
pieces.append(pd.Series(spread.loc[valid].to_numpy(), index=index))
|
|
155
|
+
if not pieces:
|
|
156
|
+
return pd.DataFrame(
|
|
157
|
+
{"spread": pd.Series(dtype="float64")},
|
|
158
|
+
index=pd.DatetimeIndex([], name="t", tz="UTC"),
|
|
159
|
+
)
|
|
160
|
+
result = pd.concat(pieces).resample("1min").mean().to_frame("spread")
|
|
161
|
+
result.index.name = "t"
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def iter_trades(symbol, start_ms, end_ms, *, columns=None, chunksize=250_000):
|
|
166
|
+
return get_trades(
|
|
167
|
+
symbol, start_ms, end_ms, columns=columns, chunk_rows=chunksize
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def iter_mbp1(symbol, start_ms, end_ms, *, columns=None, chunksize=250_000):
|
|
172
|
+
return get_mbp1(symbol, start_ms, end_ms, columns=columns, chunk_rows=chunksize)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def iter_l1_df_chunks(symbol, schema="trades", start_ms=0, end_ms=0,
|
|
176
|
+
chunksize=250_000, usecols: Optional[list[str]] = None,
|
|
177
|
+
time_col="ts_event", set_datetime_index=True, channel=None,
|
|
178
|
+
chunk_size=None):
|
|
179
|
+
return get_ticks(
|
|
180
|
+
symbol, channel or schema, start_ms, end_ms, columns=usecols,
|
|
181
|
+
chunksize=chunk_size or chunksize,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def iter_l1_trades_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
|
|
186
|
+
usecols=None):
|
|
187
|
+
return iter_trades(
|
|
188
|
+
symbol, start_ms, end_ms, columns=usecols, chunksize=chunksize
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def iter_l1_mbp1_df_chunks(symbol, start_ms, end_ms, chunksize=250_000,
|
|
193
|
+
usecols=None):
|
|
194
|
+
return iter_mbp1(symbol, start_ms, end_ms, columns=usecols, chunksize=chunksize)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = [
|
|
198
|
+
"Client", "QuantPadClient", "QuantPadDataError", "QuantPadDataNotReady", "AuthenticationError",
|
|
199
|
+
"PermissionDeniedError", "RateLimitError", "QuotaExceededError",
|
|
200
|
+
"NotFoundError", "ValidationError", "UpstreamError", "TICK_SCHEMAS",
|
|
201
|
+
"ROLL_ADJUST_MODES", "get_bars", "get_bars_with_retry", "get_ticks",
|
|
202
|
+
"get_trades", "get_mbp1", "get_mbp10", "get_trades_rth", "get_mbp1_rth",
|
|
203
|
+
"get_mbp10_rth", "get_spread_1m_rth", "symbology_resolve", "get_universe",
|
|
204
|
+
"get_coverage", "iter_trades", "iter_mbp1", "iter_l1_df_chunks",
|
|
205
|
+
"iter_l1_trades_df_chunks", "iter_l1_mbp1_df_chunks", "np", "pd", "pa",
|
|
206
|
+
]
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Synchronous QuantPad Data API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from datetime import date
|
|
10
|
+
from typing import Iterator, Sequence
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import pyarrow.ipc as ipc
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from .errors import (
|
|
17
|
+
AuthenticationError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PermissionDeniedError,
|
|
20
|
+
QuantPadDataError,
|
|
21
|
+
QuotaExceededError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
UpstreamError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
VERSION = "0.1.0"
|
|
28
|
+
USER_AGENT = f"quantpad-data-python/{VERSION}"
|
|
29
|
+
TICK_SCHEMAS = (
|
|
30
|
+
"trades", "mbp-1", "mbp-10", "cmbp-1", "tcbbo", "cbbo-1s",
|
|
31
|
+
"cbbo-1m", "definition", "statistics", "status",
|
|
32
|
+
)
|
|
33
|
+
ROLL_ADJUST_MODES = ("none", "back", "ratio")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Client:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_key: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
token: str | None = None,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
timeout: float = 120,
|
|
44
|
+
max_retries: int = 3,
|
|
45
|
+
session: requests.Session | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.api_key = (api_key or os.getenv("QUANTPAD_API_KEY") or "").strip() or None
|
|
48
|
+
self.token = (
|
|
49
|
+
token or os.getenv("QUANTPAD_NOTEBOOK_DATA_TOKEN") or ""
|
|
50
|
+
).strip() or None
|
|
51
|
+
self.base_url = (
|
|
52
|
+
base_url
|
|
53
|
+
or os.getenv("QUANTPAD_API_BASE")
|
|
54
|
+
or os.getenv("QUANTPAD_NOTEBOOK_DATA_API_BASE")
|
|
55
|
+
or "https://api.quantpad.ai"
|
|
56
|
+
).rstrip("/")
|
|
57
|
+
self.timeout = float(timeout)
|
|
58
|
+
self.max_retries = max(0, int(max_retries))
|
|
59
|
+
self.session = session or requests.Session()
|
|
60
|
+
self.session.headers.update({"User-Agent": USER_AGENT})
|
|
61
|
+
|
|
62
|
+
def _headers(self) -> dict[str, str]:
|
|
63
|
+
if self.api_key:
|
|
64
|
+
return {"X-API-Key": self.api_key}
|
|
65
|
+
if self.token:
|
|
66
|
+
return {"Authorization": f"Bearer {self.token}"}
|
|
67
|
+
raise AuthenticationError(
|
|
68
|
+
"Set QUANTPAD_API_KEY, or run inside QuantPad with "
|
|
69
|
+
"QUANTPAD_NOTEBOOK_DATA_TOKEN."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _message(response: requests.Response) -> str:
|
|
74
|
+
try:
|
|
75
|
+
body = response.json()
|
|
76
|
+
if isinstance(body, dict):
|
|
77
|
+
return str(body.get("error") or body.get("detail") or body)
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
return (response.text or f"HTTP {response.status_code}").strip()
|
|
81
|
+
|
|
82
|
+
def _raise_for_status(self, response: requests.Response) -> None:
|
|
83
|
+
if response.ok:
|
|
84
|
+
return
|
|
85
|
+
message = self._message(response)
|
|
86
|
+
retry_after: float | None = None
|
|
87
|
+
try:
|
|
88
|
+
retry_after = float(response.headers["Retry-After"])
|
|
89
|
+
except (KeyError, TypeError, ValueError):
|
|
90
|
+
pass
|
|
91
|
+
if response.status_code == 400:
|
|
92
|
+
raise ValidationError(message)
|
|
93
|
+
if response.status_code == 401:
|
|
94
|
+
raise AuthenticationError(message)
|
|
95
|
+
if response.status_code == 403:
|
|
96
|
+
raise PermissionDeniedError(message)
|
|
97
|
+
if response.status_code == 404:
|
|
98
|
+
raise NotFoundError(message)
|
|
99
|
+
if response.status_code == 429:
|
|
100
|
+
cls = QuotaExceededError if "quota" in message.lower() else RateLimitError
|
|
101
|
+
raise cls(message, retry_after)
|
|
102
|
+
if response.status_code >= 500:
|
|
103
|
+
raise UpstreamError(message)
|
|
104
|
+
raise QuantPadDataError(message)
|
|
105
|
+
|
|
106
|
+
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
107
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
108
|
+
headers = {**self._headers(), **kwargs.pop("headers", {})}
|
|
109
|
+
last_error: Exception | None = None
|
|
110
|
+
for attempt in range(self.max_retries + 1):
|
|
111
|
+
response: requests.Response | None = None
|
|
112
|
+
try:
|
|
113
|
+
response = self.session.request(
|
|
114
|
+
method, f"{self.base_url}{path}", headers=headers, **kwargs
|
|
115
|
+
)
|
|
116
|
+
self._raise_for_status(response)
|
|
117
|
+
return response
|
|
118
|
+
except RateLimitError as exc:
|
|
119
|
+
if response is not None:
|
|
120
|
+
response.close()
|
|
121
|
+
last_error = exc
|
|
122
|
+
if attempt >= self.max_retries:
|
|
123
|
+
raise
|
|
124
|
+
delay = exc.retry_after
|
|
125
|
+
except (requests.ConnectionError, requests.Timeout, UpstreamError) as exc:
|
|
126
|
+
if response is not None:
|
|
127
|
+
response.close()
|
|
128
|
+
last_error = exc
|
|
129
|
+
if attempt >= self.max_retries:
|
|
130
|
+
raise
|
|
131
|
+
delay = None
|
|
132
|
+
base_delay = delay if delay is not None else min(8.0, 0.5 * (2**attempt))
|
|
133
|
+
time.sleep(max(0.0, base_delay) + random.uniform(0, 0.25))
|
|
134
|
+
raise QuantPadDataError(str(last_error or "Request failed"))
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _roll_adjust(symbol: str, value: str | None) -> str:
|
|
138
|
+
if value is None:
|
|
139
|
+
return "back" if symbol.strip().upper().endswith(".FUT") else "none"
|
|
140
|
+
mode = str(value).strip().lower()
|
|
141
|
+
if mode not in ROLL_ADJUST_MODES:
|
|
142
|
+
raise ValidationError(f"roll_adjust must be one of {ROLL_ADJUST_MODES}")
|
|
143
|
+
return mode
|
|
144
|
+
|
|
145
|
+
def get_bars(
|
|
146
|
+
self,
|
|
147
|
+
symbol: str,
|
|
148
|
+
timeframe: str,
|
|
149
|
+
start_ms: int,
|
|
150
|
+
end_ms: int,
|
|
151
|
+
*,
|
|
152
|
+
max_rows: int | None = None,
|
|
153
|
+
as_df: bool = True,
|
|
154
|
+
roll_adjust: str | None = None,
|
|
155
|
+
) -> pd.DataFrame | list[dict]:
|
|
156
|
+
if int(end_ms) <= int(start_ms):
|
|
157
|
+
raise ValidationError("end_ms must be > start_ms")
|
|
158
|
+
params = {
|
|
159
|
+
"symbol": symbol.strip().upper(),
|
|
160
|
+
"timeframe": timeframe.strip().lower(),
|
|
161
|
+
"start": int(start_ms),
|
|
162
|
+
"end": int(end_ms),
|
|
163
|
+
"format": "arrow",
|
|
164
|
+
"roll_adjust": self._roll_adjust(symbol, roll_adjust),
|
|
165
|
+
}
|
|
166
|
+
response = self._request("GET", "/v1/bars", params=params, stream=True)
|
|
167
|
+
try:
|
|
168
|
+
table = ipc.open_stream(response.content).read_all()
|
|
169
|
+
frame = table.to_pandas()
|
|
170
|
+
except Exception:
|
|
171
|
+
response.close()
|
|
172
|
+
params["format"] = "csv"
|
|
173
|
+
fallback = self._request("GET", "/v1/bars", params=params, stream=True)
|
|
174
|
+
try:
|
|
175
|
+
frame = pd.read_csv(io.StringIO(fallback.text))
|
|
176
|
+
finally:
|
|
177
|
+
fallback.close()
|
|
178
|
+
finally:
|
|
179
|
+
response.close()
|
|
180
|
+
if "t" in frame.columns:
|
|
181
|
+
frame.index = pd.to_datetime(frame.pop("t"), unit="ms", utc=True)
|
|
182
|
+
frame.index.name = "t"
|
|
183
|
+
for short, long_name in (
|
|
184
|
+
("o", "open"), ("h", "high"), ("l", "low"),
|
|
185
|
+
("c", "close"), ("v", "volume"),
|
|
186
|
+
):
|
|
187
|
+
if short in frame.columns and long_name not in frame.columns:
|
|
188
|
+
frame[long_name] = frame[short]
|
|
189
|
+
if max_rows is not None:
|
|
190
|
+
frame = frame.head(int(max_rows))
|
|
191
|
+
return frame if as_df else frame.reset_index().to_dict("records")
|
|
192
|
+
|
|
193
|
+
def get_ticks(
|
|
194
|
+
self,
|
|
195
|
+
symbol: str,
|
|
196
|
+
schema: str,
|
|
197
|
+
start_ms: int,
|
|
198
|
+
end_ms: int,
|
|
199
|
+
*,
|
|
200
|
+
columns: Sequence[str] | None = None,
|
|
201
|
+
chunksize: int = 250_000,
|
|
202
|
+
) -> Iterator[pd.DataFrame]:
|
|
203
|
+
schema = schema.strip().lower()
|
|
204
|
+
if schema not in TICK_SCHEMAS:
|
|
205
|
+
raise ValidationError(f"schema must be one of {TICK_SCHEMAS}")
|
|
206
|
+
if chunksize <= 0 or int(end_ms) <= int(start_ms):
|
|
207
|
+
raise ValidationError("chunksize must be positive and end_ms > start_ms")
|
|
208
|
+
params: dict[str, str | int] = {
|
|
209
|
+
"symbol": symbol.strip().upper(),
|
|
210
|
+
"schema": schema,
|
|
211
|
+
"start": int(start_ms),
|
|
212
|
+
"end": int(end_ms),
|
|
213
|
+
}
|
|
214
|
+
if columns:
|
|
215
|
+
params["columns"] = ",".join(str(value).strip() for value in columns)
|
|
216
|
+
response = self._request(
|
|
217
|
+
"GET", "/v1/ticks", params=params, timeout=(30, 1800), stream=True
|
|
218
|
+
)
|
|
219
|
+
response.raw.decode_content = True
|
|
220
|
+
buffered: list[pd.DataFrame] = []
|
|
221
|
+
buffered_rows = 0
|
|
222
|
+
try:
|
|
223
|
+
for batch in ipc.open_stream(response.raw):
|
|
224
|
+
if not batch.num_rows:
|
|
225
|
+
continue
|
|
226
|
+
buffered.append(batch.to_pandas(split_blocks=True, self_destruct=True))
|
|
227
|
+
buffered_rows += batch.num_rows
|
|
228
|
+
while buffered_rows >= chunksize:
|
|
229
|
+
combined = pd.concat(buffered, ignore_index=True)
|
|
230
|
+
yield combined.iloc[:chunksize].copy()
|
|
231
|
+
remainder = combined.iloc[chunksize:]
|
|
232
|
+
buffered = [remainder] if len(remainder) else []
|
|
233
|
+
buffered_rows = len(remainder)
|
|
234
|
+
if buffered_rows:
|
|
235
|
+
yield pd.concat(buffered, ignore_index=True)
|
|
236
|
+
finally:
|
|
237
|
+
response.close()
|
|
238
|
+
|
|
239
|
+
def symbology_resolve(
|
|
240
|
+
self,
|
|
241
|
+
dataset=None,
|
|
242
|
+
symbols=None,
|
|
243
|
+
stype_in=None,
|
|
244
|
+
stype_out=None,
|
|
245
|
+
start_date=None,
|
|
246
|
+
end_date=None,
|
|
247
|
+
) -> dict:
|
|
248
|
+
if symbols is None or stype_out is None or start_date is None:
|
|
249
|
+
raise ValidationError("symbols, stype_out, and start_date are required")
|
|
250
|
+
if isinstance(start_date, date):
|
|
251
|
+
start_date = start_date.isoformat()
|
|
252
|
+
if isinstance(end_date, date):
|
|
253
|
+
end_date = end_date.isoformat()
|
|
254
|
+
payload = {
|
|
255
|
+
"dataset": str(dataset) if dataset is not None else None,
|
|
256
|
+
"symbols": symbols if isinstance(symbols, (str, int)) else list(symbols),
|
|
257
|
+
"stype_in": str(stype_in) if stype_in is not None else None,
|
|
258
|
+
"stype_out": str(stype_out),
|
|
259
|
+
"start_date": start_date,
|
|
260
|
+
"end_date": end_date,
|
|
261
|
+
}
|
|
262
|
+
return self._request("POST", "/v1/symbology/resolve", json=payload).json()
|
|
263
|
+
|
|
264
|
+
def get_universe(
|
|
265
|
+
self, query: str, *, limit: int = 20, asset_class: str | None = None
|
|
266
|
+
) -> dict:
|
|
267
|
+
params = {"q": query, "limit": int(limit)}
|
|
268
|
+
if asset_class:
|
|
269
|
+
params["asset_class"] = asset_class
|
|
270
|
+
return self._request("GET", "/v1/universe", params=params).json()
|
|
271
|
+
|
|
272
|
+
def get_coverage(self, symbol: str) -> dict:
|
|
273
|
+
return self._request("GET", "/v1/coverage", params={"symbol": symbol}).json()
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
|
|
10
|
+
class AuthenticationError(QuantPadDataError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PermissionDeniedError(QuantPadDataError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimitError(QuantPadDataError):
|
|
19
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class QuotaExceededError(RateLimitError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(ValueError, QuantPadDataError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationError(ValueError, QuantPadDataError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UpstreamError(QuantPadDataError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class QuantPadDataNotReady(QuantPadDataError):
|
|
41
|
+
"""Backward-compatible error retained for older notebooks."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import pyarrow as pa
|
|
7
|
+
import pyarrow.ipc as ipc
|
|
8
|
+
import pytest
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
import quantpad_data
|
|
12
|
+
from quantpad_data.client import Client, USER_AGENT
|
|
13
|
+
from quantpad_data.errors import RateLimitError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeSession:
|
|
17
|
+
def __init__(self, responses):
|
|
18
|
+
self.headers = {}
|
|
19
|
+
self.responses = list(responses)
|
|
20
|
+
self.calls = []
|
|
21
|
+
|
|
22
|
+
def request(self, method, url, **kwargs):
|
|
23
|
+
self.calls.append((method, url, kwargs))
|
|
24
|
+
return self.responses.pop(0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def response(status=200, body=b"", headers=None):
|
|
28
|
+
value = requests.Response()
|
|
29
|
+
value.status_code = status
|
|
30
|
+
value._content = body
|
|
31
|
+
value.headers.update(headers or {})
|
|
32
|
+
value.raw = io.BytesIO(body)
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def arrow_bytes(table):
|
|
37
|
+
sink = io.BytesIO()
|
|
38
|
+
with ipc.new_stream(sink, table.schema) as writer:
|
|
39
|
+
writer.write_table(table)
|
|
40
|
+
return sink.getvalue()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_bars_uses_api_key_and_preserves_aliases():
|
|
44
|
+
table = pa.table(
|
|
45
|
+
{"t": [1_700_000_000_000], "o": [1.0], "h": [2.0], "l": [0.5],
|
|
46
|
+
"c": [1.5], "v": [10.0]}
|
|
47
|
+
)
|
|
48
|
+
session = FakeSession([response(body=arrow_bytes(table))])
|
|
49
|
+
client = Client(api_key="secret", session=session, max_retries=0)
|
|
50
|
+
|
|
51
|
+
frame = client.get_bars("ES.FUT", "1m", 1, 2)
|
|
52
|
+
|
|
53
|
+
assert isinstance(frame.index, pd.DatetimeIndex)
|
|
54
|
+
assert frame.loc[frame.index[0], "open"] == 1.0
|
|
55
|
+
assert session.calls[0][2]["headers"]["X-API-Key"] == "secret"
|
|
56
|
+
assert session.headers["User-Agent"] == USER_AGENT
|
|
57
|
+
assert session.calls[0][2]["params"]["roll_adjust"] == "back"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_rate_limit_exposes_retry_after():
|
|
61
|
+
session = FakeSession(
|
|
62
|
+
[response(429, b'{"detail":"rate limit"}', {"Retry-After": "7"})]
|
|
63
|
+
)
|
|
64
|
+
client = Client(api_key="secret", session=session, max_retries=0)
|
|
65
|
+
with pytest.raises(RateLimitError) as caught:
|
|
66
|
+
client.get_coverage("AAPL")
|
|
67
|
+
assert caught.value.retry_after == 7
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_universe_and_coverage_paths():
|
|
71
|
+
session = FakeSession(
|
|
72
|
+
[response(body=b'{"results":[]}'), response(body=b'{"symbol":"AAPL"}')]
|
|
73
|
+
)
|
|
74
|
+
client = Client(api_key="secret", session=session)
|
|
75
|
+
assert client.get_universe("apple") == {"results": []}
|
|
76
|
+
assert client.get_coverage("AAPL")["symbol"] == "AAPL"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_notebook_aliases_preserve_tick_arguments(monkeypatch):
|
|
80
|
+
captured = {}
|
|
81
|
+
|
|
82
|
+
class Stub:
|
|
83
|
+
def get_ticks(self, *args, **kwargs):
|
|
84
|
+
captured.update(args=args, kwargs=kwargs)
|
|
85
|
+
return iter(())
|
|
86
|
+
|
|
87
|
+
monkeypatch.setattr(quantpad_data, "_client", lambda **kwargs: Stub())
|
|
88
|
+
list(quantpad_data.iter_l1_mbp1_df_chunks(
|
|
89
|
+
"ES.FUT", 1, 2, chunksize=10, usecols=["t", "bid_px"]
|
|
90
|
+
))
|
|
91
|
+
assert captured["args"][:2] == ("ES.FUT", "mbp-1")
|
|
92
|
+
assert captured["kwargs"] == {"columns": ["t", "bid_px"], "chunksize": 10}
|