form4api 0.6.0__tar.gz → 0.7.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.
- {form4api-0.6.0 → form4api-0.7.0}/PKG-INFO +30 -5
- {form4api-0.6.0 → form4api-0.7.0}/README.md +29 -4
- {form4api-0.6.0 → form4api-0.7.0}/form4api/__init__.py +38 -30
- form4api-0.7.0/form4api/_errors.py +105 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_signals.py +50 -8
- {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_transactions.py +65 -20
- {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/PKG-INFO +30 -5
- {form4api-0.6.0 → form4api-0.7.0}/pyproject.toml +1 -1
- {form4api-0.6.0 → form4api-0.7.0}/tests/test_client.py +123 -0
- form4api-0.6.0/form4api/_errors.py +0 -47
- {form4api-0.6.0 → form4api-0.7.0}/LICENSE +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/_client.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/_generated.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/_types.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/_webhook_utils.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/__init__.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_companies.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_insiders.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api/resources/_webhooks.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/SOURCES.txt +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/dependency_links.txt +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/requires.txt +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/form4api.egg-info/top_level.txt +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/setup.cfg +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/tests/test_generated.py +0 -0
- {form4api-0.6.0 → form4api-0.7.0}/tests/test_method_name.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: form4api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Python client for the Form4API — real-time SEC Form 4 insider trading data
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://www.form4api.com
|
|
@@ -183,27 +183,52 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
|
|
|
183
183
|
|
|
184
184
|
### Pagination
|
|
185
185
|
|
|
186
|
+
`paginate()` pages through the data until it runs out (a short or empty page)
|
|
187
|
+
or, since the backend's 2026-08-01 plan-gated pagination depth (Free: 20
|
|
188
|
+
pages, Starter: 100, Pro+: unlimited), the next page is rejected with a 402.
|
|
189
|
+
That 402 is not swallowed — it raises `PaginationLimitError` mid-iteration,
|
|
190
|
+
after every page already yielded has been delivered to your loop. Pages you
|
|
191
|
+
already received are real and complete; the error only means iteration
|
|
192
|
+
stopped early. Pass `max_pages` to stop deliberately before that ever
|
|
193
|
+
happens, or catch `PaginationLimitError` to know when a Free/Starter key ran
|
|
194
|
+
out of depth on a bulk pull:
|
|
195
|
+
|
|
186
196
|
```python
|
|
197
|
+
from form4api import PaginationLimitError
|
|
198
|
+
|
|
187
199
|
# transactions.paginate() — yields one list per page automatically
|
|
188
200
|
all_txns = []
|
|
189
|
-
|
|
190
|
-
|
|
201
|
+
try:
|
|
202
|
+
for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
|
|
203
|
+
all_txns.extend(batch)
|
|
204
|
+
except PaginationLimitError as e:
|
|
205
|
+
print(f"Stopped after {e.pages_yielded} pages — {e}")
|
|
206
|
+
# all_txns still holds every page yielded before the limit hit
|
|
191
207
|
|
|
192
208
|
# signals.paginate()
|
|
193
209
|
all_signals = []
|
|
194
|
-
for batch in client.signals.paginate(cluster_buy=True, per_page=100):
|
|
210
|
+
for batch in client.signals.paginate(cluster_buy=True, per_page=100, max_pages=10):
|
|
195
211
|
all_signals.extend(batch)
|
|
196
212
|
```
|
|
197
213
|
|
|
198
214
|
## Error handling
|
|
199
215
|
|
|
200
216
|
```python
|
|
201
|
-
from form4api import
|
|
217
|
+
from form4api import (
|
|
218
|
+
Form4ApiClient,
|
|
219
|
+
AuthError,
|
|
220
|
+
PlanError,
|
|
221
|
+
PaginationLimitError,
|
|
222
|
+
RateLimitError,
|
|
223
|
+
NotFoundError,
|
|
224
|
+
)
|
|
202
225
|
|
|
203
226
|
client = Form4ApiClient("YOUR_API_KEY")
|
|
204
227
|
|
|
205
228
|
try:
|
|
206
229
|
signals = client.signals.list()
|
|
230
|
+
except PaginationLimitError as e:
|
|
231
|
+
print(f"Paginate stopped after {e.pages_yielded} pages — upgrade to go deeper")
|
|
207
232
|
except PlanError as e:
|
|
208
233
|
print(f"Upgrade required")
|
|
209
234
|
except RateLimitError as e:
|
|
@@ -164,27 +164,52 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
|
|
|
164
164
|
|
|
165
165
|
### Pagination
|
|
166
166
|
|
|
167
|
+
`paginate()` pages through the data until it runs out (a short or empty page)
|
|
168
|
+
or, since the backend's 2026-08-01 plan-gated pagination depth (Free: 20
|
|
169
|
+
pages, Starter: 100, Pro+: unlimited), the next page is rejected with a 402.
|
|
170
|
+
That 402 is not swallowed — it raises `PaginationLimitError` mid-iteration,
|
|
171
|
+
after every page already yielded has been delivered to your loop. Pages you
|
|
172
|
+
already received are real and complete; the error only means iteration
|
|
173
|
+
stopped early. Pass `max_pages` to stop deliberately before that ever
|
|
174
|
+
happens, or catch `PaginationLimitError` to know when a Free/Starter key ran
|
|
175
|
+
out of depth on a bulk pull:
|
|
176
|
+
|
|
167
177
|
```python
|
|
178
|
+
from form4api import PaginationLimitError
|
|
179
|
+
|
|
168
180
|
# transactions.paginate() — yields one list per page automatically
|
|
169
181
|
all_txns = []
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
try:
|
|
183
|
+
for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
|
|
184
|
+
all_txns.extend(batch)
|
|
185
|
+
except PaginationLimitError as e:
|
|
186
|
+
print(f"Stopped after {e.pages_yielded} pages — {e}")
|
|
187
|
+
# all_txns still holds every page yielded before the limit hit
|
|
172
188
|
|
|
173
189
|
# signals.paginate()
|
|
174
190
|
all_signals = []
|
|
175
|
-
for batch in client.signals.paginate(cluster_buy=True, per_page=100):
|
|
191
|
+
for batch in client.signals.paginate(cluster_buy=True, per_page=100, max_pages=10):
|
|
176
192
|
all_signals.extend(batch)
|
|
177
193
|
```
|
|
178
194
|
|
|
179
195
|
## Error handling
|
|
180
196
|
|
|
181
197
|
```python
|
|
182
|
-
from form4api import
|
|
198
|
+
from form4api import (
|
|
199
|
+
Form4ApiClient,
|
|
200
|
+
AuthError,
|
|
201
|
+
PlanError,
|
|
202
|
+
PaginationLimitError,
|
|
203
|
+
RateLimitError,
|
|
204
|
+
NotFoundError,
|
|
205
|
+
)
|
|
183
206
|
|
|
184
207
|
client = Form4ApiClient("YOUR_API_KEY")
|
|
185
208
|
|
|
186
209
|
try:
|
|
187
210
|
signals = client.signals.list()
|
|
211
|
+
except PaginationLimitError as e:
|
|
212
|
+
print(f"Paginate stopped after {e.pages_yielded} pages — upgrade to go deeper")
|
|
188
213
|
except PlanError as e:
|
|
189
214
|
print(f"Upgrade required")
|
|
190
215
|
except RateLimitError as e:
|
|
@@ -1,30 +1,38 @@
|
|
|
1
|
-
from form4api._client import AsyncForm4ApiClient, Form4ApiClient
|
|
2
|
-
from form4api._errors import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
1
|
+
from form4api._client import AsyncForm4ApiClient, Form4ApiClient
|
|
2
|
+
from form4api._errors import (
|
|
3
|
+
AuthError,
|
|
4
|
+
Form4ApiError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
PaginationLimitError,
|
|
7
|
+
PlanError,
|
|
8
|
+
RateLimitError,
|
|
9
|
+
)
|
|
10
|
+
from form4api._types import (
|
|
11
|
+
Company,
|
|
12
|
+
Insider,
|
|
13
|
+
InsiderSignal,
|
|
14
|
+
Transaction,
|
|
15
|
+
WebhookCreated,
|
|
16
|
+
WebhookEvent,
|
|
17
|
+
WebhookSubscription,
|
|
18
|
+
)
|
|
19
|
+
from form4api._webhook_utils import verify_webhook
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Form4ApiClient",
|
|
23
|
+
"AsyncForm4ApiClient",
|
|
24
|
+
"Form4ApiError",
|
|
25
|
+
"AuthError",
|
|
26
|
+
"PlanError",
|
|
27
|
+
"PaginationLimitError",
|
|
28
|
+
"NotFoundError",
|
|
29
|
+
"RateLimitError",
|
|
30
|
+
"Transaction",
|
|
31
|
+
"Insider",
|
|
32
|
+
"Company",
|
|
33
|
+
"InsiderSignal",
|
|
34
|
+
"WebhookCreated",
|
|
35
|
+
"WebhookEvent",
|
|
36
|
+
"WebhookSubscription",
|
|
37
|
+
"verify_webhook",
|
|
38
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Form4ApiError(Exception):
|
|
7
|
+
def __init__(self, message: str, status_code: int, error_code: str | None = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.error_code = error_code
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthError(Form4ApiError):
|
|
14
|
+
def __init__(self, message: str, error_code: str | None = None) -> None:
|
|
15
|
+
super().__init__(message, 401, error_code)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlanError(Form4ApiError):
|
|
19
|
+
"""Raised on 402 PLAN_REQUIRED.
|
|
20
|
+
|
|
21
|
+
``required_plan`` is the minimum plan that unlocks the endpoint (e.g.
|
|
22
|
+
``"Business"``), ``current_plan`` is the plan the calling key is on, and
|
|
23
|
+
``upgrade_url`` is where to upgrade. All three may be ``None`` against
|
|
24
|
+
backends older than 2026-08-05, which carried the plan names only as prose
|
|
25
|
+
inside ``message``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
required_plan: str | None = None,
|
|
32
|
+
current_plan: str | None = None,
|
|
33
|
+
upgrade_url: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message, 402, "PLAN_REQUIRED")
|
|
36
|
+
self.required_plan = required_plan
|
|
37
|
+
self.current_plan = current_plan
|
|
38
|
+
self.upgrade_url = upgrade_url
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotFoundError(Form4ApiError):
|
|
42
|
+
def __init__(self, message: str, error_code: str | None = None) -> None:
|
|
43
|
+
super().__init__(message, 404, error_code)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RateLimitError(Form4ApiError):
|
|
47
|
+
def __init__(self, message: str, retry_after: int | None = None) -> None:
|
|
48
|
+
super().__init__(message, 429, "RATE_LIMIT_EXCEEDED")
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Backend message shape for the plan-gated *pagination depth* 402 (see
|
|
53
|
+
# PaginationHelpers.MaxPageFor / TransactionsEndpoints.cs / CongressEndpoints.cs
|
|
54
|
+
# on the API). This is deliberately narrow: `error_code` is "PLAN_REQUIRED" for
|
|
55
|
+
# EVERY 402 the API returns — a whole-endpoint plan gate (e.g. GET /v1/signals
|
|
56
|
+
# on a sub-Business key) and a plan-gated query parameter both use the same
|
|
57
|
+
# code — so the message text is the only reliable signal that a given 402 is
|
|
58
|
+
# specifically the depth limit rather than some other plan gate. If the
|
|
59
|
+
# backend ever changes this message shape, the regex stops matching and
|
|
60
|
+
# `paginate()` re-raises the original `PlanError` untouched instead of
|
|
61
|
+
# mislabeling an unrelated 402.
|
|
62
|
+
_PAGINATION_DEPTH_MESSAGE_RE = re.compile(r"pagination depth on /v1/", re.IGNORECASE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_pagination_depth_error(err: Exception) -> bool:
|
|
66
|
+
"""True when `err` is specifically the plan-gated pagination-depth 402 that
|
|
67
|
+
`paginate()` knows how to turn into a `PaginationLimitError`."""
|
|
68
|
+
return isinstance(err, PlanError) and bool(_PAGINATION_DEPTH_MESSAGE_RE.search(str(err)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PaginationLimitError(PlanError):
|
|
72
|
+
"""Raised by `paginate()` (on `transactions` and `signals`) when the backend
|
|
73
|
+
rejects the next page because the calling key's plan has reached its
|
|
74
|
+
pagination depth limit (Free: 20 pages, Starter: 100, Pro+: unlimited).
|
|
75
|
+
|
|
76
|
+
Pages already yielded before this point were real, complete pages — this
|
|
77
|
+
error only means iteration stopped early, not that any data already
|
|
78
|
+
delivered to the caller was wrong. `pages_yielded` tells you exactly how
|
|
79
|
+
many. The original `PlanError` is chained as `__cause__` (via `raise ... from err`).
|
|
80
|
+
|
|
81
|
+
**Subclasses PlanError deliberately.** Before this type existed, `paginate()`
|
|
82
|
+
raised a plain `PlanError` at the depth limit, so `except PlanError:` was the
|
|
83
|
+
documented way to handle it. Subclassing keeps every one of those handlers
|
|
84
|
+
working while letting new code catch the narrower type — and it is the
|
|
85
|
+
truthful relationship anyway, since this IS a 402 PLAN_REQUIRED. Making it a
|
|
86
|
+
sibling would break existing callers for no gain.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
message: str,
|
|
92
|
+
pages_yielded: int,
|
|
93
|
+
cause: PlanError | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
# Carry the upgrade metadata through from the original 402 where the
|
|
96
|
+
# backend supplied it, so a caller can link straight to the upgrade page
|
|
97
|
+
# without unwrapping __cause__ themselves. The depth-limit branch
|
|
98
|
+
# populates upgrade_url but leaves required_plan/current_plan None.
|
|
99
|
+
super().__init__(
|
|
100
|
+
message,
|
|
101
|
+
cause.required_plan if cause else None,
|
|
102
|
+
cause.current_plan if cause else None,
|
|
103
|
+
cause.upgrade_url if cause else None,
|
|
104
|
+
)
|
|
105
|
+
self.pages_yielded = pages_yielded
|
|
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
|
|
|
5
5
|
|
|
6
6
|
from collections.abc import AsyncGenerator
|
|
7
7
|
|
|
8
|
+
from form4api._errors import PaginationLimitError, PlanError, is_pagination_depth_error
|
|
8
9
|
from form4api._generated import GeneratedAsyncSignalsResource, GeneratedSignalsResource
|
|
9
10
|
from form4api._types import InsiderSignal
|
|
10
11
|
|
|
@@ -58,16 +59,39 @@ class SignalsResource(GeneratedSignalsResource):
|
|
|
58
59
|
cluster_buy: bool | None = None,
|
|
59
60
|
cluster_sell: bool | None = None,
|
|
60
61
|
per_page: int = 100,
|
|
62
|
+
max_pages: int | None = None,
|
|
61
63
|
) -> Generator[list[InsiderSignal], None, None]:
|
|
64
|
+
"""Pages through /v1/signals until the data runs out (a short or empty
|
|
65
|
+
page) or the calling key's plan-gated pagination depth is exceeded —
|
|
66
|
+
see `TransactionsResource.paginate` for the full rationale. That 402
|
|
67
|
+
is NOT swallowed; it becomes a `PaginationLimitError` after every page
|
|
68
|
+
already yielded has been delivered to the caller. Pass `max_pages` to
|
|
69
|
+
stop deliberately before that happens.
|
|
70
|
+
"""
|
|
62
71
|
page = 1
|
|
72
|
+
pages_yielded = 0
|
|
63
73
|
while True:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
if max_pages is not None and pages_yielded >= max_pages:
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
batch = self.list(
|
|
79
|
+
ticker=ticker, cluster_buy=cluster_buy, cluster_sell=cluster_sell,
|
|
80
|
+
page=page, per_page=per_page,
|
|
81
|
+
)
|
|
82
|
+
except PlanError as err:
|
|
83
|
+
if is_pagination_depth_error(err):
|
|
84
|
+
raise PaginationLimitError(
|
|
85
|
+
f"signals.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
|
|
86
|
+
pages_yielded,
|
|
87
|
+
err,
|
|
88
|
+
) from err
|
|
89
|
+
raise
|
|
90
|
+
|
|
68
91
|
if not batch:
|
|
69
92
|
break
|
|
70
93
|
yield batch
|
|
94
|
+
pages_yielded += 1
|
|
71
95
|
if len(batch) < per_page:
|
|
72
96
|
break
|
|
73
97
|
page += 1
|
|
@@ -102,16 +126,34 @@ class AsyncSignalsResource(GeneratedAsyncSignalsResource):
|
|
|
102
126
|
cluster_buy: bool | None = None,
|
|
103
127
|
cluster_sell: bool | None = None,
|
|
104
128
|
per_page: int = 100,
|
|
129
|
+
max_pages: int | None = None,
|
|
105
130
|
) -> AsyncGenerator[list[InsiderSignal], None]:
|
|
131
|
+
"""Async twin of `SignalsResource.paginate` — same depth-limit
|
|
132
|
+
semantics, see there for the full rationale."""
|
|
106
133
|
page = 1
|
|
134
|
+
pages_yielded = 0
|
|
107
135
|
while True:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
if max_pages is not None and pages_yielded >= max_pages:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
batch = await self.list(
|
|
141
|
+
ticker=ticker, cluster_buy=cluster_buy, cluster_sell=cluster_sell,
|
|
142
|
+
page=page, per_page=per_page,
|
|
143
|
+
)
|
|
144
|
+
except PlanError as err:
|
|
145
|
+
if is_pagination_depth_error(err):
|
|
146
|
+
raise PaginationLimitError(
|
|
147
|
+
f"signals.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
|
|
148
|
+
pages_yielded,
|
|
149
|
+
err,
|
|
150
|
+
) from err
|
|
151
|
+
raise
|
|
152
|
+
|
|
112
153
|
if not batch:
|
|
113
154
|
break
|
|
114
155
|
yield batch
|
|
156
|
+
pages_yielded += 1
|
|
115
157
|
if len(batch) < per_page:
|
|
116
158
|
break
|
|
117
159
|
page += 1
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from collections.abc import AsyncGenerator, Generator
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
|
+
from form4api._errors import PaginationLimitError, PlanError, is_pagination_depth_error
|
|
6
7
|
from form4api._types import Transaction
|
|
7
8
|
|
|
8
9
|
if TYPE_CHECKING:
|
|
@@ -121,23 +122,49 @@ class TransactionsResource:
|
|
|
121
122
|
min_shares: float | None = None,
|
|
122
123
|
max_shares: float | None = None,
|
|
123
124
|
per_page: int = 50,
|
|
125
|
+
max_pages: int | None = None,
|
|
124
126
|
) -> Generator[list[Transaction], None, None]:
|
|
127
|
+
"""Pages through /v1/transactions until the data runs out (a short or
|
|
128
|
+
empty page) or, since the backend's 2026-08-01 plan-gated pagination
|
|
129
|
+
depth (Free: 20 pages, Starter: 100, Pro+: unlimited), the next page is
|
|
130
|
+
rejected with a 402. That 402 is NOT swallowed — a scripted caller who
|
|
131
|
+
silently stopped there would see what looks like "no more data" and
|
|
132
|
+
never learn their dataset was truncated. Instead this raises
|
|
133
|
+
`PaginationLimitError` mid-iteration, after every page already yielded
|
|
134
|
+
has been delivered to the caller. Pass `max_pages` to stop deliberately
|
|
135
|
+
before that ever happens.
|
|
136
|
+
"""
|
|
125
137
|
page = 1
|
|
138
|
+
pages_yielded = 0
|
|
126
139
|
while True:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
if max_pages is not None and pages_yielded >= max_pages:
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
batch = self.list(
|
|
145
|
+
ticker=ticker, cik=cik, insider_cik=insider_cik,
|
|
146
|
+
code=code, from_date=from_date, to_date=to_date,
|
|
147
|
+
exclude_10b5=exclude_10b5,
|
|
148
|
+
codes=codes, exclude_codes=exclude_codes,
|
|
149
|
+
category=category, exclude_category=exclude_category,
|
|
150
|
+
exclude_derivative=exclude_derivative, significant=significant,
|
|
151
|
+
min_value=min_value, max_value=max_value,
|
|
152
|
+
min_shares=min_shares, max_shares=max_shares,
|
|
153
|
+
page=page, per_page=per_page,
|
|
154
|
+
)
|
|
155
|
+
except PlanError as err:
|
|
156
|
+
if is_pagination_depth_error(err):
|
|
157
|
+
raise PaginationLimitError(
|
|
158
|
+
f"transactions.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
|
|
159
|
+
pages_yielded,
|
|
160
|
+
err,
|
|
161
|
+
) from err
|
|
162
|
+
raise
|
|
163
|
+
|
|
138
164
|
if not batch:
|
|
139
165
|
break
|
|
140
166
|
yield batch
|
|
167
|
+
pages_yielded += 1
|
|
141
168
|
if len(batch) < per_page:
|
|
142
169
|
break
|
|
143
170
|
page += 1
|
|
@@ -205,21 +232,39 @@ class AsyncTransactionsResource:
|
|
|
205
232
|
min_shares: float | None = None,
|
|
206
233
|
max_shares: float | None = None,
|
|
207
234
|
per_page: int = 50,
|
|
235
|
+
max_pages: int | None = None,
|
|
208
236
|
) -> AsyncGenerator[list[Transaction], None]:
|
|
237
|
+
"""Async twin of `TransactionsResource.paginate` — same depth-limit
|
|
238
|
+
semantics, see there for the full rationale."""
|
|
209
239
|
page = 1
|
|
240
|
+
pages_yielded = 0
|
|
210
241
|
while True:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
242
|
+
if max_pages is not None and pages_yielded >= max_pages:
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
batch = await self.list(
|
|
247
|
+
ticker=ticker, cik=cik, insider_cik=insider_cik, code=code,
|
|
248
|
+
from_date=from_date, to_date=to_date, exclude_10b5=exclude_10b5,
|
|
249
|
+
codes=codes, exclude_codes=exclude_codes, category=category,
|
|
250
|
+
exclude_category=exclude_category, exclude_derivative=exclude_derivative,
|
|
251
|
+
significant=significant, min_value=min_value, max_value=max_value,
|
|
252
|
+
min_shares=min_shares, max_shares=max_shares,
|
|
253
|
+
page=page, per_page=per_page,
|
|
254
|
+
)
|
|
255
|
+
except PlanError as err:
|
|
256
|
+
if is_pagination_depth_error(err):
|
|
257
|
+
raise PaginationLimitError(
|
|
258
|
+
f"transactions.paginate() stopped after yielding {pages_yielded} page(s) — {err}",
|
|
259
|
+
pages_yielded,
|
|
260
|
+
err,
|
|
261
|
+
) from err
|
|
262
|
+
raise
|
|
263
|
+
|
|
220
264
|
if not batch:
|
|
221
265
|
break
|
|
222
266
|
yield batch
|
|
267
|
+
pages_yielded += 1
|
|
223
268
|
if len(batch) < per_page:
|
|
224
269
|
break
|
|
225
270
|
page += 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: form4api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Python client for the Form4API — real-time SEC Form 4 insider trading data
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://www.form4api.com
|
|
@@ -183,27 +183,52 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
|
|
|
183
183
|
|
|
184
184
|
### Pagination
|
|
185
185
|
|
|
186
|
+
`paginate()` pages through the data until it runs out (a short or empty page)
|
|
187
|
+
or, since the backend's 2026-08-01 plan-gated pagination depth (Free: 20
|
|
188
|
+
pages, Starter: 100, Pro+: unlimited), the next page is rejected with a 402.
|
|
189
|
+
That 402 is not swallowed — it raises `PaginationLimitError` mid-iteration,
|
|
190
|
+
after every page already yielded has been delivered to your loop. Pages you
|
|
191
|
+
already received are real and complete; the error only means iteration
|
|
192
|
+
stopped early. Pass `max_pages` to stop deliberately before that ever
|
|
193
|
+
happens, or catch `PaginationLimitError` to know when a Free/Starter key ran
|
|
194
|
+
out of depth on a bulk pull:
|
|
195
|
+
|
|
186
196
|
```python
|
|
197
|
+
from form4api import PaginationLimitError
|
|
198
|
+
|
|
187
199
|
# transactions.paginate() — yields one list per page automatically
|
|
188
200
|
all_txns = []
|
|
189
|
-
|
|
190
|
-
|
|
201
|
+
try:
|
|
202
|
+
for batch in client.transactions.paginate(ticker="NVDA", exclude_10b5=True, per_page=100):
|
|
203
|
+
all_txns.extend(batch)
|
|
204
|
+
except PaginationLimitError as e:
|
|
205
|
+
print(f"Stopped after {e.pages_yielded} pages — {e}")
|
|
206
|
+
# all_txns still holds every page yielded before the limit hit
|
|
191
207
|
|
|
192
208
|
# signals.paginate()
|
|
193
209
|
all_signals = []
|
|
194
|
-
for batch in client.signals.paginate(cluster_buy=True, per_page=100):
|
|
210
|
+
for batch in client.signals.paginate(cluster_buy=True, per_page=100, max_pages=10):
|
|
195
211
|
all_signals.extend(batch)
|
|
196
212
|
```
|
|
197
213
|
|
|
198
214
|
## Error handling
|
|
199
215
|
|
|
200
216
|
```python
|
|
201
|
-
from form4api import
|
|
217
|
+
from form4api import (
|
|
218
|
+
Form4ApiClient,
|
|
219
|
+
AuthError,
|
|
220
|
+
PlanError,
|
|
221
|
+
PaginationLimitError,
|
|
222
|
+
RateLimitError,
|
|
223
|
+
NotFoundError,
|
|
224
|
+
)
|
|
202
225
|
|
|
203
226
|
client = Form4ApiClient("YOUR_API_KEY")
|
|
204
227
|
|
|
205
228
|
try:
|
|
206
229
|
signals = client.signals.list()
|
|
230
|
+
except PaginationLimitError as e:
|
|
231
|
+
print(f"Paginate stopped after {e.pages_yielded} pages — upgrade to go deeper")
|
|
207
232
|
except PlanError as e:
|
|
208
233
|
print(f"Upgrade required")
|
|
209
234
|
except RateLimitError as e:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "form4api"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Python client for the Form4API — real-time SEC Form 4 insider trading data"
|
|
9
9
|
keywords = ["insider-trading", "sec", "sec-edgar", "edgar", "form-4", "form4", "form-144", "13f", "13f-hr", "financial-data", "stock-market", "stocks", "fintech", "api", "sdk", "webhooks", "form4api"]
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -6,6 +6,7 @@ from form4api import (
|
|
|
6
6
|
Form4ApiClient,
|
|
7
7
|
AuthError,
|
|
8
8
|
NotFoundError,
|
|
9
|
+
PaginationLimitError,
|
|
9
10
|
PlanError,
|
|
10
11
|
RateLimitError,
|
|
11
12
|
Form4ApiError,
|
|
@@ -161,6 +162,128 @@ def test_transactions_paginate_stops_on_short_page(client):
|
|
|
161
162
|
assert pages[0][0].ticker == "AAPL"
|
|
162
163
|
|
|
163
164
|
|
|
165
|
+
@respx.mock
|
|
166
|
+
def test_transactions_paginate_raises_pagination_limit_error_after_delivering_prior_pages(client):
|
|
167
|
+
"""The plan-gated pagination-depth 402 is not swallowed: it becomes a
|
|
168
|
+
PaginationLimitError raised mid-iteration, after every page already
|
|
169
|
+
yielded has been delivered to the caller."""
|
|
170
|
+
call = {"n": 0}
|
|
171
|
+
|
|
172
|
+
def handler(_request):
|
|
173
|
+
call["n"] += 1
|
|
174
|
+
if call["n"] <= 2:
|
|
175
|
+
return httpx.Response(200, json=[TX, TX])
|
|
176
|
+
return httpx.Response(
|
|
177
|
+
402,
|
|
178
|
+
json={
|
|
179
|
+
"error": {
|
|
180
|
+
"code": "PLAN_REQUIRED",
|
|
181
|
+
"message": (
|
|
182
|
+
"Page 3 is beyond the Free plan's pagination depth on /v1/transactions "
|
|
183
|
+
"(2 pages). The Starter plan reaches 100 pages and Pro removes the limit "
|
|
184
|
+
"— upgrade at https://form4api.com/dashboard/billing?from=page_depth_402. "
|
|
185
|
+
"For a bulk historical pull, GET /v1/transactions/export (Business plan) "
|
|
186
|
+
"streams the full filtered set as CSV instead of paging."
|
|
187
|
+
),
|
|
188
|
+
"requestId": "req_test",
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
|
|
194
|
+
|
|
195
|
+
pages = []
|
|
196
|
+
caught = None
|
|
197
|
+
try:
|
|
198
|
+
for page in client.transactions.paginate(per_page=2):
|
|
199
|
+
pages.append(page)
|
|
200
|
+
except PaginationLimitError as err:
|
|
201
|
+
caught = err
|
|
202
|
+
|
|
203
|
+
assert len(pages) == 2
|
|
204
|
+
assert caught is not None
|
|
205
|
+
assert caught.pages_yielded == 2
|
|
206
|
+
assert "yielding 2 page(s)" in str(caught)
|
|
207
|
+
assert "/v1/transactions/export" in str(caught)
|
|
208
|
+
assert isinstance(caught.__cause__, PlanError)
|
|
209
|
+
|
|
210
|
+
# Backward compatibility, and the reason PaginationLimitError subclasses
|
|
211
|
+
# PlanError rather than sitting beside it. Before this type existed,
|
|
212
|
+
# paginate() raised a plain PlanError here, so `except PlanError:` was the
|
|
213
|
+
# documented way to handle the depth limit. If this assertion ever fails,
|
|
214
|
+
# every caller written against the old behaviour is silently no longer
|
|
215
|
+
# catching this — an uncaught exception rather than a handled upgrade path.
|
|
216
|
+
assert isinstance(caught, PlanError)
|
|
217
|
+
# The upgrade metadata is carried through from the original 402 so callers
|
|
218
|
+
# do not have to unwrap __cause__ to find it.
|
|
219
|
+
assert caught.upgrade_url == caught.__cause__.upgrade_url
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@respx.mock
|
|
223
|
+
def test_transactions_paginate_max_pages_stops_before_depth_limit(client):
|
|
224
|
+
respx.get(f"{BASE}/v1/transactions").mock(return_value=httpx.Response(200, json=[TX, TX]))
|
|
225
|
+
pages = list(client.transactions.paginate(per_page=2, max_pages=3))
|
|
226
|
+
assert len(pages) == 3
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@respx.mock
|
|
230
|
+
def test_transactions_paginate_does_not_swallow_or_mislabel_non_402_error(client):
|
|
231
|
+
call = {"n": 0}
|
|
232
|
+
|
|
233
|
+
def handler(_request):
|
|
234
|
+
call["n"] += 1
|
|
235
|
+
if call["n"] == 1:
|
|
236
|
+
return httpx.Response(200, json=[TX, TX])
|
|
237
|
+
return httpx.Response(500, json={})
|
|
238
|
+
|
|
239
|
+
respx.get(f"{BASE}/v1/transactions").mock(side_effect=handler)
|
|
240
|
+
|
|
241
|
+
pages = []
|
|
242
|
+
caught = None
|
|
243
|
+
try:
|
|
244
|
+
for page in client.transactions.paginate(per_page=2):
|
|
245
|
+
pages.append(page)
|
|
246
|
+
except Form4ApiError as err:
|
|
247
|
+
caught = err
|
|
248
|
+
|
|
249
|
+
assert len(pages) == 1
|
|
250
|
+
assert caught is not None
|
|
251
|
+
assert not isinstance(caught, PaginationLimitError)
|
|
252
|
+
assert caught.status_code == 500
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@respx.mock
|
|
256
|
+
def test_signals_paginate_reraises_non_depth_402_as_plan_error(client):
|
|
257
|
+
"""GET /v1/signals is gated at the whole-endpoint level (Business plan) —
|
|
258
|
+
this 402 has nothing to do with pagination depth, and must not be
|
|
259
|
+
rewritten as though it were the depth limit."""
|
|
260
|
+
respx.get(f"{BASE}/v1/signals").mock(
|
|
261
|
+
return_value=httpx.Response(
|
|
262
|
+
402,
|
|
263
|
+
json={
|
|
264
|
+
"error": {
|
|
265
|
+
"code": "PLAN_REQUIRED",
|
|
266
|
+
"message": "This endpoint requires the Business plan or higher. Your current plan is Free.",
|
|
267
|
+
"requestId": "req_test",
|
|
268
|
+
"requiredPlan": "Business",
|
|
269
|
+
"currentPlan": "Free",
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
caught = None
|
|
276
|
+
try:
|
|
277
|
+
for _page in client.signals.paginate():
|
|
278
|
+
pass
|
|
279
|
+
except PlanError as err:
|
|
280
|
+
caught = err
|
|
281
|
+
|
|
282
|
+
assert caught is not None
|
|
283
|
+
assert not isinstance(caught, PaginationLimitError)
|
|
284
|
+
assert caught.required_plan == "Business"
|
|
285
|
+
|
|
286
|
+
|
|
164
287
|
# ── insiders ──────────────────────────────────────────────────────────────────
|
|
165
288
|
|
|
166
289
|
@respx.mock
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class Form4ApiError(Exception):
|
|
5
|
-
def __init__(self, message: str, status_code: int, error_code: str | None = None) -> None:
|
|
6
|
-
super().__init__(message)
|
|
7
|
-
self.status_code = status_code
|
|
8
|
-
self.error_code = error_code
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class AuthError(Form4ApiError):
|
|
12
|
-
def __init__(self, message: str, error_code: str | None = None) -> None:
|
|
13
|
-
super().__init__(message, 401, error_code)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class PlanError(Form4ApiError):
|
|
17
|
-
"""Raised on 402 PLAN_REQUIRED.
|
|
18
|
-
|
|
19
|
-
``required_plan`` is the minimum plan that unlocks the endpoint (e.g.
|
|
20
|
-
``"Business"``), ``current_plan`` is the plan the calling key is on, and
|
|
21
|
-
``upgrade_url`` is where to upgrade. All three may be ``None`` against
|
|
22
|
-
backends older than 2026-08-05, which carried the plan names only as prose
|
|
23
|
-
inside ``message``.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def __init__(
|
|
27
|
-
self,
|
|
28
|
-
message: str,
|
|
29
|
-
required_plan: str | None = None,
|
|
30
|
-
current_plan: str | None = None,
|
|
31
|
-
upgrade_url: str | None = None,
|
|
32
|
-
) -> None:
|
|
33
|
-
super().__init__(message, 402, "PLAN_REQUIRED")
|
|
34
|
-
self.required_plan = required_plan
|
|
35
|
-
self.current_plan = current_plan
|
|
36
|
-
self.upgrade_url = upgrade_url
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class NotFoundError(Form4ApiError):
|
|
40
|
-
def __init__(self, message: str, error_code: str | None = None) -> None:
|
|
41
|
-
super().__init__(message, 404, error_code)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
class RateLimitError(Form4ApiError):
|
|
45
|
-
def __init__(self, message: str, retry_after: int | None = None) -> None:
|
|
46
|
-
super().__init__(message, 429, "RATE_LIMIT_EXCEEDED")
|
|
47
|
-
self.retry_after = retry_after
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|