From 4de2e279750decc7a4c8d111ca6b1d5cc4fa54be Mon Sep 17 00:00:00 2001 From: Dwight Gunning Date: Thu, 1 Oct 2026 17:20:33 -0400 Subject: [PATCH] fix(bdc): is_active checks a dropped BDC's own filings once its report row expires (huul) Since #1146 the register unions the last three SEC BDC reports and keeps each registrant's newest row. 22 registrants the 2026 report dropped (Ares Capital, Gladstone Capital, Great Elm, Goldman Sachs Private Credit, ...) keep 2025 rows dated ~2025-05-29, which leave the 18-month window around 2026-11-29: is_active would have flipped to False for BDCs that file every quarter. Rows now carry in_latest_report. is_active: - report row inside the window -> True, no request; - row from the newest report, outside the window -> False (the SEC's own current snapshot; these are the 1995-dated dormant registrants); - row from an older report, outside the window -> the company's newest filing date from its submissions JSON, lru-cached per CIK, so at most 22 requests per session today rather than one per stale row (52). An unreachable SEC falls back to the report row with a warning; any other error propagates rather than reading as "inactive". An explicit get_bdc_list(year=...) older than the newest report marks every row not-latest, since it is a snapshot of then. PR #1380's version exposed the window rule as a helper for callers but left is_active on the stale row. Tests freeze the module clock at 2026-12-01: offline cases for each branch, the register flag, the submissions parse, and a live ARCC/MAIN check (ARCC's own latest filing >= its 2026-07-29 10-Q). Co-Authored-By: Claude Opus 5.5 --- changelog.d/huul.fixed.md | 1 + docs/guides/bdc-guide.md | 4 +- edgar/bdc/reference.py | 50 ++++++- tests/bdc/test_bdc_is_active_stale_row.py | 156 ++++++++++++++++++++++ 4 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 changelog.d/huul.fixed.md create mode 100644 tests/bdc/test_bdc_is_active_stale_row.py diff --git a/changelog.d/huul.fixed.md b/changelog.d/huul.fixed.md new file mode 100644 index 000000000..81ccf9b0f --- /dev/null +++ b/changelog.d/huul.fixed.md @@ -0,0 +1 @@ +**`BDCEntity.is_active` would have turned False for BDCs the newest SEC report dropped, though they still file.** Their rows come from the 2025 report, so Ares Capital's activity date stopped at 2025-05-29 and would leave the 18-month window around 2026-11-29. For these 22 BDCs, `is_active` now checks the company's own filings once the report date expires; the new `in_latest_report` flag marks them. (bead edgartools-huul) diff --git a/docs/guides/bdc-guide.md b/docs/guides/bdc-guide.md index 0adbb0c07..94db99e9e 100644 --- a/docs/guides/bdc-guide.md +++ b/docs/guides/bdc-guide.md @@ -126,10 +126,12 @@ CIK: 1287750 File Number: 814-00663 State: MD Active: True -Last Filing: 2024-11-05 +Last Filing: 2025-05-29 Last Form: 10-Q ``` +`last_filing_date` is as of the SEC BDC report the row came from, so it can be a year or more old. The SEC's newest report does not list every BDC (Ares Capital is in the 2025 report but not the 2026 one), so for a BDC missing from it whose report date is outside the 18-month window, `is_active` checks the company's own filings. That costs one request per such BDC per session. `in_latest_report` tells you which rows these are. + ## Portfolio Investments BDCs disclose their portfolio holdings in the Schedule of Investments within their 10-K and 10-Q filings. diff --git a/edgar/bdc/reference.py b/edgar/bdc/reference.py index bc3bd8080..3de88fcdf 100644 --- a/edgar/bdc/reference.py +++ b/edgar/bdc/reference.py @@ -43,6 +43,24 @@ # year presented as confirmed is how a moved dataset reads as current data. _BDC_REPORT_FALLBACK_YEAR = 2024 +# A BDC counts as actively filing when it has filed within this many months. +ACTIVE_FILING_WINDOW_MONTHS = 18 + + +def _within_active_window(filing_date: date) -> bool: + return filing_date >= date.today() - relativedelta(months=ACTIVE_FILING_WINDOW_MONTHS) + + +@lru_cache(maxsize=None) +def _latest_filing_date(cik: int) -> Optional[date]: + """The newest filing date in the company's own submissions, or None if it has none.""" + from edgar.entity.submissions import download_entity_submissions_from_sec + submissions = download_entity_submissions_from_sec(cik) + if not submissions: + return None + dates = submissions.get('filings', {}).get('recent', {}).get('filingDate') or [] + return max(date.fromisoformat(d) for d in dates) if dates else None + @dataclass class BDCEntity: @@ -59,8 +77,11 @@ class BDCEntity: city: Optional[str] = None state: Optional[str] = None zip_code: Optional[str] = None - last_filing_date: Optional[date] = None + last_filing_date: Optional[date] = None # As of the report this row came from last_filing_type: Optional[str] = None + # False when the row comes from an older report than the newest one published, + # i.e. the SEC dropped this registrant from its latest snapshot (GH #1146) + in_latest_report: bool = True @property def is_active(self) -> bool: @@ -71,13 +92,30 @@ def is_active(self) -> bool: BDCs that haven't filed recently may have been acquired, liquidated, or converted to a different structure. + ``last_filing_date`` is as of the SEC report the row came from. For a BDC + the newest report no longer lists, that date is a year or more behind + (Ares Capital's is 2025-05-29 though it files every quarter), so once it + leaves the window the company's own filings decide, with one request + per such BDC per session. + Returns: True if the BDC has filed within the last 18 months. """ - if not self.last_filing_date: + if self.last_filing_date and _within_active_window(self.last_filing_date): + return True + if self.in_latest_report: + return False + try: + latest = _latest_filing_date(self.cik) + except Exception as e: + if not is_unreachable(e): + raise + log.warning( + "Could not check %s's own filings (%s); is_active uses its report row from %s.", + self.name, type(e).__name__, self.last_filing_date, + ) return False - cutoff = date.today() - relativedelta(months=18) - return self.last_filing_date >= cutoff + return latest is not None and _within_active_window(latest) def __rich__(self): @@ -674,6 +712,7 @@ def _combined_bdc_report(union_years: int = BDC_REPORT_UNION_YEARS) -> pd.DataFr if 'cik' in combined.columns: combined = combined[combined['cik'].notna()].drop_duplicates(subset='cik', keep='first') + combined['in_latest_report'] = combined['report_year'] == latest return combined.drop(columns='report_year') @@ -700,6 +739,8 @@ def get_bdc_list(year: Optional[int] = None) -> BDCEntities: BDCEntities with NY-based BDCs """ df = fetch_bdc_report(year) if year is not None else _combined_bdc_report() + # A requested year is a snapshot of then: its rows are only current if it is the newest + in_latest = year is None or year >= get_latest_bdc_report_year() bdcs = [] for _, row in df.iterrows(): @@ -716,6 +757,7 @@ def get_bdc_list(year: Optional[int] = None) -> BDCEntities: zip_code=str(row.get('zip_code', '')) if pd.notna(row.get('zip_code')) else None, last_filing_date=last_date, last_filing_type=str(row.get('last_filing_type', '')) if pd.notna(row.get('last_filing_type')) else None, + in_latest_report=bool(row.get('in_latest_report', True)) and in_latest, )) # Sort by name diff --git a/tests/bdc/test_bdc_is_active_stale_row.py b/tests/bdc/test_bdc_is_active_stale_row.py new file mode 100644 index 000000000..7d0e7a0f8 --- /dev/null +++ b/tests/bdc/test_bdc_is_active_stale_row.py @@ -0,0 +1,156 @@ +"""BDCEntity.is_active went stale for a BDC the newest SEC report dropped (edgartools-huul). + +Since #1146 the register unions the last three yearly reports, so a registrant +the 2026 report dropped (Ares Capital among 22) keeps its 2025 row, whose +last_filing_date is 2025-05-29. That row leaves the 18-month window around +2026-11-29 and is_active flipped to False, though ARCC files every quarter. +For such a row, is_active now asks the company's own filings. + +The clock is frozen at 2026-12-01, past the flip. +""" +from datetime import date + +import httpx +import pandas as pd +import pytest + +from edgar.bdc import reference +from edgar.bdc.reference import BDCEntity, get_bdc_list + +ARCC = 1287750 +MAIN = 1396440 + + +class _Dec2026(date): + @classmethod + def today(cls): + return cls(2026, 12, 1) + + +@pytest.fixture +def december_2026(monkeypatch): + # Held directly: by teardown a test may have monkeypatched the module attribute + lookup = reference._latest_filing_date + monkeypatch.setattr(reference, "date", _Dec2026) + lookup.cache_clear() + yield + lookup.cache_clear() + + +def _entity(in_latest_report, last_filing_date=date(2025, 5, 29)): + return BDCEntity(file_number="814-00663", cik=ARCC, name="ARES CAPITAL CORP", + last_filing_date=last_filing_date, last_filing_type="10-Q", + in_latest_report=in_latest_report) + + +def _own_latest_filing(monkeypatch, result): + calls = [] + + def fake(cik): + calls.append(cik) + if isinstance(result, Exception): + raise result + return result + monkeypatch.setattr(reference, "_latest_filing_date", fake) + return calls + + +@pytest.mark.fast +def test_a_dropped_bdc_that_still_files_stays_active(december_2026, monkeypatch): + calls = _own_latest_filing(monkeypatch, date(2026, 10, 29)) + assert _entity(in_latest_report=False).is_active is True + assert calls == [ARCC] + + +@pytest.mark.fast +def test_a_dropped_bdc_that_stopped_filing_is_inactive(december_2026, monkeypatch): + _own_latest_filing(monkeypatch, date(2025, 3, 31)) + assert _entity(in_latest_report=False).is_active is False + + +@pytest.mark.fast +def test_a_dropped_bdc_with_no_filings_is_inactive(december_2026, monkeypatch): + _own_latest_filing(monkeypatch, None) + assert _entity(in_latest_report=False).is_active is False + + +@pytest.mark.fast +def test_the_newest_report_is_trusted_without_a_request(december_2026, monkeypatch): + """A row in the newest report is the SEC's own current answer, so no lookup.""" + calls = _own_latest_filing(monkeypatch, date(2026, 10, 29)) + assert _entity(in_latest_report=True).is_active is False + assert calls == [] + + +@pytest.mark.fast +def test_a_row_inside_the_window_needs_no_request(december_2026, monkeypatch): + calls = _own_latest_filing(monkeypatch, None) + assert _entity(in_latest_report=False, last_filing_date=date(2026, 5, 8)).is_active is True + assert calls == [] + + +@pytest.mark.fast +def test_an_unreachable_sec_falls_back_to_the_report_row(december_2026, monkeypatch, caplog): + _own_latest_filing(monkeypatch, httpx.ConnectError("connection refused")) + assert _entity(in_latest_report=False).is_active is False + assert "Could not check ARES CAPITAL CORP's own filings" in caplog.text + + +@pytest.mark.fast +def test_any_other_failure_is_not_read_as_inactive(december_2026, monkeypatch): + _own_latest_filing(monkeypatch, KeyError("filings")) + with pytest.raises(KeyError): + _ = _entity(in_latest_report=False).is_active + + +@pytest.mark.fast +def test_latest_filing_date_is_the_newest_in_the_submissions(monkeypatch): + from edgar.entity import submissions + monkeypatch.setattr(submissions, "download_entity_submissions_from_sec", lambda cik: { + "filings": {"recent": {"filingDate": ["2026-09-15", "2026-07-29", "2026-04-29"]}}}) + reference._latest_filing_date.cache_clear() + try: + assert reference._latest_filing_date(ARCC) == date(2026, 9, 15) + finally: + reference._latest_filing_date.cache_clear() + + +def _report(rows): + return pd.DataFrame([ + {'file_number': '814-00001', 'cik': cik, 'registrant_name': name, 'city': 'NEW YORK', 'state': 'NY', + 'zip_code': '10019', 'last_filing_date': pd.Timestamp(filed), 'last_filing_type': '10-Q'} + for cik, name, filed in rows + ]) + + +@pytest.mark.fast +def test_the_register_marks_rows_the_newest_report_dropped(monkeypatch): + reports = { + 2026: _report([(MAIN, 'MAIN STREET CAPITAL CORP', '2026-05-08')]), + 2025: _report([(MAIN, 'MAIN STREET CAPITAL CORP', '2025-05-09'), (ARCC, 'ARES CAPITAL CORP', '2025-05-29')]), + 2024: _report([(MAIN, 'MAIN STREET CAPITAL CORP', '2024-05-10')]), + } + monkeypatch.setattr(reference, 'fetch_bdc_report', lambda year=None: reports[year or 2026]) + monkeypatch.setattr(reference, 'get_latest_bdc_report_year', lambda: 2026) + + bdcs = get_bdc_list() + assert bdcs.get_by_cik(MAIN).in_latest_report is True + assert bdcs.get_by_cik(ARCC).in_latest_report is False + # An explicit older year is a snapshot of then + assert get_bdc_list(year=2025).get_by_cik(MAIN).in_latest_report is False + assert get_bdc_list(year=2026).get_by_cik(MAIN).in_latest_report is True + + +@pytest.mark.network +def test_arcc_is_still_active_after_its_report_row_expires(december_2026): + """Live: ARCC is in the register only through the 2025 report, and its own + filings (10-Q for 2026-06-30 filed 2026-07-29) keep it active on 2026-12-01.""" + bdcs = get_bdc_list() + arcc, main = bdcs.get_by_cik(ARCC), bdcs.get_by_cik(MAIN) + + assert arcc.in_latest_report is False + assert arcc.last_filing_date == date(2025, 5, 29) + assert reference._latest_filing_date(ARCC) >= date(2026, 7, 29) + assert arcc.is_active is True + assert main.in_latest_report is True + assert main.is_active is True