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