quantpad-data 0.2.0__tar.gz → 0.2.1__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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .env
5
+ .env.local
6
+ .venv/
7
+ venv/
8
+ *.egg-info/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+
13
+ # Throwaway investigation probes. Named so they sort together and are
14
+ # obviously not part of the service; there are usually a lot of them
15
+ # mid-investigation and none of them should ever be committed.
16
+ _qp_*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quantpad-data
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Official Python client for the QuantPad market-data API
5
5
  Project-URL: Documentation, https://api.quantpad.ai/external/docs
6
6
  Author: QuantPad
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quantpad-data"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Official Python client for the QuantPad market-data API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,7 +24,7 @@ from .errors import (
24
24
  ValidationError,
25
25
  )
26
26
 
27
- VERSION = "0.2.0"
27
+ VERSION = "0.2.1"
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",
@@ -32,6 +32,28 @@ TICK_SCHEMAS = (
32
32
  )
33
33
  ROLL_ADJUST_MODES = ("none", "back", "ratio")
34
34
 
35
+ #: ``(connect, read)`` for ``/v1/bars``, matching what ``/v1/ticks`` has
36
+ #: always used.
37
+ #:
38
+ #: The default 120s read budget is wrong for this route and was wrong in
39
+ #: the direction that hurts most. ``/v1/bars`` does not stream: the
40
+ #: server materialises the whole answer before the first byte, so the
41
+ #: read timeout is a cap on total server time rather than a stall
42
+ #: detector. Measured over three hours of production traffic, 24.6% of
43
+ #: bars requests took longer than 120s -- 37% of those from the notebook
44
+ #: sandbox, and 75% of every ``30s`` request -- against 2 of 1,924 tick
45
+ #: requests exceeding the 1800s ticks already allowed.
46
+ #:
47
+ #: Retrying made it worse rather than better. The server finishes an
48
+ #: abandoned request (it notices the client left on 1% of them), so a
49
+ #: timeout frees nothing and the three retries turn one slow call into
50
+ #: four concurrent copies of it, each holding one of the eight stream
51
+ #: slots a key is allowed. That is a self-inflicted 429.
52
+ #:
53
+ #: This is a floor under the symptom, not a fix: the tail beyond 1800s is
54
+ #: server latency and belongs in the engine, not in a client's patience.
55
+ BARS_TIMEOUT = (30, 1800)
56
+
35
57
 
36
58
  class Client:
37
59
  def __init__(
@@ -164,14 +186,18 @@ class Client:
164
186
  "format": "arrow",
165
187
  "roll_adjust": self._roll_adjust(symbol, roll_adjust),
166
188
  }
167
- response = self._request("GET", "/v1/bars", params=params, stream=True)
189
+ response = self._request(
190
+ "GET", "/v1/bars", params=params, timeout=BARS_TIMEOUT, stream=True
191
+ )
168
192
  try:
169
193
  table = ipc.open_stream(response.content).read_all()
170
194
  frame = table.to_pandas()
171
195
  except Exception:
172
196
  response.close()
173
197
  params["format"] = "csv"
174
- fallback = self._request("GET", "/v1/bars", params=params, stream=True)
198
+ fallback = self._request(
199
+ "GET", "/v1/bars", params=params, timeout=BARS_TIMEOUT, stream=True
200
+ )
175
201
  try:
176
202
  frame = pd.read_csv(io.StringIO(fallback.text))
177
203
  finally:
@@ -66,6 +66,44 @@ def test_bars_uses_api_key_and_preserves_aliases():
66
66
  assert session.calls[0][2]["params"]["roll_adjust"] == "back"
67
67
 
68
68
 
69
+ def test_bars_gets_the_same_read_budget_as_ticks():
70
+ """The default 120s was a cap on total server time, not a stall
71
+ detector: ``/v1/bars`` materialises the whole answer before the first
72
+ byte, and 24.6% of production bars requests take longer than that.
73
+ Timing out did not even save the work -- the server finishes the
74
+ request regardless, so the three retries just ran four copies of it.
75
+ """
76
+ from quantpad_data.client import BARS_TIMEOUT
77
+
78
+ table = pa.table({"t": [1_700_000_000_000], "c": [1.5]})
79
+ session = FakeSession([response(body=arrow_bytes(table))])
80
+ client = Client(api_key="secret", session=session, max_retries=0)
81
+
82
+ client.get_bars("GC.FUT", "30s", 1, 2)
83
+
84
+ connect, read = session.calls[0][2]["timeout"]
85
+ assert (connect, read) == BARS_TIMEOUT
86
+ assert read >= 1800, "a one-year 30s request does not fit in 120s"
87
+
88
+
89
+ def test_the_csv_fallback_keeps_the_long_read_budget():
90
+ """It re-requests the same window, so inheriting the 120s default
91
+ would time out exactly where the Arrow attempt just did."""
92
+ from quantpad_data.client import BARS_TIMEOUT
93
+
94
+ table = pa.table({"t": [1_700_000_000_000], "c": [1.5]})
95
+ session = FakeSession(
96
+ [response(body=b"not arrow"), response(body=b"t,c\n1700000000000,1.5\n")]
97
+ )
98
+ client = Client(api_key="secret", session=session, max_retries=0)
99
+
100
+ client.get_bars("GC.FUT", "30s", 1, 2)
101
+
102
+ assert len(session.calls) == 2
103
+ assert session.calls[1][2]["timeout"] == BARS_TIMEOUT
104
+ assert session.calls[1][2]["params"]["format"] == "csv"
105
+
106
+
69
107
  def test_rate_limit_exposes_retry_after():
70
108
  session = FakeSession(
71
109
  [response(429, b'{"detail":"rate limit"}', {"Retry-After": "7"})]
@@ -1,11 +0,0 @@
1
- __pycache__/
2
- *.py[cod]
3
- *$py.class
4
- .env
5
- .env.local
6
- .venv/
7
- venv/
8
- *.egg-info/
9
- .mypy_cache/
10
- .ruff_cache/
11
- .pytest_cache/
File without changes