Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/huul.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion docs/guides/bdc-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 46 additions & 4 deletions edgar/bdc/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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):

Expand Down Expand Up @@ -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')


Expand All @@ -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():
Expand All @@ -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
Expand Down
156 changes: 156 additions & 0 deletions tests/bdc/test_bdc_is_active_stale_row.py
Original file line number Diff line number Diff line change
@@ -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
Loading