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