diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index a0ab469..5926364 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -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 diff --git a/backend/tests/test_loan_import.py b/backend/tests/test_loan_import.py new file mode 100644 index 0000000..f44375c --- /dev/null +++ b/backend/tests/test_loan_import.py @@ -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 diff --git a/deploy/package.json b/deploy/package.json index 753bfe6..2cfae01 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -1,6 +1,6 @@ { "name": "ten31portal-startos", - "version": "0.2.45", + "version": "0.2.47", "private": true, "scripts": { "build": "npm run check && rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", diff --git a/deploy/startos/install/versions/index.ts b/deploy/startos/install/versions/index.ts index d1b943a..bada67b 100644 --- a/deploy/startos/install/versions/index.ts +++ b/deploy/startos/install/versions/index.ts @@ -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_2_46 } from './v_0_2_46' import { v_0_2_45 } from './v_0_2_45' import { v_0_2_40 } from './v_0_2_40' 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_38 } from './v_0_2_38' 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] diff --git a/deploy/startos/install/versions/v_0_2_47.ts b/deploy/startos/install/versions/v_0_2_47.ts new file mode 100644 index 0000000..4c1ab91 --- /dev/null +++ b/deploy/startos/install/versions/v_0_2_47.ts @@ -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 }) => {}, + }, +})