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:
@@ -1,9 +1,10 @@
|
|||||||
"""XLSX/CSV import of a fund's holdings and NAV from a fund-administrator eNAV pack.
|
"""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
|
Reads the "HLD" (Holdings Report) and "HLD-LOANS" (loan schedule) sheets of an
|
||||||
security row becomes a holding + position, and its market value (book) becomes the
|
administrator eNAV workbook: each security and loan row becomes a holding + position,
|
||||||
valuation for the quarter. Encrypted workbooks are decrypted with the open password.
|
and its market value (book) becomes the valuation for the quarter. For a credit fund the
|
||||||
A plain holdings CSV is also accepted.
|
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
|
import csv
|
||||||
@@ -113,11 +114,113 @@ def _enav_as_of(wb) -> date | None:
|
|||||||
return 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]]:
|
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).
|
and market value (book). Returns: (entity_name, as_of, holdings, positions, errors).
|
||||||
"""
|
"""
|
||||||
wb = _open_workbook(file_bytes, password)
|
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,
|
"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
|
return entity_name, as_of, holdings_preview, positions_preview, errors
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ten31portal-startos",
|
"name": "ten31portal-startos",
|
||||||
"version": "0.2.45",
|
"version": "0.2.47",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
"build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { v_0_2_46 as current } from './v_0_2_46'
|
export { v_0_2_47 as current } from './v_0_2_47'
|
||||||
import { v_0_1_0 } from './v_0_1_0'
|
import { v_0_1_0 } from './v_0_1_0'
|
||||||
|
import { v_0_2_46 } from './v_0_2_46'
|
||||||
import { v_0_2_45 } from './v_0_2_45'
|
import { v_0_2_45 } from './v_0_2_45'
|
||||||
import { v_0_2_40 } from './v_0_2_40'
|
import { v_0_2_40 } from './v_0_2_40'
|
||||||
import { v_0_2_41 } from './v_0_2_41'
|
import { v_0_2_41 } from './v_0_2_41'
|
||||||
@@ -45,4 +46,4 @@ import { v_0_2_36 } from './v_0_2_36'
|
|||||||
import { v_0_2_37 } from './v_0_2_37'
|
import { v_0_2_37 } from './v_0_2_37'
|
||||||
import { v_0_2_38 } from './v_0_2_38'
|
import { v_0_2_38 } from './v_0_2_38'
|
||||||
import { v_0_2_39 } from './v_0_2_39'
|
import { v_0_2_39 } from './v_0_2_39'
|
||||||
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44, v_0_2_45]
|
export const other = [v_0_1_0, v_0_2_0, v_0_2_1, v_0_2_3, v_0_2_4, v_0_2_5, v_0_2_6, v_0_2_7, v_0_2_8, v_0_2_9, v_0_2_10, v_0_2_11, v_0_2_12, v_0_2_13, v_0_2_14, v_0_2_15, v_0_2_16, v_0_2_17, v_0_2_18, v_0_2_19, v_0_2_20, v_0_2_21, v_0_2_22, v_0_2_23, v_0_2_24, v_0_2_25, v_0_2_26, v_0_2_27, v_0_2_28, v_0_2_29, v_0_2_30, v_0_2_31, v_0_2_32, v_0_2_33, v_0_2_34, v_0_2_35, v_0_2_36, v_0_2_37, v_0_2_38, v_0_2_39, v_0_2_40, v_0_2_41, v_0_2_42, v_0_2_43, v_0_2_44, v_0_2_45, v_0_2_46]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
export const v_0_2_47 = VersionInfo.of({
|
||||||
|
version: '0.2.47:0',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Credit-fund loans now count toward NAV. The eNAV importer reads the loan schedule ' +
|
||||||
|
'(HLD-LOANS) alongside the securities Holdings Report, so a fund’s loan book flows ' +
|
||||||
|
'into its holdings, invested cost, and quarter NAV. Previously a credit fund could show ' +
|
||||||
|
'only its securities (e.g. $3.6M) while holding a much larger loan (e.g. $46.3M). ' +
|
||||||
|
'Re-import the fund’s eNAV to refresh its signed value.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {},
|
||||||
|
down: async ({ effects }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user