form4api 0.5.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.5.0 → form4api-0.7.0}/PKG-INFO +32 -7
- {form4api-0.5.0 → form4api-0.7.0}/README.md +31 -6
- {form4api-0.5.0 → form4api-0.7.0}/form4api/__init__.py +38 -30
- form4api-0.7.0/form4api/_errors.py +105 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/_generated.py +164 -2
- {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_signals.py +50 -8
- {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_transactions.py +65 -20
- {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/PKG-INFO +32 -7
- {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/SOURCES.txt +2 -1
- {form4api-0.5.0 → form4api-0.7.0}/pyproject.toml +1 -1
- {form4api-0.5.0 → form4api-0.7.0}/tests/test_client.py +123 -0
- {form4api-0.5.0 → form4api-0.7.0}/tests/test_generated.py +44 -0
- form4api-0.7.0/tests/test_method_name.py +88 -0
- form4api-0.5.0/form4api/_errors.py +0 -47
- {form4api-0.5.0 → form4api-0.7.0}/LICENSE +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/_client.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/_types.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/_webhook_utils.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/__init__.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_companies.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_insiders.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api/resources/_webhooks.py +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/dependency_links.txt +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/requires.txt +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/form4api.egg-info/top_level.txt +0 -0
- {form4api-0.5.0 → form4api-0.7.0}/setup.cfg +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
|
|
@@ -82,13 +82,13 @@ asyncio.run(main())
|
|
|
82
82
|
| Resource | Methods |
|
|
83
83
|
|---|---|
|
|
84
84
|
| `client.transactions` | `.list(**params)`, `.paginate(**params)` |
|
|
85
|
-
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
85
|
+
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
86
86
|
| `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
|
|
87
87
|
| `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
|
|
88
88
|
| `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
|
|
89
89
|
| `client.form144` | `.list(**params)` — Business plan |
|
|
90
90
|
| `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
|
|
91
|
-
| `client.filings` | `.recent(**params)`, `.get(accession_number)` |
|
|
91
|
+
| `client.filings` | `.list(**params)`, `.recent(**params)`, `.get(accession_number)` |
|
|
92
92
|
| `client.stats` | `.get()` — public, no key required |
|
|
93
93
|
| `client.data_quality` | `.get()` — public, no key required |
|
|
94
94
|
| `client.status` | `.history(**params)` |
|
|
@@ -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:
|
|
@@ -63,13 +63,13 @@ asyncio.run(main())
|
|
|
63
63
|
| Resource | Methods |
|
|
64
64
|
|---|---|
|
|
65
65
|
| `client.transactions` | `.list(**params)`, `.paginate(**params)` |
|
|
66
|
-
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
66
|
+
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
67
67
|
| `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
|
|
68
68
|
| `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
|
|
69
69
|
| `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
|
|
70
70
|
| `client.form144` | `.list(**params)` — Business plan |
|
|
71
71
|
| `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
|
|
72
|
-
| `client.filings` | `.recent(**params)`, `.get(accession_number)` |
|
|
72
|
+
| `client.filings` | `.list(**params)`, `.recent(**params)`, `.get(accession_number)` |
|
|
73
73
|
| `client.stats` | `.get()` — public, no key required |
|
|
74
74
|
| `client.data_quality` | `.get()` — public, no key required |
|
|
75
75
|
| `client.status` | `.history(**params)` |
|
|
@@ -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
|
|
@@ -427,6 +427,47 @@ class DataQualityResponse:
|
|
|
427
427
|
return cls(**kwargs)
|
|
428
428
|
|
|
429
429
|
|
|
430
|
+
@dataclass
|
|
431
|
+
class DirectoryEntryResponse:
|
|
432
|
+
cik: str | None = None
|
|
433
|
+
filer_group_size: int | None = None
|
|
434
|
+
is_director: bool | None = None
|
|
435
|
+
is_officer: bool | None = None
|
|
436
|
+
is_ten_percent_owner: bool | None = None
|
|
437
|
+
last_filed_at: str | None = None
|
|
438
|
+
name: str | None = None
|
|
439
|
+
officer_title: str | None = None
|
|
440
|
+
primary_company_name: str | None = None
|
|
441
|
+
primary_ticker: str | None = None
|
|
442
|
+
transaction_count: int | None = None
|
|
443
|
+
|
|
444
|
+
@classmethod
|
|
445
|
+
def _from_dict(cls, data: dict) -> "DirectoryEntryResponse":
|
|
446
|
+
"""Build from an API payload, ignoring unknown keys.
|
|
447
|
+
|
|
448
|
+
Constructing with **data directly (as the hand-written resources do)
|
|
449
|
+
means the SDK raises TypeError the moment the backend adds a field.
|
|
450
|
+
Filtering keeps older SDK versions working against a newer API."""
|
|
451
|
+
known = {f.name for f in fields(cls)}
|
|
452
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass
|
|
456
|
+
class DirectoryLetter:
|
|
457
|
+
count: int | None = None
|
|
458
|
+
letter: str | None = None
|
|
459
|
+
|
|
460
|
+
@classmethod
|
|
461
|
+
def _from_dict(cls, data: dict) -> "DirectoryLetter":
|
|
462
|
+
"""Build from an API payload, ignoring unknown keys.
|
|
463
|
+
|
|
464
|
+
Constructing with **data directly (as the hand-written resources do)
|
|
465
|
+
means the SDK raises TypeError the moment the backend adds a field.
|
|
466
|
+
Filtering keeps older SDK versions working against a newer API."""
|
|
467
|
+
known = {f.name for f in fields(cls)}
|
|
468
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
469
|
+
|
|
470
|
+
|
|
430
471
|
@dataclass
|
|
431
472
|
class ExcludedTradeEntry:
|
|
432
473
|
code: str | None = None
|
|
@@ -678,6 +719,33 @@ class InsiderCompanyEntry:
|
|
|
678
719
|
return cls(**{k: v for k, v in data.items() if k in known})
|
|
679
720
|
|
|
680
721
|
|
|
722
|
+
@dataclass
|
|
723
|
+
class InsiderDirectoryResponse:
|
|
724
|
+
entries: list[DirectoryEntryResponse] | None = None
|
|
725
|
+
letter: str | None = None
|
|
726
|
+
letter_total: int | None = None
|
|
727
|
+
letters: list[DirectoryLetter] | None = None
|
|
728
|
+
page: int | None = None
|
|
729
|
+
per_page: int | None = None
|
|
730
|
+
refreshed_at: str | None = None
|
|
731
|
+
total: int | None = None
|
|
732
|
+
|
|
733
|
+
@classmethod
|
|
734
|
+
def _from_dict(cls, data: dict) -> "InsiderDirectoryResponse":
|
|
735
|
+
"""Build from an API payload, ignoring unknown keys.
|
|
736
|
+
|
|
737
|
+
Constructing with **data directly (as the hand-written resources do)
|
|
738
|
+
means the SDK raises TypeError the moment the backend adds a field.
|
|
739
|
+
Filtering keeps older SDK versions working against a newer API."""
|
|
740
|
+
known = {f.name for f in fields(cls)}
|
|
741
|
+
kwargs = {k: v for k, v in data.items() if k in known}
|
|
742
|
+
if isinstance(kwargs.get("entries"), list):
|
|
743
|
+
kwargs["entries"] = [DirectoryEntryResponse._from_dict(i) if isinstance(i, dict) else i for i in kwargs["entries"]]
|
|
744
|
+
if isinstance(kwargs.get("letters"), list):
|
|
745
|
+
kwargs["letters"] = [DirectoryLetter._from_dict(i) if isinstance(i, dict) else i for i in kwargs["letters"]]
|
|
746
|
+
return cls(**kwargs)
|
|
747
|
+
|
|
748
|
+
|
|
681
749
|
@dataclass
|
|
682
750
|
class InsiderLeaderboardResponse:
|
|
683
751
|
insiders: list[LeaderboardEntry] | None = None
|
|
@@ -1302,7 +1370,7 @@ class GeneratedCongressResource:
|
|
|
1302
1370
|
def trades(self, *, ticker: str | None = None, politician: str | None = None, party: str | None = None, chamber: str | None = None, state: str | None = None, transaction_type: str | None = None, min_amount: float | None = None, transaction_date_from: str | None = None, transaction_date_to: str | None = None, disclosure_date_from: str | None = None, disclosure_date_to: str | None = None, page: int | None = None, per_page: int | None = None) -> list[CongressTradeDto]:
|
|
1303
1371
|
"""Query congressional STOCK Act trades (Free+, plan-clamped disclosure window)
|
|
1304
1372
|
|
|
1305
|
-
Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
|
|
1373
|
+
Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). COVERAGE — HOUSE ONLY TODAY: every trade in this dataset comes from the U.S. House Clerk's PTR index. Senate eFD (efdsearch.senate.gov) returns 403 to datacenter traffic, so no Senate filings are ingested yet. chamber=Senate remains a valid filter but matches nothing and returns the response header X-Coverage-Note: chamber-not-covered, so an empty result is never ambiguous. Scanning by chamber should treat that header as "not covered", not as "no trades". PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate — see the coverage note above), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
|
|
1306
1374
|
params = {
|
|
1307
1375
|
"ticker": ticker,
|
|
1308
1376
|
"politician": politician,
|
|
@@ -1365,7 +1433,7 @@ class GeneratedAsyncCongressResource:
|
|
|
1365
1433
|
async def trades(self, *, ticker: str | None = None, politician: str | None = None, party: str | None = None, chamber: str | None = None, state: str | None = None, transaction_type: str | None = None, min_amount: float | None = None, transaction_date_from: str | None = None, transaction_date_to: str | None = None, disclosure_date_from: str | None = None, disclosure_date_to: str | None = None, page: int | None = None, per_page: int | None = None) -> list[CongressTradeDto]:
|
|
1366
1434
|
"""Query congressional STOCK Act trades (Free+, plan-clamped disclosure window)
|
|
1367
1435
|
|
|
1368
|
-
Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
|
|
1436
|
+
Returns a paginated JSON list of congressional periodic-transaction-report trades, most recently DISCLOSED first, with non-superseded rows only (amended-away rows never appear). COVERAGE — HOUSE ONLY TODAY: every trade in this dataset comes from the U.S. House Clerk's PTR index. Senate eFD (efdsearch.senate.gov) returns 403 to datacenter traffic, so no Senate filings are ingested yet. chamber=Senate remains a valid filter but matches nothing and returns the response header X-Coverage-Note: chamber-not-covered, so an empty result is never ambiguous. Scanning by chamber should treat that header as "not covered", not as "no trades". PLAN-CLAMPED WINDOW: this endpoint is open to every plan, but how far back you can see is clamped on disclosureDate — Free sees only trades disclosed in the last 30 days, Starter the last 366 days, Pro/Business/Enterprise unlimited history. Passing an older disclosure_date_from than your plan allows does not extend the window — the floor always wins. Filters: ticker, politician (bioguideId, exact), party (free-text, case-insensitive exact match — not a fixed enum), chamber (House|Senate — see the coverage note above), state (2-letter code), transaction_type (purchase|sale|partial_sale|exchange), min_amount (range-aware — matches AmountLow >= value, never a fabricated midpoint), transaction_date_from/to, disclosure_date_from/to. Every row always carries BOTH amountLow and amountHigh (STOCK Act discloses ranges, never exact figures) and disclosureLagDays = (disclosureDate - transactionDate) — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). Query runs live against the database — no caching."""
|
|
1369
1437
|
params = {
|
|
1370
1438
|
"ticker": ticker,
|
|
1371
1439
|
"politician": politician,
|
|
@@ -1421,6 +1489,23 @@ class GeneratedFilingsResource:
|
|
|
1421
1489
|
data = self._client._get(f"/v1/filings/{accession}")
|
|
1422
1490
|
return FilingResponse._from_dict(data)
|
|
1423
1491
|
|
|
1492
|
+
def list(self, *, ticker: str | None = None, cik: str | None = None, from_: str | None = None, to: str | None = None, page: int | None = None, per_page: int | None = None, limit: int | None = None) -> list[FilingResponse]:
|
|
1493
|
+
"""List Form 4 filings with optional ticker, CIK and date filters
|
|
1494
|
+
|
|
1495
|
+
Returns a paginated list of Form 4 filings, newest filed first. Filter by ticker, cik, and a from/to filed-date window. Each entry carries the accession number, company ticker/name, period of report, filed date, amendment type (Original/Amendment), and the count of non-superseded transactions in that filing. Use this for a company's filing HISTORY; use GET /v1/filings/recent for a live newest-first feed (it has no page parameter), and GET /v1/transactions when you want the individual trades rather than the filings that contain them. `limit` is accepted as an alias for `per_page`. Not plan-gated."""
|
|
1496
|
+
params = {
|
|
1497
|
+
"ticker": ticker,
|
|
1498
|
+
"cik": cik,
|
|
1499
|
+
"from": from_,
|
|
1500
|
+
"to": to,
|
|
1501
|
+
"page": page,
|
|
1502
|
+
"per_page": per_page,
|
|
1503
|
+
"limit": limit,
|
|
1504
|
+
}
|
|
1505
|
+
params = {k: str(v) for k, v in params.items() if v is not None}
|
|
1506
|
+
data = self._client._get(f"/v1/filings", params=params)
|
|
1507
|
+
return [FilingResponse._from_dict(item) for item in data]
|
|
1508
|
+
|
|
1424
1509
|
def recent(self, *, ticker: str | None = None, per_page: int | None = None) -> list[FilingResponse]:
|
|
1425
1510
|
"""Get the most recently filed Form 4s, optionally filtered by ticker
|
|
1426
1511
|
|
|
@@ -1445,6 +1530,23 @@ class GeneratedAsyncFilingsResource:
|
|
|
1445
1530
|
data = await self._client._get(f"/v1/filings/{accession}")
|
|
1446
1531
|
return FilingResponse._from_dict(data)
|
|
1447
1532
|
|
|
1533
|
+
async def list(self, *, ticker: str | None = None, cik: str | None = None, from_: str | None = None, to: str | None = None, page: int | None = None, per_page: int | None = None, limit: int | None = None) -> list[FilingResponse]:
|
|
1534
|
+
"""List Form 4 filings with optional ticker, CIK and date filters
|
|
1535
|
+
|
|
1536
|
+
Returns a paginated list of Form 4 filings, newest filed first. Filter by ticker, cik, and a from/to filed-date window. Each entry carries the accession number, company ticker/name, period of report, filed date, amendment type (Original/Amendment), and the count of non-superseded transactions in that filing. Use this for a company's filing HISTORY; use GET /v1/filings/recent for a live newest-first feed (it has no page parameter), and GET /v1/transactions when you want the individual trades rather than the filings that contain them. `limit` is accepted as an alias for `per_page`. Not plan-gated."""
|
|
1537
|
+
params = {
|
|
1538
|
+
"ticker": ticker,
|
|
1539
|
+
"cik": cik,
|
|
1540
|
+
"from": from_,
|
|
1541
|
+
"to": to,
|
|
1542
|
+
"page": page,
|
|
1543
|
+
"per_page": per_page,
|
|
1544
|
+
"limit": limit,
|
|
1545
|
+
}
|
|
1546
|
+
params = {k: str(v) for k, v in params.items() if v is not None}
|
|
1547
|
+
data = await self._client._get(f"/v1/filings", params=params)
|
|
1548
|
+
return [FilingResponse._from_dict(item) for item in data]
|
|
1549
|
+
|
|
1448
1550
|
async def recent(self, *, ticker: str | None = None, per_page: int | None = None) -> list[FilingResponse]:
|
|
1449
1551
|
"""Get the most recently filed Form 4s, optionally filtered by ticker
|
|
1450
1552
|
|
|
@@ -1578,6 +1680,36 @@ class GeneratedInsidersResource:
|
|
|
1578
1680
|
def __init__(self, client) -> None:
|
|
1579
1681
|
self._client = client
|
|
1580
1682
|
|
|
1683
|
+
def directory(self, *, letter: str | None = None, page: int | None = None, per_page: int | None = None) -> InsiderDirectoryResponse:
|
|
1684
|
+
"""Browse insiders alphabetically by surname
|
|
1685
|
+
|
|
1686
|
+
Returns the A-Z rail with a count per letter, plus one page of insiders under the
|
|
1687
|
+
requested letter. Omit `letter` to get the rail and totals with no rows.
|
|
1688
|
+
|
|
1689
|
+
Names come from EDGAR surname-first ("HENNEMAN JOHN B III"), so alphabetical order
|
|
1690
|
+
is order by surname. Casing in the source is inconsistent and is not normalised here.
|
|
1691
|
+
|
|
1692
|
+
This lists only insiders with at least 3 non-superseded transactions, capped at the
|
|
1693
|
+
5,000 most active — the same set as the insiders sitemap shard, so the two cannot
|
|
1694
|
+
drift. To find someone outside that set, use GET /v1/insiders?name= which searches
|
|
1695
|
+
every filer. Rebuilt daily; `refreshedAt` reports when. Not plan-gated.
|
|
1696
|
+
|
|
1697
|
+
One row per FILER GROUP. A fund group files a single Form 4 listing several
|
|
1698
|
+
reporting owners — the fund, its GP, its management company — and each is a real
|
|
1699
|
+
EDGAR filer with its own CIK. Listing all of them spent about 11% of this capped
|
|
1700
|
+
surface describing the same actors more than once, so browse shows one per group
|
|
1701
|
+
and `filerGroupSize` says how many others share those exact transactions. The
|
|
1702
|
+
others are not hidden: each keeps its own profile and is still returned by
|
|
1703
|
+
GET /v1/insiders?name=."""
|
|
1704
|
+
params = {
|
|
1705
|
+
"letter": letter,
|
|
1706
|
+
"page": page,
|
|
1707
|
+
"per_page": per_page,
|
|
1708
|
+
}
|
|
1709
|
+
params = {k: str(v) for k, v in params.items() if v is not None}
|
|
1710
|
+
data = self._client._get(f"/v1/insiders/directory", params=params)
|
|
1711
|
+
return InsiderDirectoryResponse._from_dict(data)
|
|
1712
|
+
|
|
1581
1713
|
def leaderboard(self, *, horizon: str | None = None, order: str | None = None, min_trades: int | None = None, limit: int | None = None) -> InsiderLeaderboardResponse:
|
|
1582
1714
|
"""Ranked leaderboard of insiders by buy track-record (Business plan+)
|
|
1583
1715
|
|
|
@@ -1651,6 +1783,36 @@ class GeneratedAsyncInsidersResource:
|
|
|
1651
1783
|
def __init__(self, client) -> None:
|
|
1652
1784
|
self._client = client
|
|
1653
1785
|
|
|
1786
|
+
async def directory(self, *, letter: str | None = None, page: int | None = None, per_page: int | None = None) -> InsiderDirectoryResponse:
|
|
1787
|
+
"""Browse insiders alphabetically by surname
|
|
1788
|
+
|
|
1789
|
+
Returns the A-Z rail with a count per letter, plus one page of insiders under the
|
|
1790
|
+
requested letter. Omit `letter` to get the rail and totals with no rows.
|
|
1791
|
+
|
|
1792
|
+
Names come from EDGAR surname-first ("HENNEMAN JOHN B III"), so alphabetical order
|
|
1793
|
+
is order by surname. Casing in the source is inconsistent and is not normalised here.
|
|
1794
|
+
|
|
1795
|
+
This lists only insiders with at least 3 non-superseded transactions, capped at the
|
|
1796
|
+
5,000 most active — the same set as the insiders sitemap shard, so the two cannot
|
|
1797
|
+
drift. To find someone outside that set, use GET /v1/insiders?name= which searches
|
|
1798
|
+
every filer. Rebuilt daily; `refreshedAt` reports when. Not plan-gated.
|
|
1799
|
+
|
|
1800
|
+
One row per FILER GROUP. A fund group files a single Form 4 listing several
|
|
1801
|
+
reporting owners — the fund, its GP, its management company — and each is a real
|
|
1802
|
+
EDGAR filer with its own CIK. Listing all of them spent about 11% of this capped
|
|
1803
|
+
surface describing the same actors more than once, so browse shows one per group
|
|
1804
|
+
and `filerGroupSize` says how many others share those exact transactions. The
|
|
1805
|
+
others are not hidden: each keeps its own profile and is still returned by
|
|
1806
|
+
GET /v1/insiders?name=."""
|
|
1807
|
+
params = {
|
|
1808
|
+
"letter": letter,
|
|
1809
|
+
"page": page,
|
|
1810
|
+
"per_page": per_page,
|
|
1811
|
+
}
|
|
1812
|
+
params = {k: str(v) for k, v in params.items() if v is not None}
|
|
1813
|
+
data = await self._client._get(f"/v1/insiders/directory", params=params)
|
|
1814
|
+
return InsiderDirectoryResponse._from_dict(data)
|
|
1815
|
+
|
|
1654
1816
|
async def leaderboard(self, *, horizon: str | None = None, order: str | None = None, min_trades: int | None = None, limit: int | None = None) -> InsiderLeaderboardResponse:
|
|
1655
1817
|
"""Ranked leaderboard of insiders by buy track-record (Business plan+)
|
|
1656
1818
|
|
|
@@ -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
|
|
@@ -82,13 +82,13 @@ asyncio.run(main())
|
|
|
82
82
|
| Resource | Methods |
|
|
83
83
|
|---|---|
|
|
84
84
|
| `client.transactions` | `.list(**params)`, `.paginate(**params)` |
|
|
85
|
-
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
85
|
+
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
86
86
|
| `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
|
|
87
87
|
| `client.signals` | `.list(**params)`, `.paginate(**params)`, `.explain(ticker)`, `.sentiment(ticker, **params)` — Business; `.convergence(**params)` — Pro |
|
|
88
88
|
| `client.congress` | `.trades(**params)`, `.politicians(**params)` *(Pro)*, `.politician(id_or_slug)` *(Pro)*, `.ticker(ticker)` *(Pro)* |
|
|
89
89
|
| `client.form144` | `.list(**params)` — Business plan |
|
|
90
90
|
| `client.holdings` | `.list(**params)`, `.managers(**params)` — Business plan |
|
|
91
|
-
| `client.filings` | `.recent(**params)`, `.get(accession_number)` |
|
|
91
|
+
| `client.filings` | `.list(**params)`, `.recent(**params)`, `.get(accession_number)` |
|
|
92
92
|
| `client.stats` | `.get()` — public, no key required |
|
|
93
93
|
| `client.data_quality` | `.get()` — public, no key required |
|
|
94
94
|
| `client.status` | `.history(**params)` |
|
|
@@ -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
|
|
@@ -387,3 +387,47 @@ def test_nested_objects_hydrate_all_the_way_down(client: Form4ApiClient) -> None
|
|
|
387
387
|
assert result.career.returns.avg_return3m == 0.109
|
|
388
388
|
# And through a list, not just a single object.
|
|
389
389
|
assert result.career.companies[0].ticker == "AAPL"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# The two methods that arrived by derivation rather than by a hand-written
|
|
393
|
+
# METHOD_NAMES entry. Codegen could not run at all between 2026-08-04 and
|
|
394
|
+
# 2026-08-25, so these are the first endpoints to reach this SDK without anyone
|
|
395
|
+
# naming them — worth pinning that they are wired to the right paths and not
|
|
396
|
+
# just present on the class.
|
|
397
|
+
@respx.mock
|
|
398
|
+
def test_filings_list_hits_path_and_forwards_filters(client: Form4ApiClient) -> None:
|
|
399
|
+
route = respx.get(f"{BASE}/v1/filings").mock(return_value=httpx.Response(200, json=[]))
|
|
400
|
+
client.filings.list(ticker="AAPL", per_page=5)
|
|
401
|
+
|
|
402
|
+
request = route.calls.last.request
|
|
403
|
+
assert request.url.path == "/v1/filings"
|
|
404
|
+
assert request.url.params["ticker"] == "AAPL"
|
|
405
|
+
assert request.url.params["per_page"] == "5"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@respx.mock
|
|
409
|
+
def test_filings_list_is_distinct_from_filings_recent(client: Form4ApiClient) -> None:
|
|
410
|
+
# Derivation stripped the resource from both operationIds; if it had
|
|
411
|
+
# collapsed them, one would silently shadow the other.
|
|
412
|
+
route = respx.get(f"{BASE}/v1/filings/recent").mock(return_value=httpx.Response(200, json=[]))
|
|
413
|
+
client.filings.recent()
|
|
414
|
+
assert route.calls.last.request.url.path == "/v1/filings/recent"
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@respx.mock
|
|
418
|
+
def test_insiders_directory_hits_path_and_forwards_letter(client: Form4ApiClient) -> None:
|
|
419
|
+
route = respx.get(f"{BASE}/v1/insiders/directory").mock(
|
|
420
|
+
return_value=httpx.Response(200, json={"letters": [], "insiders": []})
|
|
421
|
+
)
|
|
422
|
+
client.insiders.directory(letter="S", per_page=200)
|
|
423
|
+
|
|
424
|
+
request = route.calls.last.request
|
|
425
|
+
assert request.url.path == "/v1/insiders/directory"
|
|
426
|
+
assert request.url.params["letter"] == "S"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@respx.mock
|
|
430
|
+
def test_insiders_directory_does_not_shadow_insiders_list(client: Form4ApiClient) -> None:
|
|
431
|
+
route = respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[]))
|
|
432
|
+
client.insiders.list()
|
|
433
|
+
assert route.calls.last.request.url.path == "/v1/insiders"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""The generator derives a method name when no override is pinned.
|
|
2
|
+
|
|
3
|
+
It used to raise instead, which is why this SDK could not regenerate at all
|
|
4
|
+
between 2026-08-04 (/v1/filings) and 2026-08-25 (/v1/insiders/directory): a new
|
|
5
|
+
backend endpoint took codegen down until someone hand-added a line, and with CI
|
|
6
|
+
billing-blocked nobody saw it go red.
|
|
7
|
+
|
|
8
|
+
So the rule is load-bearing and pinned here. Deriving a name wrong is worse
|
|
9
|
+
than not deriving one — the method ships, someone imports it, and correcting it
|
|
10
|
+
afterwards is a breaking rename.
|
|
11
|
+
|
|
12
|
+
Mirrors tests/methodNames.test.ts in the JS SDK. The two derivations must agree
|
|
13
|
+
on which words are dropped; they differ only in casing.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "codegen"))
|
|
22
|
+
|
|
23
|
+
from method_name import derive_method_name # noqa: E402
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_drops_the_resource_the_method_already_lives_on() -> None:
|
|
27
|
+
# filings.get_recent_filings() says "filings" twice.
|
|
28
|
+
assert derive_method_name("GetRecentFilings", "filings") == "recent"
|
|
29
|
+
assert derive_method_name("GetInsiderScorecard", "insiders") == "scorecard"
|
|
30
|
+
assert derive_method_name("GetConvergenceSignals", "signals") == "convergence"
|
|
31
|
+
assert derive_method_name("GetStatusHistory", "status") == "history"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_falls_back_to_the_verb_when_the_resource_was_the_whole_name() -> None:
|
|
35
|
+
# What makes companies.list() and filings.get() read correctly.
|
|
36
|
+
assert derive_method_name("ListCompanies", "companies") == "list"
|
|
37
|
+
assert derive_method_name("ListInsiders", "insiders") == "list"
|
|
38
|
+
assert derive_method_name("GetFiling", "filings") == "get"
|
|
39
|
+
assert derive_method_name("GetDataQuality", "data_quality") == "get"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_derives_the_two_endpoints_that_had_been_breaking_codegen() -> None:
|
|
43
|
+
assert derive_method_name("ListFilings", "filings") == "list"
|
|
44
|
+
assert derive_method_name("GetInsiderDirectory", "insiders") == "directory"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_matches_exact_or_simple_plural_never_a_prefix() -> None:
|
|
48
|
+
# A looser test would strip "Sentiment" for a resource called "signals"
|
|
49
|
+
# and collapse two different endpoints onto signals.get().
|
|
50
|
+
assert derive_method_name("GetSentiment", "signals") == "sentiment"
|
|
51
|
+
assert derive_method_name("ListManagers", "holdings") == "managers"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_handles_a_resource_carrying_digits() -> None:
|
|
55
|
+
# "Form144" must stay one word. Split into "Form" + "144", neither half
|
|
56
|
+
# matches the resource and this derives to form144() instead of list().
|
|
57
|
+
assert derive_method_name("ListForm144", "form144") == "list"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_snake_cases_a_multi_word_remainder() -> None:
|
|
61
|
+
# The one place the two SDKs differ: JS camelCases this to tickerRollup.
|
|
62
|
+
assert derive_method_name("GetCongressTickerRollup", "congress") == "ticker_rollup"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_already_published_names_stay_pinned() -> None:
|
|
66
|
+
"""Every shipped name is kept as an explicit override even where the
|
|
67
|
+
derived value agrees, so no upstream operationId rename can quietly change
|
|
68
|
+
a method someone has already imported."""
|
|
69
|
+
source = (Path(__file__).resolve().parents[1] / "codegen" / "generate.py").read_text(
|
|
70
|
+
encoding="utf-8"
|
|
71
|
+
)
|
|
72
|
+
block = source[source.index("METHOD_NAME_OVERRIDES = {") :]
|
|
73
|
+
block = block[: block.index("\n}")]
|
|
74
|
+
|
|
75
|
+
# These two would derive to something else entirely — the case the
|
|
76
|
+
# override list exists for.
|
|
77
|
+
assert '"GetCongressTickerRollup": "ticker"' in block
|
|
78
|
+
assert '"GetPublicStats": "get"' in block
|
|
79
|
+
|
|
80
|
+
for operation_id in (
|
|
81
|
+
"ListCompanies",
|
|
82
|
+
"ListCongressTrades",
|
|
83
|
+
"GetInsiderLeaderboard",
|
|
84
|
+
"ListHoldings",
|
|
85
|
+
"ExplainSignal",
|
|
86
|
+
"GetSentiment",
|
|
87
|
+
):
|
|
88
|
+
assert f'"{operation_id}":' in block, f"{operation_id} must stay pinned"
|
|
@@ -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
|