form4api 0.8.0__tar.gz → 0.9.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.8.0 → form4api-0.9.0}/PKG-INFO +36 -1
- {form4api-0.8.0 → form4api-0.9.0}/README.md +35 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/__init__.py +6 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/_client.py +25 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/_generated.py +62 -4
- {form4api-0.8.0 → form4api-0.9.0}/form4api/_types.py +43 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/PKG-INFO +36 -1
- {form4api-0.8.0 → form4api-0.9.0}/pyproject.toml +1 -1
- {form4api-0.8.0 → form4api-0.9.0}/tests/test_client.py +127 -0
- {form4api-0.8.0 → form4api-0.9.0}/tests/test_generated.py +48 -1
- {form4api-0.8.0 → form4api-0.9.0}/LICENSE +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/_errors.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/_webhook_utils.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/__init__.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_companies.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_insiders.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_signals.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_transactions.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_webhooks.py +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/SOURCES.txt +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/dependency_links.txt +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/requires.txt +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/top_level.txt +0 -0
- {form4api-0.8.0 → form4api-0.9.0}/setup.cfg +0 -0
- {form4api-0.8.0 → form4api-0.9.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.9.0
|
|
4
4
|
Summary: Python client for the Form4API — real-time SEC Form 4 insider trading, Form 144 intent-to-sell, institutional 13F-HR, and congressional STOCK Act trading data, including the insider/Congress convergence signal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://www.form4api.com
|
|
@@ -81,6 +81,7 @@ asyncio.run(main())
|
|
|
81
81
|
|
|
82
82
|
| Resource | Methods |
|
|
83
83
|
|---|---|
|
|
84
|
+
| `client.search(q, limit=None)` | Combined company + insider lookup — not namespaced under a resource |
|
|
84
85
|
| `client.transactions` | `.list(**params)`, `.paginate(**params)` |
|
|
85
86
|
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
86
87
|
| `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
|
|
@@ -94,6 +95,19 @@ asyncio.run(main())
|
|
|
94
95
|
| `client.status` | `.history(**params)` |
|
|
95
96
|
| `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
|
|
96
97
|
|
|
98
|
+
**Congress date quality.** Each congress trade (and convergence leg) has
|
|
99
|
+
`disclosure_lag_days: int | None` and `date_quality: str | None`. When the
|
|
100
|
+
filing's own dates are impossible or implausible, the lag is `None` and
|
|
101
|
+
`date_quality` is one of `"transaction_after_disclosure"`,
|
|
102
|
+
`"future_transaction_date"` or `"implausible_lag"`; the raw dates are still
|
|
103
|
+
returned. A lag over 45 days is not a legal finding.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
for t in client.congress.trades(ticker="NVDA"):
|
|
107
|
+
lag = f"dates unreliable ({t.date_quality})" if t.disclosure_lag_days is None else f"{t.disclosure_lag_days}d"
|
|
108
|
+
print(t.politician.full_name, t.transaction_date, lag)
|
|
109
|
+
```
|
|
110
|
+
|
|
97
111
|
Every plan-gated endpoint the API exposes has a typed method here. Calling one
|
|
98
112
|
your key isn't entitled to raises `PlanError` (HTTP 402) carrying
|
|
99
113
|
`required_plan`, `current_plan`, and `upgrade_url` rather than failing opaquely.
|
|
@@ -191,6 +205,27 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
|
|
|
191
205
|
| `state_of_incorporation` | `str \| None` | Two-letter state code |
|
|
192
206
|
| `website` | `str \| None` | Company website as filed with SEC |
|
|
193
207
|
|
|
208
|
+
### Search
|
|
209
|
+
|
|
210
|
+
`client.search(q, limit=None)` looks up companies and insiders by name or
|
|
211
|
+
ticker in a single call — useful for a combined search box where you don't
|
|
212
|
+
know in advance whether the user typed a ticker or a person's name. `q` must
|
|
213
|
+
be 2-64 characters; outside that range the API returns HTTP 400 and the SDK
|
|
214
|
+
raises `Form4ApiError` with `error_code` `"QUERY_TOO_SHORT"` or
|
|
215
|
+
`"QUERY_TOO_LONG"`. Insider matching is whitespace-tokenised, so `"tim cook"`
|
|
216
|
+
matches an insider named `"Cook Timothy D"`. `limit` caps the number of
|
|
217
|
+
results per array (1-20, API default 8).
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
result = client.search("tim cook")
|
|
221
|
+
for c in result.companies:
|
|
222
|
+
print(c.ticker, c.name, c.cik)
|
|
223
|
+
for i in result.insiders:
|
|
224
|
+
# i.ticker is a ticker associated with the insider and may be None —
|
|
225
|
+
# its exact meaning is still settling on the backend.
|
|
226
|
+
print(i.cik, i.name, i.title, i.ticker)
|
|
227
|
+
```
|
|
228
|
+
|
|
194
229
|
### Pagination
|
|
195
230
|
|
|
196
231
|
`paginate()` pages through the data until it runs out (a short or empty page)
|
|
@@ -62,6 +62,7 @@ asyncio.run(main())
|
|
|
62
62
|
|
|
63
63
|
| Resource | Methods |
|
|
64
64
|
|---|---|
|
|
65
|
+
| `client.search(q, limit=None)` | Combined company + insider lookup — not namespaced under a resource |
|
|
65
66
|
| `client.transactions` | `.list(**params)`, `.paginate(**params)` |
|
|
66
67
|
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
67
68
|
| `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
|
|
@@ -75,6 +76,19 @@ asyncio.run(main())
|
|
|
75
76
|
| `client.status` | `.history(**params)` |
|
|
76
77
|
| `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
|
|
77
78
|
|
|
79
|
+
**Congress date quality.** Each congress trade (and convergence leg) has
|
|
80
|
+
`disclosure_lag_days: int | None` and `date_quality: str | None`. When the
|
|
81
|
+
filing's own dates are impossible or implausible, the lag is `None` and
|
|
82
|
+
`date_quality` is one of `"transaction_after_disclosure"`,
|
|
83
|
+
`"future_transaction_date"` or `"implausible_lag"`; the raw dates are still
|
|
84
|
+
returned. A lag over 45 days is not a legal finding.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
for t in client.congress.trades(ticker="NVDA"):
|
|
88
|
+
lag = f"dates unreliable ({t.date_quality})" if t.disclosure_lag_days is None else f"{t.disclosure_lag_days}d"
|
|
89
|
+
print(t.politician.full_name, t.transaction_date, lag)
|
|
90
|
+
```
|
|
91
|
+
|
|
78
92
|
Every plan-gated endpoint the API exposes has a typed method here. Calling one
|
|
79
93
|
your key isn't entitled to raises `PlanError` (HTTP 402) carrying
|
|
80
94
|
`required_plan`, `current_plan`, and `upgrade_url` rather than failing opaquely.
|
|
@@ -172,6 +186,27 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
|
|
|
172
186
|
| `state_of_incorporation` | `str \| None` | Two-letter state code |
|
|
173
187
|
| `website` | `str \| None` | Company website as filed with SEC |
|
|
174
188
|
|
|
189
|
+
### Search
|
|
190
|
+
|
|
191
|
+
`client.search(q, limit=None)` looks up companies and insiders by name or
|
|
192
|
+
ticker in a single call — useful for a combined search box where you don't
|
|
193
|
+
know in advance whether the user typed a ticker or a person's name. `q` must
|
|
194
|
+
be 2-64 characters; outside that range the API returns HTTP 400 and the SDK
|
|
195
|
+
raises `Form4ApiError` with `error_code` `"QUERY_TOO_SHORT"` or
|
|
196
|
+
`"QUERY_TOO_LONG"`. Insider matching is whitespace-tokenised, so `"tim cook"`
|
|
197
|
+
matches an insider named `"Cook Timothy D"`. `limit` caps the number of
|
|
198
|
+
results per array (1-20, API default 8).
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
result = client.search("tim cook")
|
|
202
|
+
for c in result.companies:
|
|
203
|
+
print(c.ticker, c.name, c.cik)
|
|
204
|
+
for i in result.insiders:
|
|
205
|
+
# i.ticker is a ticker associated with the insider and may be None —
|
|
206
|
+
# its exact meaning is still settling on the backend.
|
|
207
|
+
print(i.cik, i.name, i.title, i.ticker)
|
|
208
|
+
```
|
|
209
|
+
|
|
175
210
|
### Pagination
|
|
176
211
|
|
|
177
212
|
`paginate()` pages through the data until it runs out (a short or empty page)
|
|
@@ -12,6 +12,9 @@ from form4api._types import (
|
|
|
12
12
|
Insider,
|
|
13
13
|
InsiderSignal,
|
|
14
14
|
InstitutionalOwnership,
|
|
15
|
+
SearchCompany,
|
|
16
|
+
SearchInsider,
|
|
17
|
+
SearchResults,
|
|
15
18
|
TopHolder,
|
|
16
19
|
Transaction,
|
|
17
20
|
WebhookCreated,
|
|
@@ -34,6 +37,9 @@ __all__ = [
|
|
|
34
37
|
"Company",
|
|
35
38
|
"InsiderSignal",
|
|
36
39
|
"InstitutionalOwnership",
|
|
40
|
+
"SearchCompany",
|
|
41
|
+
"SearchInsider",
|
|
42
|
+
"SearchResults",
|
|
37
43
|
"TopHolder",
|
|
38
44
|
"WebhookCreated",
|
|
39
45
|
"WebhookEvent",
|
|
@@ -9,6 +9,7 @@ from typing import Any, TypeVar
|
|
|
9
9
|
import httpx
|
|
10
10
|
|
|
11
11
|
from form4api._errors import AuthError, Form4ApiError, NotFoundError, PlanError, RateLimitError
|
|
12
|
+
from form4api._types import SearchResults
|
|
12
13
|
|
|
13
14
|
# Sent as the User-Agent so the backend can attribute traffic to the Python SDK
|
|
14
15
|
# channel (the admin dashboard buckets by client). Read from installed package
|
|
@@ -105,6 +106,22 @@ class Form4ApiClient:
|
|
|
105
106
|
def close(self) -> None:
|
|
106
107
|
self._http.close()
|
|
107
108
|
|
|
109
|
+
def search(self, q: str, limit: int | None = None) -> SearchResults:
|
|
110
|
+
"""Search companies and insiders by name or ticker in one call.
|
|
111
|
+
|
|
112
|
+
``q`` must be 2-64 characters, or the API raises `Form4ApiError`
|
|
113
|
+
(HTTP 400) with `error_code` `QUERY_TOO_SHORT` or `QUERY_TOO_LONG`.
|
|
114
|
+
Insider matching is whitespace-tokenised, so `"tim cook"` matches an
|
|
115
|
+
insider named `"Cook Timothy D"`. `limit` caps the number of results
|
|
116
|
+
per array (1-20, API default 8) and defaults to the API's own default
|
|
117
|
+
when omitted. Free tier, no plan gate.
|
|
118
|
+
"""
|
|
119
|
+
params: dict[str, str] = {"q": q}
|
|
120
|
+
if limit is not None:
|
|
121
|
+
params["limit"] = str(limit)
|
|
122
|
+
data = self._get("/v1/search", params)
|
|
123
|
+
return SearchResults._from_dict(data)
|
|
124
|
+
|
|
108
125
|
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
109
126
|
url = self._base_url + path
|
|
110
127
|
last_exc: Exception | None = None
|
|
@@ -224,6 +241,14 @@ class AsyncForm4ApiClient:
|
|
|
224
241
|
async def close(self) -> None:
|
|
225
242
|
await self._http.aclose()
|
|
226
243
|
|
|
244
|
+
async def search(self, q: str, limit: int | None = None) -> SearchResults:
|
|
245
|
+
"""Async twin of `Form4ApiClient.search`. See there for parameter details."""
|
|
246
|
+
params: dict[str, str] = {"q": q}
|
|
247
|
+
if limit is not None:
|
|
248
|
+
params["limit"] = str(limit)
|
|
249
|
+
data = await self._get("/v1/search", params)
|
|
250
|
+
return SearchResults._from_dict(data)
|
|
251
|
+
|
|
227
252
|
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
228
253
|
url = self._base_url + path
|
|
229
254
|
last_exc: Exception | None = None
|
|
@@ -233,6 +233,7 @@ class CongressTradeDto:
|
|
|
233
233
|
amount_low: float | None = None
|
|
234
234
|
asset_name: str | None = None
|
|
235
235
|
asset_type: str | None = None
|
|
236
|
+
date_quality: str | None = None
|
|
236
237
|
disclosure_date: str | None = None
|
|
237
238
|
disclosure_lag_days: int | None = None
|
|
238
239
|
owner_type: str | None = None
|
|
@@ -260,6 +261,7 @@ class ConvergenceCongressLegDto:
|
|
|
260
261
|
amount_high: float | None = None
|
|
261
262
|
amount_low: float | None = None
|
|
262
263
|
bioguide_id: str | None = None
|
|
264
|
+
date_quality: str | None = None
|
|
263
265
|
disclosure_date: str | None = None
|
|
264
266
|
disclosure_lag_days: int | None = None
|
|
265
267
|
full_name: str | None = None
|
|
@@ -1108,6 +1110,62 @@ class ScorecardTradeRef:
|
|
|
1108
1110
|
return cls(**{k: v for k, v in data.items() if k in known})
|
|
1109
1111
|
|
|
1110
1112
|
|
|
1113
|
+
@dataclass
|
|
1114
|
+
class SearchCompanyResult:
|
|
1115
|
+
cik: str | None = None
|
|
1116
|
+
name: str | None = None
|
|
1117
|
+
ticker: str | None = None
|
|
1118
|
+
|
|
1119
|
+
@classmethod
|
|
1120
|
+
def _from_dict(cls, data: dict) -> "SearchCompanyResult":
|
|
1121
|
+
"""Build from an API payload, ignoring unknown keys.
|
|
1122
|
+
|
|
1123
|
+
Constructing with **data directly (as the hand-written resources do)
|
|
1124
|
+
means the SDK raises TypeError the moment the backend adds a field.
|
|
1125
|
+
Filtering keeps older SDK versions working against a newer API."""
|
|
1126
|
+
known = {f.name for f in fields(cls)}
|
|
1127
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@dataclass
|
|
1131
|
+
class SearchInsiderResult:
|
|
1132
|
+
cik: str | None = None
|
|
1133
|
+
name: str | None = None
|
|
1134
|
+
ticker: str | None = None
|
|
1135
|
+
title: str | None = None
|
|
1136
|
+
|
|
1137
|
+
@classmethod
|
|
1138
|
+
def _from_dict(cls, data: dict) -> "SearchInsiderResult":
|
|
1139
|
+
"""Build from an API payload, ignoring unknown keys.
|
|
1140
|
+
|
|
1141
|
+
Constructing with **data directly (as the hand-written resources do)
|
|
1142
|
+
means the SDK raises TypeError the moment the backend adds a field.
|
|
1143
|
+
Filtering keeps older SDK versions working against a newer API."""
|
|
1144
|
+
known = {f.name for f in fields(cls)}
|
|
1145
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@dataclass
|
|
1149
|
+
class SearchResponse:
|
|
1150
|
+
companies: list[SearchCompanyResult] | None = None
|
|
1151
|
+
insiders: list[SearchInsiderResult] | None = None
|
|
1152
|
+
|
|
1153
|
+
@classmethod
|
|
1154
|
+
def _from_dict(cls, data: dict) -> "SearchResponse":
|
|
1155
|
+
"""Build from an API payload, ignoring unknown keys.
|
|
1156
|
+
|
|
1157
|
+
Constructing with **data directly (as the hand-written resources do)
|
|
1158
|
+
means the SDK raises TypeError the moment the backend adds a field.
|
|
1159
|
+
Filtering keeps older SDK versions working against a newer API."""
|
|
1160
|
+
known = {f.name for f in fields(cls)}
|
|
1161
|
+
kwargs = {k: v for k, v in data.items() if k in known}
|
|
1162
|
+
if isinstance(kwargs.get("companies"), list):
|
|
1163
|
+
kwargs["companies"] = [SearchCompanyResult._from_dict(i) if isinstance(i, dict) else i for i in kwargs["companies"]]
|
|
1164
|
+
if isinstance(kwargs.get("insiders"), list):
|
|
1165
|
+
kwargs["insiders"] = [SearchInsiderResult._from_dict(i) if isinstance(i, dict) else i for i in kwargs["insiders"]]
|
|
1166
|
+
return cls(**kwargs)
|
|
1167
|
+
|
|
1168
|
+
|
|
1111
1169
|
@dataclass
|
|
1112
1170
|
class SentimentMonthEntry:
|
|
1113
1171
|
buy_count: int | None = None
|
|
@@ -1445,7 +1503,7 @@ class GeneratedCongressResource:
|
|
|
1445
1503
|
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, limit: int | None = None) -> list[CongressTradeDto]:
|
|
1446
1504
|
"""Query congressional STOCK Act trades (Free+, plan-clamped disclosure window)
|
|
1447
1505
|
|
|
1448
|
-
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+). To check whether an insider cluster-buy lines up with a congressional purchase in the same ticker, use GET /v1/signals/convergence (Pro+); for the company's own profile use GET /v1/companies/{ticker} (Free). `limit` is accepted as an alias for `per_page`. Query runs live against the database — no caching."""
|
|
1506
|
+
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) in whole days — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. DATE QUALITY: source dates are sometimes wrong. When a row's dates are impossible or implausible, disclosureLagDays is null and dateQuality names the reason: transaction_after_disclosure (transaction date later than disclosure date), future_transaction_date (transaction date in the future), or implausible_lag (lag above 1500 days, almost always a mistyped year). dateQuality is null on a normal row. Flagged rows are never dropped and their raw transactionDate and disclosureDate are returned unchanged. Genuine late filings with a long but plausible lag keep their disclosureLagDays. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). To check whether an insider cluster-buy lines up with a congressional purchase in the same ticker, use GET /v1/signals/convergence (Pro+); for the company's own profile use GET /v1/companies/{ticker} (Free). `limit` is accepted as an alias for `per_page`. Query runs live against the database — no caching."""
|
|
1449
1507
|
params = {
|
|
1450
1508
|
"ticker": ticker,
|
|
1451
1509
|
"politician": politician,
|
|
@@ -1510,7 +1568,7 @@ class GeneratedAsyncCongressResource:
|
|
|
1510
1568
|
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, limit: int | None = None) -> list[CongressTradeDto]:
|
|
1511
1569
|
"""Query congressional STOCK Act trades (Free+, plan-clamped disclosure window)
|
|
1512
1570
|
|
|
1513
|
-
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+). To check whether an insider cluster-buy lines up with a congressional purchase in the same ticker, use GET /v1/signals/convergence (Pro+); for the company's own profile use GET /v1/companies/{ticker} (Free). `limit` is accepted as an alias for `per_page`. Query runs live against the database — no caching."""
|
|
1571
|
+
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) in whole days — the STOCK Act allows up to 45 days of lag, so "real-time" here means minutes-after-disclosure, not minutes-after-trade. DATE QUALITY: source dates are sometimes wrong. When a row's dates are impossible or implausible, disclosureLagDays is null and dateQuality names the reason: transaction_after_disclosure (transaction date later than disclosure date), future_transaction_date (transaction date in the future), or implausible_lag (lag above 1500 days, almost always a mistyped year). dateQuality is null on a normal row. Flagged rows are never dropped and their raw transactionDate and disclosureDate are returned unchanged. Genuine late filings with a long but plausible lag keep their disclosureLagDays. For per-politician or per-ticker rollups use GET /v1/congress/politicians, /v1/congress/politicians/{idOrSlug}, or /v1/congress/tickers/{ticker} (all Pro+). To check whether an insider cluster-buy lines up with a congressional purchase in the same ticker, use GET /v1/signals/convergence (Pro+); for the company's own profile use GET /v1/companies/{ticker} (Free). `limit` is accepted as an alias for `per_page`. Query runs live against the database — no caching."""
|
|
1514
1572
|
params = {
|
|
1515
1573
|
"ticker": ticker,
|
|
1516
1574
|
"politician": politician,
|
|
@@ -1979,7 +2037,7 @@ class GeneratedSignalsResource:
|
|
|
1979
2037
|
def convergence(self, *, ticker: str | None = None, window_days: int | None = None, lookback_days: int | None = None, page: int | None = None, per_page: int | None = None, limit: int | None = None) -> list[ConvergenceEntryDto]:
|
|
1980
2038
|
"""Insider cluster-buy x congressional-purchase convergence (Pro plan+)
|
|
1981
2039
|
|
|
1982
|
-
Returns the tickers where an insider cluster-buy (InsiderSignal.IsClusterBuy) and at least one non-superseded congressional PURCHASE happened within window_days of EACH OTHER, restricted to convergences where the MORE RECENT of the pair's two dates is within a trailing lookback_days (so this surfaces CURRENT convergences, not ancient history). DEFINITION: for each result, insider.signalDate is the SignalDate of the qualifying cluster-buy signal with the most recent date (insider.insiderCount is that same signal's count — never summed or maxed across multiple signals), and congress is every non-superseded congressional purchase that paired with at least one qualifying cluster-buy (not every purchase in the window — only the ones that actually paired). firstSeen/lastSeen are the earliest/most recent dates among all qualifying insider and congress dates for that ticker. STRENGTH is documented arithmetic, NOT a black-box or predictive/ML score: strength = (distinct congressional purchasers among the qualifying legs) x (the representative signal's insiderCount) — a plain multiplication of two observed counts, nothing more. HONESTY: every congress leg always carries both amountLow and amountHigh (STOCK Act discloses ranges, never exact figures — never combined into a fabricated midpoint) and disclosureLagDays = (disclosureDate - transactionDate);
|
|
2040
|
+
Returns the tickers where an insider cluster-buy (InsiderSignal.IsClusterBuy) and at least one non-superseded congressional PURCHASE happened within window_days of EACH OTHER, restricted to convergences where the MORE RECENT of the pair's two dates is within a trailing lookback_days (so this surfaces CURRENT convergences, not ancient history). DEFINITION: for each result, insider.signalDate is the SignalDate of the qualifying cluster-buy signal with the most recent date (insider.insiderCount is that same signal's count — never summed or maxed across multiple signals), and congress is every non-superseded congressional purchase that paired with at least one qualifying cluster-buy (not every purchase in the window — only the ones that actually paired). firstSeen/lastSeen are the earliest/most recent dates among all qualifying insider and congress dates for that ticker. STRENGTH is documented arithmetic, NOT a black-box or predictive/ML score: strength = (distinct congressional purchasers among the qualifying legs) x (the representative signal's insiderCount) — a plain multiplication of two observed counts, nothing more. HONESTY: every congress leg always carries both amountLow and amountHigh (STOCK Act discloses ranges, never exact figures — never combined into a fabricated midpoint) and disclosureLagDays = (disclosureDate - transactionDate). Congressional trades whose dates are impossible or implausible (a dateQuality flag: transaction_after_disclosure, future_transaction_date or implausible_lag) are excluded from detection, so they never appear as legs and never create a convergence; dateQuality is therefore null on every leg returned here and disclosureLagDays is always set. Congressional trades are disclosed up to 45 days after the actual trade under the STOCK Act, so this endpoint is detection/monitoring of what insiders AND members of Congress have DISCLOSED buying, not a claim of predictive edge, alpha, or win rate — no performance numbers are computed or implied anywhere in this response. window_days and lookback_days are both caller-overridable with clamps (see each parameter's own description for the exact bounds). Requires Pro plan or higher (402 PLAN_REQUIRED on Free/Starter). `limit` is accepted as an alias for `per_page`. Query runs live against the database — no caching."""
|
|
1983
2041
|
params = {
|
|
1984
2042
|
"ticker": ticker,
|
|
1985
2043
|
"window_days": window_days,
|
|
@@ -2022,7 +2080,7 @@ class GeneratedAsyncSignalsResource:
|
|
|
2022
2080
|
async def convergence(self, *, ticker: str | None = None, window_days: int | None = None, lookback_days: int | None = None, page: int | None = None, per_page: int | None = None, limit: int | None = None) -> list[ConvergenceEntryDto]:
|
|
2023
2081
|
"""Insider cluster-buy x congressional-purchase convergence (Pro plan+)
|
|
2024
2082
|
|
|
2025
|
-
Returns the tickers where an insider cluster-buy (InsiderSignal.IsClusterBuy) and at least one non-superseded congressional PURCHASE happened within window_days of EACH OTHER, restricted to convergences where the MORE RECENT of the pair's two dates is within a trailing lookback_days (so this surfaces CURRENT convergences, not ancient history). DEFINITION: for each result, insider.signalDate is the SignalDate of the qualifying cluster-buy signal with the most recent date (insider.insiderCount is that same signal's count — never summed or maxed across multiple signals), and congress is every non-superseded congressional purchase that paired with at least one qualifying cluster-buy (not every purchase in the window — only the ones that actually paired). firstSeen/lastSeen are the earliest/most recent dates among all qualifying insider and congress dates for that ticker. STRENGTH is documented arithmetic, NOT a black-box or predictive/ML score: strength = (distinct congressional purchasers among the qualifying legs) x (the representative signal's insiderCount) — a plain multiplication of two observed counts, nothing more. HONESTY: every congress leg always carries both amountLow and amountHigh (STOCK Act discloses ranges, never exact figures — never combined into a fabricated midpoint) and disclosureLagDays = (disclosureDate - transactionDate);
|
|
2083
|
+
Returns the tickers where an insider cluster-buy (InsiderSignal.IsClusterBuy) and at least one non-superseded congressional PURCHASE happened within window_days of EACH OTHER, restricted to convergences where the MORE RECENT of the pair's two dates is within a trailing lookback_days (so this surfaces CURRENT convergences, not ancient history). DEFINITION: for each result, insider.signalDate is the SignalDate of the qualifying cluster-buy signal with the most recent date (insider.insiderCount is that same signal's count — never summed or maxed across multiple signals), and congress is every non-superseded congressional purchase that paired with at least one qualifying cluster-buy (not every purchase in the window — only the ones that actually paired). firstSeen/lastSeen are the earliest/most recent dates among all qualifying insider and congress dates for that ticker. STRENGTH is documented arithmetic, NOT a black-box or predictive/ML score: strength = (distinct congressional purchasers among the qualifying legs) x (the representative signal's insiderCount) — a plain multiplication of two observed counts, nothing more. HONESTY: every congress leg always carries both amountLow and amountHigh (STOCK Act discloses ranges, never exact figures — never combined into a fabricated midpoint) and disclosureLagDays = (disclosureDate - transactionDate). Congressional trades whose dates are impossible or implausible (a dateQuality flag: transaction_after_disclosure, future_transaction_date or implausible_lag) are excluded from detection, so they never appear as legs and never create a convergence; dateQuality is therefore null on every leg returned here and disclosureLagDays is always set. Congressional trades are disclosed up to 45 days after the actual trade under the STOCK Act, so this endpoint is detection/monitoring of what insiders AND members of Congress have DISCLOSED buying, not a claim of predictive edge, alpha, or win rate — no performance numbers are computed or implied anywhere in this response. window_days and lookback_days are both caller-overridable with clamps (see each parameter's own description for the exact bounds). Requires Pro plan or higher (402 PLAN_REQUIRED on Free/Starter). `limit` is accepted as an alias for `per_page`. Query runs live against the database — no caching."""
|
|
2026
2084
|
params = {
|
|
2027
2085
|
"ticker": ticker,
|
|
2028
2086
|
"window_days": window_days,
|
|
@@ -111,6 +111,49 @@ class Company:
|
|
|
111
111
|
website: str | None
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
@dataclass
|
|
115
|
+
class SearchCompany:
|
|
116
|
+
ticker: str
|
|
117
|
+
name: str
|
|
118
|
+
cik: str
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def _from_dict(cls, data: dict) -> "SearchCompany":
|
|
122
|
+
known = {f.name for f in fields(cls)}
|
|
123
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class SearchInsider:
|
|
128
|
+
cik: str
|
|
129
|
+
name: str
|
|
130
|
+
title: str | None
|
|
131
|
+
# A ticker associated with the insider (may be None).
|
|
132
|
+
ticker: str | None
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def _from_dict(cls, data: dict) -> "SearchInsider":
|
|
136
|
+
known = {f.name for f in fields(cls)}
|
|
137
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class SearchResults:
|
|
142
|
+
companies: list[SearchCompany]
|
|
143
|
+
insiders: list[SearchInsider]
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def _from_dict(cls, data: dict) -> "SearchResults":
|
|
147
|
+
"""Build from the `/v1/search` payload, hydrating both arrays into
|
|
148
|
+
their own dataclasses rather than leaving them as raw dicts (see
|
|
149
|
+
Transaction._from_dict for why filtering unknown keys matters here
|
|
150
|
+
too — a field the backend adds later must not crash an older SDK)."""
|
|
151
|
+
return cls(
|
|
152
|
+
companies=[SearchCompany._from_dict(c) for c in (data.get("companies") or [])],
|
|
153
|
+
insiders=[SearchInsider._from_dict(i) for i in (data.get("insiders") or [])],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
114
157
|
@dataclass
|
|
115
158
|
class InsiderSignal:
|
|
116
159
|
ticker: str | None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: form4api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Python client for the Form4API — real-time SEC Form 4 insider trading, Form 144 intent-to-sell, institutional 13F-HR, and congressional STOCK Act trading data, including the insider/Congress convergence signal
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://www.form4api.com
|
|
@@ -81,6 +81,7 @@ asyncio.run(main())
|
|
|
81
81
|
|
|
82
82
|
| Resource | Methods |
|
|
83
83
|
|---|---|
|
|
84
|
+
| `client.search(q, limit=None)` | Combined company + insider lookup — not namespaced under a resource |
|
|
84
85
|
| `client.transactions` | `.list(**params)`, `.paginate(**params)` |
|
|
85
86
|
| `client.insiders` | `.search(name, **params)`, `.get(cik)`, `.list(**params)`, `.directory(**params)`, `.transactions(cik, **params)`, `.summary(cik)` *(Pro)*, `.scorecard(cik)` *(Pro)*, `.leaderboard(**params)` *(Business)* |
|
|
86
87
|
| `client.companies` | `.get(ticker)`, `.insiders(ticker)`, `.list(**params)` |
|
|
@@ -94,6 +95,19 @@ asyncio.run(main())
|
|
|
94
95
|
| `client.status` | `.history(**params)` |
|
|
95
96
|
| `client.webhooks` | `.create(url, event_types)`, `.list()`, `.delete(id)`, `.events(**params)` |
|
|
96
97
|
|
|
98
|
+
**Congress date quality.** Each congress trade (and convergence leg) has
|
|
99
|
+
`disclosure_lag_days: int | None` and `date_quality: str | None`. When the
|
|
100
|
+
filing's own dates are impossible or implausible, the lag is `None` and
|
|
101
|
+
`date_quality` is one of `"transaction_after_disclosure"`,
|
|
102
|
+
`"future_transaction_date"` or `"implausible_lag"`; the raw dates are still
|
|
103
|
+
returned. A lag over 45 days is not a legal finding.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
for t in client.congress.trades(ticker="NVDA"):
|
|
107
|
+
lag = f"dates unreliable ({t.date_quality})" if t.disclosure_lag_days is None else f"{t.disclosure_lag_days}d"
|
|
108
|
+
print(t.politician.full_name, t.transaction_date, lag)
|
|
109
|
+
```
|
|
110
|
+
|
|
97
111
|
Every plan-gated endpoint the API exposes has a typed method here. Calling one
|
|
98
112
|
your key isn't entitled to raises `PlanError` (HTTP 402) carrying
|
|
99
113
|
`required_plan`, `current_plan`, and `upgrade_url` rather than failing opaquely.
|
|
@@ -191,6 +205,27 @@ client.transactions.list(min_shares=10_000, max_shares=100_000)
|
|
|
191
205
|
| `state_of_incorporation` | `str \| None` | Two-letter state code |
|
|
192
206
|
| `website` | `str \| None` | Company website as filed with SEC |
|
|
193
207
|
|
|
208
|
+
### Search
|
|
209
|
+
|
|
210
|
+
`client.search(q, limit=None)` looks up companies and insiders by name or
|
|
211
|
+
ticker in a single call — useful for a combined search box where you don't
|
|
212
|
+
know in advance whether the user typed a ticker or a person's name. `q` must
|
|
213
|
+
be 2-64 characters; outside that range the API returns HTTP 400 and the SDK
|
|
214
|
+
raises `Form4ApiError` with `error_code` `"QUERY_TOO_SHORT"` or
|
|
215
|
+
`"QUERY_TOO_LONG"`. Insider matching is whitespace-tokenised, so `"tim cook"`
|
|
216
|
+
matches an insider named `"Cook Timothy D"`. `limit` caps the number of
|
|
217
|
+
results per array (1-20, API default 8).
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
result = client.search("tim cook")
|
|
221
|
+
for c in result.companies:
|
|
222
|
+
print(c.ticker, c.name, c.cik)
|
|
223
|
+
for i in result.insiders:
|
|
224
|
+
# i.ticker is a ticker associated with the insider and may be None —
|
|
225
|
+
# its exact meaning is still settling on the backend.
|
|
226
|
+
print(i.cik, i.name, i.title, i.ticker)
|
|
227
|
+
```
|
|
228
|
+
|
|
194
229
|
### Pagination
|
|
195
230
|
|
|
196
231
|
`paginate()` pages through the data until it runs out (a short or empty page)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "form4api"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "Python client for the Form4API — real-time SEC Form 4 insider trading, Form 144 intent-to-sell, institutional 13F-HR, and congressional STOCK Act trading data, including the insider/Congress convergence signal"
|
|
9
9
|
keywords = ["insider-trading", "sec", "sec-edgar", "edgar", "form-4", "form4", "form-144", "13f", "13f-hr", "congress", "stock-act", "financial-data", "stock-market", "stocks", "fintech", "api", "sdk", "webhooks", "form4api"]
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -14,8 +14,12 @@ from form4api import (
|
|
|
14
14
|
Insider,
|
|
15
15
|
Company,
|
|
16
16
|
InsiderSignal,
|
|
17
|
+
SearchCompany,
|
|
18
|
+
SearchInsider,
|
|
19
|
+
SearchResults,
|
|
17
20
|
verify_webhook,
|
|
18
21
|
)
|
|
22
|
+
from form4api._client import AsyncForm4ApiClient
|
|
19
23
|
|
|
20
24
|
BASE = "https://api.form4api.com"
|
|
21
25
|
|
|
@@ -412,6 +416,129 @@ def test_signals_list_returns_typed_objects(client):
|
|
|
412
416
|
assert results[0].buy_sell_ratio == 2.5
|
|
413
417
|
|
|
414
418
|
|
|
419
|
+
# ── search ───────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
SEARCH_BODY = {
|
|
422
|
+
"companies": [{"ticker": "AAPL", "name": "Apple Inc.", "cik": "0000320193"}],
|
|
423
|
+
"insiders": [
|
|
424
|
+
{
|
|
425
|
+
"cik": "0001214156",
|
|
426
|
+
"name": "Cook Timothy D",
|
|
427
|
+
"title": "Chief Executive Officer",
|
|
428
|
+
"ticker": "AAPL",
|
|
429
|
+
}
|
|
430
|
+
],
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@respx.mock
|
|
435
|
+
def test_search_returns_typed_companies_and_insiders(client):
|
|
436
|
+
respx.get(f"{BASE}/v1/search").mock(return_value=httpx.Response(200, json=SEARCH_BODY))
|
|
437
|
+
result = client.search("tim cook")
|
|
438
|
+
assert isinstance(result, SearchResults)
|
|
439
|
+
assert len(result.companies) == 1
|
|
440
|
+
assert isinstance(result.companies[0], SearchCompany)
|
|
441
|
+
assert result.companies[0].ticker == "AAPL"
|
|
442
|
+
assert result.companies[0].cik == "0000320193"
|
|
443
|
+
assert len(result.insiders) == 1
|
|
444
|
+
assert isinstance(result.insiders[0], SearchInsider)
|
|
445
|
+
assert result.insiders[0].name == "Cook Timothy D"
|
|
446
|
+
assert result.insiders[0].title == "Chief Executive Officer"
|
|
447
|
+
assert result.insiders[0].ticker == "AAPL"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@respx.mock
|
|
451
|
+
def test_search_sends_q_and_omits_limit_when_not_given(client):
|
|
452
|
+
route = respx.get(f"{BASE}/v1/search").mock(return_value=httpx.Response(200, json=SEARCH_BODY))
|
|
453
|
+
client.search("aapl")
|
|
454
|
+
qs = dict(route.calls.last.request.url.params)
|
|
455
|
+
assert qs["q"] == "aapl"
|
|
456
|
+
assert "limit" not in qs
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@respx.mock
|
|
460
|
+
def test_search_sends_limit_when_given(client):
|
|
461
|
+
route = respx.get(f"{BASE}/v1/search").mock(return_value=httpx.Response(200, json=SEARCH_BODY))
|
|
462
|
+
client.search("aapl", limit=3)
|
|
463
|
+
qs = dict(route.calls.last.request.url.params)
|
|
464
|
+
assert qs["limit"] == "3"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
@respx.mock
|
|
468
|
+
def test_search_tolerates_null_insider_title_and_ticker(client):
|
|
469
|
+
body = {
|
|
470
|
+
"companies": [],
|
|
471
|
+
"insiders": [{"cik": "0001", "name": "Someone Else", "title": None, "ticker": None}],
|
|
472
|
+
}
|
|
473
|
+
respx.get(f"{BASE}/v1/search").mock(return_value=httpx.Response(200, json=body))
|
|
474
|
+
result = client.search("someone")
|
|
475
|
+
assert result.insiders[0].title is None
|
|
476
|
+
assert result.insiders[0].ticker is None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@respx.mock
|
|
480
|
+
def test_search_400_query_too_short_raises_form4_api_error(client):
|
|
481
|
+
respx.get(f"{BASE}/v1/search").mock(
|
|
482
|
+
return_value=httpx.Response(
|
|
483
|
+
400,
|
|
484
|
+
json={"error": {"code": "QUERY_TOO_SHORT", "message": "q must be at least 2 characters"}},
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
with pytest.raises(Form4ApiError) as exc:
|
|
488
|
+
client.search("a")
|
|
489
|
+
assert exc.value.status_code == 400
|
|
490
|
+
assert exc.value.error_code == "QUERY_TOO_SHORT"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@respx.mock
|
|
494
|
+
def test_search_400_query_too_long_raises_form4_api_error(client):
|
|
495
|
+
respx.get(f"{BASE}/v1/search").mock(
|
|
496
|
+
return_value=httpx.Response(
|
|
497
|
+
400,
|
|
498
|
+
json={"error": {"code": "QUERY_TOO_LONG", "message": "q must be at most 64 characters"}},
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
with pytest.raises(Form4ApiError) as exc:
|
|
502
|
+
client.search("a" * 65)
|
|
503
|
+
assert exc.value.status_code == 400
|
|
504
|
+
assert exc.value.error_code == "QUERY_TOO_LONG"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@respx.mock
|
|
508
|
+
def test_search_works_on_the_async_client():
|
|
509
|
+
"""The async twin, exercised directly rather than assumed from the sync
|
|
510
|
+
pass — see test_generated.py's history of the async client silently
|
|
511
|
+
breaking every hand-written resource while only sync tests existed."""
|
|
512
|
+
import asyncio
|
|
513
|
+
|
|
514
|
+
respx.get(f"{BASE}/v1/search").mock(return_value=httpx.Response(200, json=SEARCH_BODY))
|
|
515
|
+
|
|
516
|
+
async def go():
|
|
517
|
+
async with AsyncForm4ApiClient("test-key", max_retries=0) as c:
|
|
518
|
+
return await c.search("tim cook", limit=5)
|
|
519
|
+
|
|
520
|
+
result = asyncio.run(go())
|
|
521
|
+
assert isinstance(result, SearchResults)
|
|
522
|
+
assert result.companies[0].name == "Apple Inc."
|
|
523
|
+
assert result.insiders[0].cik == "0001214156"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@respx.mock
|
|
527
|
+
def test_search_async_sends_limit_param():
|
|
528
|
+
import asyncio
|
|
529
|
+
|
|
530
|
+
route = respx.get(f"{BASE}/v1/search").mock(return_value=httpx.Response(200, json=SEARCH_BODY))
|
|
531
|
+
|
|
532
|
+
async def go():
|
|
533
|
+
async with AsyncForm4ApiClient("test-key", max_retries=0) as c:
|
|
534
|
+
await c.search("aapl", limit=5)
|
|
535
|
+
|
|
536
|
+
asyncio.run(go())
|
|
537
|
+
qs = dict(route.calls.last.request.url.params)
|
|
538
|
+
assert qs["q"] == "aapl"
|
|
539
|
+
assert qs["limit"] == "5"
|
|
540
|
+
|
|
541
|
+
|
|
415
542
|
# ── error handling ────────────────────────────────────────────────────────────
|
|
416
543
|
|
|
417
544
|
@respx.mock
|
|
@@ -17,7 +17,7 @@ import respx
|
|
|
17
17
|
|
|
18
18
|
from form4api import Form4ApiClient
|
|
19
19
|
from form4api._client import AsyncForm4ApiClient
|
|
20
|
-
from form4api._generated import Form144Response
|
|
20
|
+
from form4api._generated import CongressTradeDto, ConvergenceCongressLegDto, Form144Response
|
|
21
21
|
|
|
22
22
|
BASE = "http://test.local"
|
|
23
23
|
|
|
@@ -431,3 +431,50 @@ def test_insiders_directory_does_not_shadow_insiders_list(client: Form4ApiClient
|
|
|
431
431
|
route = respx.get(f"{BASE}/v1/insiders").mock(return_value=httpx.Response(200, json=[]))
|
|
432
432
|
client.insiders.list()
|
|
433
433
|
assert route.calls.last.request.url.path == "/v1/insiders"
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@respx.mock
|
|
437
|
+
def test_congress_trades_parse_null_lag_and_date_quality(client: Form4ApiClient) -> None:
|
|
438
|
+
base = {
|
|
439
|
+
"ticker": "NVDA",
|
|
440
|
+
"assetName": "NVIDIA",
|
|
441
|
+
"transactionType": "Purchase",
|
|
442
|
+
"amountLow": 1001,
|
|
443
|
+
"amountHigh": 15000,
|
|
444
|
+
"disclosureDate": "2026-01-10",
|
|
445
|
+
}
|
|
446
|
+
respx.get(f"{BASE}/v1/congress/trades").mock(
|
|
447
|
+
return_value=httpx.Response(
|
|
448
|
+
200,
|
|
449
|
+
json=[
|
|
450
|
+
{**base, "transactionDate": "2026-01-01", "disclosureLagDays": 9, "dateQuality": None},
|
|
451
|
+
{
|
|
452
|
+
**base,
|
|
453
|
+
"transactionDate": "2026-02-01",
|
|
454
|
+
"disclosureLagDays": None,
|
|
455
|
+
"dateQuality": "transaction_after_disclosure",
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
rows = client.congress.trades()
|
|
461
|
+
|
|
462
|
+
assert isinstance(rows[0], CongressTradeDto)
|
|
463
|
+
assert rows[0].disclosure_lag_days == 9
|
|
464
|
+
assert rows[0].date_quality is None
|
|
465
|
+
# A flagged row: no lag, a reason code, and the raw dates unchanged.
|
|
466
|
+
assert rows[1].disclosure_lag_days is None
|
|
467
|
+
assert rows[1].date_quality == "transaction_after_disclosure"
|
|
468
|
+
assert rows[1].transaction_date == "2026-02-01"
|
|
469
|
+
assert rows[1].disclosure_date == "2026-01-10"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def test_convergence_leg_models_date_quality_as_nullable() -> None:
|
|
473
|
+
leg = ConvergenceCongressLegDto._from_dict(
|
|
474
|
+
{"full_name": "X", "disclosure_lag_days": 12, "date_quality": None, "brand_new_field": 1}
|
|
475
|
+
)
|
|
476
|
+
assert leg.disclosure_lag_days == 12
|
|
477
|
+
assert leg.date_quality is None
|
|
478
|
+
# Same fields on both congress DTOs, so the two stay in step with the spec.
|
|
479
|
+
assert {"disclosure_lag_days", "date_quality"} <= set(CongressTradeDto.__dataclass_fields__)
|
|
480
|
+
assert {"disclosure_lag_days", "date_quality"} <= set(ConvergenceCongressLegDto.__dataclass_fields__)
|
|
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
|