Import credit-fund loans into NAV (eNAV HLD-LOANS schedule)

The eNAV importer read only the HLD securities sheet, so a credit fund's
loan book (its largest asset) never reached NAV. SATS Credit Fund showed
$3.6M of securities while holding a $46.3M loan to Fold Holdings.

Parse the HLD-LOANS schedule alongside HLD: each loan becomes a holding
and position valued at principal plus accrued interest, flowing into
holdings, invested cost, and the quarter NAV. Adds test coverage and
bumps the package to 0.2.47.
This commit is contained in:
Jonathan Kirkwood
2026-09-02 08:50:31 -05:00
parent ee8e1f30a1
commit 12a6e33bd8
5 changed files with 221 additions and 9 deletions
+116 -6
View File
@@ -1,9 +1,10 @@
"""XLSX/CSV import of a fund's holdings and NAV from a fund-administrator eNAV pack.
Reads the "HLD" (Holdings Report) sheet of an administrator eNAV workbook: each
security row becomes a holding + position, and its market value (book) becomes the
valuation for the quarter. Encrypted workbooks are decrypted with the open password.
A plain holdings CSV is also accepted.
Reads the "HLD" (Holdings Report) and "HLD-LOANS" (loan schedule) sheets of an
administrator eNAV workbook: each security and loan row becomes a holding + position,
and its market value (book) becomes the valuation for the quarter. For a credit fund the
loan book is the bulk of NAV, so both sheets must be read. Encrypted workbooks are
decrypted with the open password. A plain holdings CSV is also accepted.
"""
import csv
@@ -113,11 +114,113 @@ def _enav_as_of(wb) -> date | None:
return None
def _parse_loans(wb, as_of, seen_companies: set[str]) -> tuple[list[dict], list[dict]]:
"""Parse the HLD-LOANS (loan schedule) sheet of an eNAV workbook.
A credit fund's principal asset is its loan book, which the administrator reports on a
SEPARATE 'HLD-LOANS' schedule, not the 'HLD' Holdings Report. Each loan row becomes a
holding (issuer) + position (loan), valued at principal PLUS accrued interest receivable
(the 'VALUE PLUS ACCRUED INTEREST' column) so the quarter's NAV captures the whole loan.
Without this the NAV omits the loan book entirely (e.g. a fund showing $3.6M of
securities while holding a $46.3M loan).
"""
holdings: list[dict] = []
positions: list[dict] = []
if "HLD-LOANS" not in wb.sheetnames:
return holdings, positions
ws = wb["HLD-LOANS"]
rows = list(ws.iter_rows(min_row=1, max_row=ws.max_row, values_only=True))
# Header row: the one carrying both "SECURITY NAME" and "PRINCIPAL BALANCE".
header: list[str] = []
header_idx = None
for i, row in enumerate(rows):
cells = [str(c).strip().upper() if isinstance(c, str) else "" for c in row]
if "SECURITY NAME" in cells and "PRINCIPAL BALANCE" in cells:
header_idx, header = i, cells
break
if header_idx is None:
return holdings, positions
def col(*names: str) -> int | None:
for n in names:
if n in header:
return header.index(n)
return None
def col_prefix(prefix: str) -> int | None:
for j, h in enumerate(header):
if h.startswith(prefix):
return j
return None
c_name = col("SECURITY NAME")
c_issuer = col("ISSUER NAME", "COUNTERPART", "COUNTERPARTY")
c_value = col_prefix("VALUE PLUS ACCRUED")
if c_value is None:
c_value = col("VALUE", "PRINCIPAL BALANCE")
c_cost = col("TOTAL COST", "PRINCIPAL BALANCE")
c_trade = col("TRADE DATE", "ORIGINAL TAX LOT DATE")
if c_name is None or c_value is None:
return holdings, positions
for r in range(header_idx + 1, len(rows)):
row = rows[r]
raw_name = row[c_name] if c_name < len(row) else None
if raw_name is None or not str(raw_name).strip():
continue
name = str(raw_name).strip()
# Stop at total / summary rows (e.g. "TOTAL - LOAN").
if name.upper().startswith("TOTAL"):
continue
security = name
issuer = None
if c_issuer is not None and c_issuer < len(row) and row[c_issuer]:
issuer = str(row[c_issuer]).strip()
# Group under the issuer so a company's loan sits beside its equity/warrant.
company = issuer or name.split(" - ")[0].strip() or name
if company not in seen_companies:
holdings.append({"row": r + 1, "company_name": company})
seen_companies.add(company)
value_cents = None
if c_value < len(row) and isinstance(row[c_value], (int, float)):
value_cents = _dollars_to_cents(row[c_value])
cost_cents = None
if c_cost is not None and c_cost < len(row) and isinstance(row[c_cost], (int, float)):
cost_cents = _dollars_to_cents(row[c_cost])
investment_date = None
if c_trade is not None and c_trade < len(row):
tv = row[c_trade]
if isinstance(tv, datetime):
investment_date = str(tv.date())
elif isinstance(tv, date):
investment_date = str(tv)
positions.append({
"row": r + 1,
"company_name": company,
"security_name": security,
"investment_date": investment_date,
"shares": None, # a loan has no share count
"cost_cents": cost_cents,
"value_cents": value_cents,
"valuation_date": str(as_of) if as_of else None,
})
return holdings, positions
def _parse_schedule_xlsx(file_bytes: bytes, password: str | None = None) -> tuple[str | None, date | None, list[dict], list[dict], list[dict]]:
"""
Parse the HLD (Holdings Report) sheet of a fund-administrator eNAV workbook.
Parse the HLD (Holdings Report) and HLD-LOANS (loan schedule) sheets of a
fund-administrator eNAV workbook.
Each security row becomes a holding (issuer) + position (security), with cost basis
Each security and loan row becomes a holding (issuer) + position, with cost basis
and market value (book). Returns: (entity_name, as_of, holdings, positions, errors).
"""
wb = _open_workbook(file_bytes, password)
@@ -218,6 +321,13 @@ def _parse_schedule_xlsx(file_bytes: bytes, password: str | None = None) -> tupl
"valuation_date": str(as_of) if as_of else None,
})
# A credit fund's loan book lives on a separate 'HLD-LOANS' schedule, not the Holdings
# Report. Fold it in so each loan counts toward holdings, invested cost, and the
# quarter's NAV. seen_companies is shared so a loan groups under its issuer's holding.
loan_holdings, loan_positions = _parse_loans(wb, as_of, seen_companies)
holdings_preview.extend(loan_holdings)
positions_preview.extend(loan_positions)
return entity_name, as_of, holdings_preview, positions_preview, errors