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
+84
View File
@@ -0,0 +1,84 @@
"""Credit-fund loan schedule: the eNAV 'HLD-LOANS' sheet must fold into holdings and NAV.
Regression for the SATS Credit Fund case where the Portal showed $3.6M of securities while
the fund held a $46.3M loan — because the loan lives on a separate schedule, not the HLD
Holdings Report. See import_router._parse_loans.
"""
import io
from datetime import date, datetime
import openpyxl
from ten31portal.routers.import_router import _parse_schedule_xlsx
def _enav_with_loan() -> bytes:
"""Minimal eNAV: an HLD securities sheet plus an HLD-LOANS loan schedule."""
wb = openpyxl.Workbook()
hld = wb.active
hld.title = "HLD"
hld["A1"] = datetime(2025, 12, 31)
hld.append([None])
hld.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
hld.append(["BTC", 18.74, 1_734_584.37, 1_639_942.63])
hld.append(["Fold Holdings, Inc. - Warrant 03/26/25", 3_702_360, 0, 0])
hld.append(["TOTAL - LONG", None, 1_734_584.37, 3_597_442.63])
loans = wb.create_sheet("HLD-LOANS")
loans.append(["SATS CREDIT FUND LP"])
loans.append([None])
# Header + one loan row + a total row the parser must skip.
loans.append([
"COUNTERPART", "SECURITY NAME", "ISSUER NAME", "TRADE DATE",
"PRINCIPAL BALANCE", "TOTAL COST", "VALUE",
"VALUE PLUS ACCRUED INTEREST REC / (PAY)",
])
loans.append([
"Fold Holdings, Inc.", "Fold Holdings, Inc. (Floating Rate)", "Fold Holdings, Inc.",
datetime(2025, 3, 26), 46_279_500, 46_279_500, 46_279_500, 46_494_185.46,
])
loans.append(["TOTAL - LOAN", None, None, None, 46_279_500, 46_279_500, 46_279_500, 46_494_185.46])
out = io.BytesIO()
wb.save(out)
return out.getvalue()
def test_loan_schedule_folds_into_holdings_and_nav():
name, as_of, holdings, positions, errors = _parse_schedule_xlsx(_enav_with_loan())
assert errors == []
assert as_of == date(2025, 12, 31)
# The loan groups under its issuer, which the warrant already created: no duplicate holding.
assert [h["company_name"] for h in holdings].count("Fold Holdings, Inc.") == 1
loan = next(p for p in positions if p["security_name"] == "Fold Holdings, Inc. (Floating Rate)")
assert loan["company_name"] == "Fold Holdings, Inc."
assert loan["value_cents"] == 46_494_185_46 # principal + accrued interest, in cents
assert loan["cost_cents"] == 46_279_500_00
assert loan["investment_date"] == "2025-03-26"
assert loan["shares"] is None
# NAV is the sum of every position value: securities ($3.6M) + the loan ($46.5M).
nav = sum(p["value_cents"] or 0 for p in positions)
assert nav == 1_639_942_63 + 0 + 46_494_185_46
def test_no_loan_sheet_is_a_noop():
"""A workbook with no HLD-LOANS sheet parses the securities exactly as before."""
wb = openpyxl.Workbook()
hld = wb.active
hld.title = "HLD"
hld["A1"] = datetime(2025, 12, 31)
hld.append([None])
hld.append(["SECURITY NAME", "QUANTITY", "COST BASIS - BOOK", "MARKET VALUE (BOOK)"])
hld.append(["Acme - Series A", 10, 100.0, 900.0])
out = io.BytesIO()
wb.save(out)
_, _, holdings, positions, errors = _parse_schedule_xlsx(out.getvalue())
assert errors == []
assert len(positions) == 1
assert positions[0]["value_cents"] == 900_00