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.
- {form4api-0.5.0 → form4api-0.6.0}/PKG-INFO +3 -3
- {form4api-0.5.0 → form4api-0.6.0}/README.md +2 -2
- {form4api-0.5.0 → form4api-0.6.0}/form4api/_generated.py +164 -2
- {form4api-0.5.0 → form4api-0.6.0}/form4api.egg-info/PKG-INFO +3 -3
- {form4api-0.5.0 → form4api-0.6.0}/form4api.egg-info/SOURCES.txt +2 -1
- {form4api-0.5.0 → form4api-0.6.0}/pyproject.toml +1 -1
- {form4api-0.5.0 → form4api-0.6.0}/tests/test_generated.py +44 -0
- form4api-0.6.0/tests/test_method_name.py +88 -0
- {form4api-0.5.0 → form4api-0.6.0}/LICENSE +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/__init__.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/_client.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/_errors.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/_types.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/_webhook_utils.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/resources/__init__.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/resources/_companies.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/resources/_insiders.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/resources/_signals.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/resources/_transactions.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api/resources/_webhooks.py +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api.egg-info/dependency_links.txt +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api.egg-info/requires.txt +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/form4api.egg-info/top_level.txt +0 -0
- {form4api-0.5.0 → form4api-0.6.0}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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)` |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "form4api"
|
|
7
|
-
version = "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
|
|
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
|