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.
Files changed (25) hide show
  1. {form4api-0.8.0 → form4api-0.9.0}/PKG-INFO +36 -1
  2. {form4api-0.8.0 → form4api-0.9.0}/README.md +35 -0
  3. {form4api-0.8.0 → form4api-0.9.0}/form4api/__init__.py +6 -0
  4. {form4api-0.8.0 → form4api-0.9.0}/form4api/_client.py +25 -0
  5. {form4api-0.8.0 → form4api-0.9.0}/form4api/_generated.py +62 -4
  6. {form4api-0.8.0 → form4api-0.9.0}/form4api/_types.py +43 -0
  7. {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/PKG-INFO +36 -1
  8. {form4api-0.8.0 → form4api-0.9.0}/pyproject.toml +1 -1
  9. {form4api-0.8.0 → form4api-0.9.0}/tests/test_client.py +127 -0
  10. {form4api-0.8.0 → form4api-0.9.0}/tests/test_generated.py +48 -1
  11. {form4api-0.8.0 → form4api-0.9.0}/LICENSE +0 -0
  12. {form4api-0.8.0 → form4api-0.9.0}/form4api/_errors.py +0 -0
  13. {form4api-0.8.0 → form4api-0.9.0}/form4api/_webhook_utils.py +0 -0
  14. {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/__init__.py +0 -0
  15. {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_companies.py +0 -0
  16. {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_insiders.py +0 -0
  17. {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_signals.py +0 -0
  18. {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_transactions.py +0 -0
  19. {form4api-0.8.0 → form4api-0.9.0}/form4api/resources/_webhooks.py +0 -0
  20. {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/SOURCES.txt +0 -0
  21. {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/dependency_links.txt +0 -0
  22. {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/requires.txt +0 -0
  23. {form4api-0.8.0 → form4api-0.9.0}/form4api.egg-info/top_level.txt +0 -0
  24. {form4api-0.8.0 → form4api-0.9.0}/setup.cfg +0 -0
  25. {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.8.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); 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."""
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); 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."""
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.8.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.8.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