diff --git a/CHANGELOG.md b/CHANGELOG.md index bb219e956..0855ac399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BDC investment parsing now recognizes structured Schedule of Investments labels and normalizes trailing numeric XBRL disambiguators out of `investment_type`.** The original label remains available in `identifier`, so separate tranches remain distinguishable while grouping by investment type is stable. + - **On a multi-filer filing, `Filing.cik` and `Filing.company` now name the issuer rather than whichever filer the quarterly index listed first.** Accession lookups go through EDGAR full-text search before falling back to the quarterly index, and the two order a filing's filers differently. `find("0001918704-25-005439")` was `(70858, 'BANK OF AMERICA CORP /DE/')` and is now `(1682472, 'BofA Finance LLC')`. `all_ciks` and `all_entities` still return every filer; single-filer filings are unaffected. - **`edgar.xbrl.facts.FactQuery.to_dataframe()` returned a different column set depending on which rows matched.** Columns now follow the query's configuration rather than its results. On Foot Locker's FY2024 10-K, `.limit(5)` returned five fewer columns than the same query unlimited, dropping `balance`, `currency`, `decimals`, `unit_ref` and `weight` because those rows were null. Unpopulated columns come back null, and an empty result carries the full column set. (GH #929) diff --git a/edgar/bdc/investments.py b/edgar/bdc/investments.py index a3f533c2f..e43bf1181 100644 --- a/edgar/bdc/investments.py +++ b/edgar/bdc/investments.py @@ -53,6 +53,18 @@ 'First lien senior secured delayed draw term loan', 'First lien senior secured term loan', 'First lien senior secured loan', + 'First-lien holdco loan', + 'First-lien revolving loan', + 'First-lien loan', + 'Second-lien loan', + 'First lien secured debt - delayed draw', + 'First lien secured debt - revolver', + 'First lien secured debt - term loan', + '1st lien/senior secured debt', + '2nd lien/senior secured debt', + '1st lien/last-out unitranche', + '1st lien, secured loan', + '2nd lien, secured loan', 'Second lien senior secured loan', 'Senior secured revolving loan', 'Senior secured term loan', @@ -60,13 +72,23 @@ 'Senior subordinated loan', 'Junior secured loan', 'Subordinated certificate', + 'Subordinate debt', 'Subordinated debt', 'Subordinated loan', 'Subordinated note', + 'Structured note', 'Unsecured debt', 'Unsecured loan', 'Mezzanine debt', 'Mezzanine loan', + 'Convertible promissory note A', + 'Promissory note', + 'Other debt', + 'Corporate bonds', + 'Equipment financing', + 'Secured bond', + 'Unsecured bond', + 'Secured loan', 'Term loan', 'Revolver', 'Revolving loan', @@ -89,6 +111,7 @@ 'Senior preferred units', 'Senior preferred stock', 'Junior preferred stock', + 'Preferred equity interest', 'Preferred shares', 'Preferred stock', 'Preferred units', @@ -101,6 +124,7 @@ 'Class B common units', 'Class B common stock', 'Class C common units', + 'Common equity/warrants', 'Common units', 'Common stock', 'Common shares', @@ -112,7 +136,10 @@ 'Class A units', 'Class B units', 'Class C units', + 'Class AA units', + 'Class C-1 units', # Member units (used by some BDCs like Main Street) + 'Class AA Preferred Member Units', 'Class A Preferred Member Units', 'Class B Preferred Member Units', 'Preferred Member Units', @@ -130,7 +157,10 @@ 'Class A membership units', 'Class B membership units', 'Partnership interest', + 'Partnership', + 'Equity interests', 'Equity interest', + 'Earnout interests', 'Equity', # Warrants 'Warrants to purchase shares of common stock', @@ -138,6 +168,7 @@ 'Warrant to purchase common stock', 'Warrant to purchase units', 'Warrants', + 'Warrant', 'Options', # Series units 'Series A common units', @@ -151,14 +182,17 @@ 'Class A preferred units', 'Class B preferred units', # Certificates + 'Trust certificates', 'Subordinated certificates', 'Senior certificates', 'Certificates', # Notes 'First lien senior secured note', 'Second lien senior secured note', - 'Senior subordinated note', + 'First-lien note', + 'Second-lien note', 'Senior secured note', + 'Senior subordinated note', 'Subordinated note', 'Unsecured note', # Partnership/LP interests @@ -184,6 +218,7 @@ 'Series A-3 preferred shares', 'Series C-3 preferred shares', 'Middle preferred shares', + 'Convertible preference shares', 'Warrant to purchase shares of Series C preferred stock', 'Warrant to purchase shares of Series A preferred stock', 'Warrant to purchase shares of Series B preferred stock', @@ -196,6 +231,8 @@ 'Delayed draw term loan', 'Delayed draw', 'Structured mezzanine', + 'Structured credit', + 'US Government Securities', 'ABF Equity', # Asset-based finance equity 'Senior secured', # HTGC format # Other @@ -206,7 +243,743 @@ ] -def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: +_STRUCTURED_FIELD_RE = re.compile( + r'(?:\b(?:Initial Acquisition Date|Issuer Name|Type of Investment|' + r'Industry Classification|Industry|Interest Rate|Acquisition|Maturity(?: Date)?)|' + r'Investment Type)\b', + re.IGNORECASE, +) + +_PAIRED_INVESTMENT_TYPE_RE = re.compile( + r'\b(?P(?:First Lien Senior Secured Loan|Lien Senior Secured Loan|' + r'First Lien Secured Debt|Second Lien Secured Debt|Unsecured Debt|Secured Debt|' + r'Structured Products and Other|Common Equity|Preferred Equity|Warrants)' + r'\s*[-\u2013\u2014]\s*.+?)' + r'(?=\s+(?:Initial Acquisition Date|Acquisition|Interest Rate|Reference Rate|Maturity(?: Date)?|' + r'SOFR|LIBOR|EURIBOR|SONIA|CORRA|BBKM|BBSY|Prime)\b|$)', + re.IGNORECASE, +) + +_PORTFOLIO_CATEGORY_RE = re.compile( + r'^(?:debt investment|equity investment|affiliate investment|control investment|' + r'controlled investment|other investment|equity and other investment|portfolio company|' + r'non-controlled|non-affiliate)', + re.IGNORECASE, +) + +_GENERIC_COMPANY_MEMBER_RE = re.compile( + r'^(?:inc|llc|l l c|lp|l p|ltd|limited|corp|corporation|company|' + r'(?:holding|holdco|acquisition|international)(?: usa)?(?: inc| llc| ltd| limited|' + r'corp| corporation)?)$', + re.IGNORECASE, +) + + +def _normalize_member_text(value: str) -> str: + tokens = re.findall(r'[A-Za-z0-9]+', value.lower()) + return ' '.join( + token[:-1] if len(token) > 3 and token.endswith('s') and not token.endswith('ss') else token + for token in tokens + ) + + +def _strip_trailing_member_candidate( + value: str, + member_candidates: tuple[str, ...], +) -> str: + """Remove a trailing taxonomy industry while preserving the company span.""" + normalized_value = _normalize_member_text(value) + suffixes = [ + candidate for candidate in member_candidates + if normalized_value.endswith(f' {candidate}') + if not _PORTFOLIO_CATEGORY_RE.match(candidate) + if not _GENERIC_COMPANY_MEMBER_RE.fullmatch(candidate) + ] + if not suffixes: + return value.strip() + + suffix_tokens = len(max(suffixes, key=len).split()) + value_tokens = list(re.finditer(r'[A-Za-z0-9]+', value)) + company_name = value[:value_tokens[-suffix_tokens].start()].rstrip(' ,') + return company_name.strip() + + +def _get_investment_member_candidates(xbrl) -> tuple[str, ...]: + """Collect normalized taxonomy member labels used to bound company names.""" + candidates = set() + for element_name, element in xbrl.element_catalog.items(): + if not element_name.lower().endswith('member'): + continue + for label in element.labels.values(): + candidate = re.sub(r'\s*\[Member\]\s*$', '', label).strip() + if 1 < len(candidate) <= 120: + normalized_candidate = _normalize_member_text(candidate) + if normalized_candidate: + candidates.add(normalized_candidate) + return tuple(candidates) + + +def _known_investment_type_matches(identifier: str) -> list[re.Match]: + matches = [] + for investment_type in INVESTMENT_TYPES: + for match in re.finditer(re.escape(investment_type), identifier, re.IGNORECASE): + starts_at_boundary = match.start() == 0 or not identifier[match.start() - 1].isalnum() + follows_member_code = bool(re.search(r'\b[A-Z]\d{1,3}$', identifier[:match.start()])) + ends_at_boundary = match.end() == len(identifier) or not identifier[match.end()].isalnum() + if (starts_at_boundary or follows_member_code) and ends_at_boundary: + matches.append(match) + matches = [ + match for match in matches + if identifier.rfind('(', 0, match.start()) <= identifier.rfind(')', 0, match.start()) + ] + return [ + match for match in matches + if not any( + other.start() <= match.start() + and other.end() >= match.end() + and (other.end() - other.start()) > (match.end() - match.start()) + for other in matches + ) + ] + + +def _anchored_investment_type_match(identifier: str, type_matches: list[re.Match]) -> Optional[re.Match]: + for match in sorted(type_matches, key=lambda item: item.start()): + if re.search( + r'\bInvestment(?:\s+[A-Z]\d{1,3})?\s*$', + identifier[:match.start()], + re.IGNORECASE, + ): + return match + return None + + +def _portfolio_company_fields( + identifier: str, + member_candidates: tuple[str, ...] = (), +) -> Optional[tuple[str, str]]: + relationship_equity = re.match( + r'^(?:Control|Affiliate) Investments Equity Investments\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if relationship_equity and not _STRUCTURED_FIELD_RE.search(identifier): + return relationship_equity.group('company').strip(), 'Equity' + + relationship_debt = re.match( + r'^(?:Control|Affiliate) Investments Debt Investments\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if relationship_debt and not _STRUCTURED_FIELD_RE.search(identifier): + body = relationship_debt.group('body') + leading_types = [match for match in _known_investment_type_matches(body) if match.start() == 0] + if leading_types: + type_match = max(leading_types, key=lambda match: match.end()) + return body[type_match.end():].strip(), type_match.group().strip() + + short_term_investment = re.match( + r'^Short-Term Investments\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if short_term_investment: + return short_term_investment.group('company').strip(), 'Short-Term Investments' + + truncated_warrants = re.match(r'^/Warrants\s+(?P.+)$', identifier, re.IGNORECASE) + if truncated_warrants: + company_name = re.sub( + r'\s+-\s+Warrants$', + '', + truncated_warrants.group('company'), + flags=re.IGNORECASE, + ).strip() + return company_name, 'Warrants' + + portfolio_match = re.match( + r'^(?:Investments\s+)?in .+? Portfolio Companies\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if not portfolio_match: + return None + + body = portfolio_match.group('body') + lien_prefix = re.match( + r'^(?PFirst|Second) Lien\s*/\s*Senior Secured Debt\s+(?P.+)$', + body, + re.IGNORECASE, + ) + if lien_prefix: + investment_type = f"{lien_prefix.group('lien')} Lien/Senior Secured Debt" + company_and_fields = lien_prefix.group('fields').strip() + else: + leading_types = [ + match for match in _known_investment_type_matches(body) + if match.start() == 0 + ] + if not leading_types: + return None + type_match = max(leading_types, key=lambda match: match.end()) + investment_type = type_match.group().strip() + company_and_fields = body[type_match.end():].strip() + + continuation_units = re.match( + r'^and (?PMembership Units|Units)\s+(?P.+)$', + company_and_fields, + re.IGNORECASE, + ) + if continuation_units: + investment_type = f"{investment_type} and {continuation_units.group('type')}" + company_and_fields = continuation_units.group('fields').strip() + + issuer_path = re.match( + r'^(?P/.*?)?(?:of Net Assets\s+)?Issuer(?: Name)?\s+(?P.+)$', + company_and_fields, + re.IGNORECASE, + ) + if issuer_path: + company_and_fields = issuer_path.group('fields').strip() + type_path = issuer_path.group('type_path') + if type_path: + investment_type = f'{investment_type}{type_path.strip()}' + field_match = re.search( + r'\s+(?:Acquisitions?(?=\s+\d)|Maturity(?: Date)?|Industry(?: Classification)?|' + r'Current Coupon|Interest Rate|Reference Rate(?: and Spread)?)\b', + company_and_fields, + re.IGNORECASE, + ) + company_and_detail = company_and_fields[:field_match.start() if field_match else None].strip() + repeated_type = re.match( + rf'^(?P.+?)\s+(?:[-\u2013\u2014]\s+)?' + rf'(?P{re.escape(investment_type)}\s*[-\u2013\u2014]\s*.+)$', + company_and_detail, + re.IGNORECASE, + ) + if repeated_type: + company_name = repeated_type.group('company').strip() + detail = repeated_type.group('detail').strip() + else: + company_and_detail = re.split(r'\s+[-\u2013\u2014]\s*', company_and_detail, maxsplit=1) + if len(company_and_detail) == 1: + legal_suffix_detail = re.match( + r'^(?P.+?\b(?:LLC|Inc\.?|LP|Corp\.?))\s*[-\u2013\u2014]\s*(?P.+)$', + company_and_detail[0], + re.IGNORECASE, + ) + if legal_suffix_detail: + company_and_detail = [ + legal_suffix_detail.group('company'), + legal_suffix_detail.group('detail'), + ] + company_name = company_and_detail[0].strip() + detail = company_and_detail[1].strip() if len(company_and_detail) > 1 else '' + + if detail: + if detail.casefold().startswith(f'{investment_type.casefold()} -'): + investment_type = detail + elif detail.casefold() != investment_type.casefold(): + investment_type = f'{investment_type} - {detail}' + company_name = _strip_trailing_member_candidate(company_name, member_candidates) + named_facility = re.search( + r'\s+(?P(?:First|Second) Lien,\s*Term Loan [A-Z0-9-]+)$', + company_name, + re.IGNORECASE, + ) + if named_facility: + company_name = company_name[:named_facility.start()].strip() + investment_type = f"{investment_type} - {named_facility.group('facility')}" + parenthetical_facility = re.search( + r'\s+\((?PRevolver|(?:[^()]+ )?Delayed Draw Term Loan|' + r'Term Loan [A-Z0-9-]+|Second Out|Third Out|Super Senior [A-Z])\)$', + company_name, + re.IGNORECASE, + ) + if parenthetical_facility: + company_name = company_name[:parenthetical_facility.start()].strip() + investment_type = f"{investment_type} - {parenthetical_facility.group('facility')}" + if 'warrant' in investment_type.casefold(): + company_name = re.sub(r'\s+\(Warrants?\)$', '', company_name, flags=re.IGNORECASE) + if investment_type.casefold().startswith('preferred equity'): + series = re.search( + r'\s+\((?P[A-Z]-\d+\s+Series)\)$', + company_name, + re.IGNORECASE, + ) + if series: + company_name = company_name[:series.start()].strip() + investment_type = f"{investment_type} - {series.group('series')}" + company_name = re.sub(r'\s+Preferred$', '', company_name, flags=re.IGNORECASE) + return company_name, investment_type + + +def _structured_company_window( + identifier: str, + member_candidates: tuple[str, ...] = (), +) -> Optional[str]: + portfolio_fields = _portfolio_company_fields(identifier, member_candidates) + if portfolio_fields: + return portfolio_fields[0] + + issuer_match = re.search(r'\bIssuer Name\s+', identifier, re.IGNORECASE) + if issuer_match: + tail = identifier[issuer_match.end():] + end_match = re.search( + r'\s+-\s+|\s+(?:First|Second)\s+Lien\s*-\s*|' + r'\s+(?:Acquisition|Maturity(?: Date)?|Industry(?: Classification)?|' + r'Current Coupon|Interest Rate|Reference Rate)\b', + tail, + re.IGNORECASE, + ) + return tail[:end_match.start() if end_match else None].strip() + + type_field = re.search( + r'(?:Investment Type|\b(?:Type of Investment|Facility Type))\b', + identifier, + re.IGNORECASE, + ) + if type_field: + return identifier[:type_field.start()].strip() + + industry_field = re.search(r'\bIndustry(?: Classification)?\b', identifier, re.IGNORECASE) + if industry_field: + return identifier[:industry_field.start()].strip() + + type_matches = _known_investment_type_matches(identifier) + if type_matches: + type_match = _anchored_investment_type_match(identifier, type_matches) + if type_match is None: + type_match = max(type_matches, key=lambda match: (match.start(), match.end() - match.start())) + company_window = identifier[:type_match.start()] + return re.sub( + r'\bInvestment(?:\s+[A-Z]\d{1,3})?\s*$', + '', + company_window, + flags=re.IGNORECASE, + ).strip() + return None + + +def _match_company_candidate(window: str, member_candidates: tuple[str, ...]) -> Optional[str]: + category_matches = list( + re.finditer( + r'\b(?:Equity and Other Investments|Debt Investments|Equity Investments|Other Investments)\b', + window, + re.IGNORECASE, + ) + ) + if len(category_matches) > 1: + window = window[category_matches[-1].start():].strip() + + window_tokens = list(re.finditer(r'[A-Za-z0-9]+', window)) + normalized_window = _normalize_member_text(window) + suffixes = [ + candidate for candidate in member_candidates + if normalized_window == candidate or normalized_window.endswith(f' {candidate}') + if not _PORTFOLIO_CATEGORY_RE.match(candidate) + if normalized_window == candidate or not _GENERIC_COMPANY_MEMBER_RE.fullmatch(candidate) + ] + if suffixes: + company_tokens = len(max(suffixes, key=len).split()) + company_start = window_tokens[-company_tokens].start() + if window.rfind('(', 0, company_start) <= window.rfind(')', 0, company_start): + company_name = window[company_start:].strip() + if company_name.count('(') >= company_name.count(')'): + return company_name + + other_investments = re.match(r'^Other Investments\s+(?P.+)$', window, re.IGNORECASE) + if other_investments: + return other_investments.group('company').strip() + + # Typed identifiers may not have a company member. In that case, remove + # portfolio/category prefixes and the longest taxonomy industry prefix. + cleaned_window = re.sub( + r'^(?:(?:[A-Z]?Investments[-\u2013\u2014][\w/-]+|' + r'Non-(?:control|controlled)/Non-Affiliate(?:d)?(?: Investments)?|' + r'Non-Controlled/Affiliate Investments|' + r'Non-affiliate Investments|Controlled Affiliate Investments|Controlled Investments|' + r'Affiliate Investments|Control Investments|Debt Investments|Equity Investments|Warrants?|' + r'Issuer Name|' + r'Equity and Other Investments|Equity Securities|Corporate Bonds|CLO Mezzanine|CLO Equity|' + r'US Corporate Debt|U\.S\. Debt|' + r'Senior Secured U\.S\. Notes|U\.S\. Dollar|European Currency|British Pound|' + r'Canadian Dollar|Australian Dollar|New Zealand Dollar|' + r'First Lien Senior Secured U\.S\. Debt|' + r'Second Lien Senior Secured(?: U\.S\. Debt)?|' + r'First Lien Senior Secured Canadian Debt(?: Information)?|' + r'Portfolio Company (?:Debt Securities|Equity Investments|Warrant Investments)\s*' + r'[-\u2013\u2014]\s*(?:United States|Canada|Europe))\s+)+', + '', + window, + flags=re.IGNORECASE, + ) + removed_prefix = cleaned_window != window + acronym_prefix = re.match( + r'^.+?\(["\'](?P[^"\']+)["\']\)\s+(?P.+)$', + cleaned_window, + ) + if acronym_prefix and _normalize_member_text(acronym_prefix.group('acronym')) in member_candidates: + cleaned_window = acronym_prefix.group('company').strip() + removed_prefix = True + last_prefix = None + for _ in range(4): + cleaned_tokens = list(re.finditer(r'[A-Za-z0-9]+', cleaned_window)) + normalized_cleaned = _normalize_member_text(cleaned_window) + suffixes = [ + candidate for candidate in member_candidates + if normalized_cleaned == candidate or normalized_cleaned.endswith(f' {candidate}') + if not _PORTFOLIO_CATEGORY_RE.match(candidate) + if normalized_cleaned == candidate or not _GENERIC_COMPANY_MEMBER_RE.fullmatch(candidate) + ] + if suffixes: + company_tokens = len(max(suffixes, key=len).split()) + company_start = cleaned_tokens[-company_tokens].start() + if cleaned_window.rfind('(', 0, company_start) <= cleaned_window.rfind(')', 0, company_start): + company_name = cleaned_window[company_start:].strip() + if company_name.count('(') >= company_name.count(')'): + return company_name + + prefixes = [ + candidate for candidate in member_candidates + if normalized_cleaned.startswith(f'{candidate} ') + if not _GENERIC_COMPANY_MEMBER_RE.fullmatch(candidate) + ] + if not prefixes: + return cleaned_window.strip() if removed_prefix else None + prefix = max(prefixes, key=len) + if prefix == last_prefix: + return cleaned_window.strip() + prefix_tokens = len(prefix.split()) + prefix_tail = cleaned_window[cleaned_tokens[prefix_tokens - 1].end():].strip() + if re.fullmatch(r'[\s.,)]*\([^)]*\)', prefix_tail): + return cleaned_window.strip() + remaining_window = cleaned_window[cleaned_tokens[prefix_tokens].start():].strip() + if _GENERIC_COMPANY_MEMBER_RE.fullmatch(_normalize_member_text(remaining_window)): + return cleaned_window.strip() + cleaned_window = remaining_window + removed_prefix = True + last_prefix = prefix + return cleaned_window.strip() + + +def _extract_structured_investment_type( + identifier: str, + member_candidates: tuple[str, ...] = (), +) -> str: + portfolio_fields = _portfolio_company_fields(identifier, member_candidates) + if portfolio_fields: + return portfolio_fields[1] + + type_field = re.search( + r'(?:Investment Type|\b(?:Type of Investment|Facility Type|Security))\s+(?P.+?)' + r'(?=\s+(?:Initial Acquisition Date|Investment Date|Acquisition|Maturity(?: Date)?|' + r'Interest Rate|Reference Rate|All in Rate|Benchmark|Industry Classification|Current Coupon)\b|$)', + identifier, + re.IGNORECASE, + ) + if type_field: + investment_type = type_field.group('type').strip() + warrant_type = re.match(r'Warrants?\b', investment_type, re.IGNORECASE) + if warrant_type: + return warrant_type.group() + return re.split( + r'\s+(?=(?:SOFR|LIBOR|Prime|Fixed interest|Variable interest|\d+(?:\.\d+)?%))', + investment_type, + maxsplit=1, + flags=re.IGNORECASE, + )[0].strip() + + issuer_match = re.search(r'\bIssuer Name\b', identifier, re.IGNORECASE) + if issuer_match: + after_issuer = identifier[issuer_match.end():] + dash_type = re.search(r'\s+-\s+(?P.+?)\s+Acquisition\b', after_issuer, re.IGNORECASE) + if dash_type: + return dash_type.group('type').strip() + + type_matches = _known_investment_type_matches(identifier) + if type_matches: + match = _anchored_investment_type_match(identifier, type_matches) + if match is None: + match = max(type_matches, key=lambda item: (item.start(), item.end() - item.start())) + return match.group().strip() + return "Unknown" + + +def _parse_percentage_hierarchy( + identifier: str, + member_candidates: tuple[str, ...], +) -> Optional[tuple[str, str]]: + company_path = re.split(r'\s+Industry', identifier, maxsplit=1, flags=re.IGNORECASE)[0] + company_path = re.sub( + r'^(?:Investment\s+)?(?:Debt Investments|Equity Securities)\s*[-\u2013\u2014]\s*', + '', + company_path, + flags=re.IGNORECASE, + ) + hierarchy_match = re.match( + r'^\d+(?:\.\d+)?%\s+.+?\s+[-\u2013\u2014]\s+\d+(?:\.\d+)?%\s+' + r'(?P.+?)\s+[-\u2013\u2014]\s+\d+(?:\.\d+)?%\s+(?P.+)$', + company_path, + re.IGNORECASE, + ) + if not hierarchy_match: + return None + + company_name = hierarchy_match.group('company').strip() + normalized_company = _normalize_member_text(company_name) + candidate_prefixes = [ + candidate for candidate in member_candidates + if normalized_company == candidate or normalized_company.startswith(f'{candidate} ') + if not _PORTFOLIO_CATEGORY_RE.match(candidate) + ] + if candidate_prefixes: + company_tokens = list(re.finditer(r'[A-Za-z0-9]+', company_name)) + candidate_token_count = len(max(candidate_prefixes, key=len).split()) + if candidate_token_count < len(company_tokens): + company_name = company_name[:company_tokens[candidate_token_count].start()].strip() + investment_type = re.sub(r'\s*\(\d+\)$', '', hierarchy_match.group('type')).strip() + return company_name, investment_type + + +def _parse_structured_identifier( + identifier: str, + member_candidates: tuple[str, ...], +) -> Optional[tuple[str, str]]: + clo_subordinated_note = re.match( + r'^(?P.+?)\s+(?PCLO Subordinated Notes)\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if clo_subordinated_note: + return ( + clo_subordinated_note.group('company').strip(), + f"{clo_subordinated_note.group('type')} - " + f"{clo_subordinated_note.group('detail').strip()}", + ) + + labeled_security = re.match( + r'^(?P.+?)\s+Industry\s+.+?\s+Security\s+(?P.+?)' + r'(?=\s+(?:Interest Rate|(?:\d+[DMY]\s+)?SOFR|Initial Acquisition Date|' + r'Acquisition Date|Maturity)\b|$)', + identifier, + re.IGNORECASE, + ) + if labeled_security: + return labeled_security.group('company').strip(), labeled_security.group('type').strip() + + missing_security_label = re.match( + r'^(?P.+?)\s+Industry\s+.+?\s+' + r'(?P(?:Unsecured|Secured) Bond)\s+' + r'(?=Interest Rate|Initial Acquisition Date|Acquisition Date|Maturity\b)', + identifier, + re.IGNORECASE, + ) + if missing_security_label: + return ( + missing_security_label.group('company').strip(), + missing_security_label.group('type').strip(), + ) + + short_term_security = re.match( + r'^(?P.+?)\s+Short-Term Investments\s+' + r'(?PMoney Market|Treasury Bill)\s+Interest Rate\b', + identifier, + re.IGNORECASE, + ) + if short_term_security: + return ( + short_term_security.group('company').strip(), + f"Short-Term Investments - {short_term_security.group('type').strip()}", + ) + + continuation_units = re.match( + r'^and (?PMembership Units|Units)\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if continuation_units: + company_name = _strip_trailing_member_candidate( + continuation_units.group('company'), + member_candidates, + ) + return company_name, continuation_units.group('type') + + portfolio_category = re.match( + r'^Investments in .+? Portfolio Companies\s+' + r'(?PCollateralized Loan Obligations|Derivatives|Joint Ventures|' + r'Asset Manager Affiliates)\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if portfolio_category: + category = portfolio_category.group('category') + body = portfolio_category.group('body') + clo = re.match( + r'(?P.+?)\s+(?PCLO Fund Securities)\s+Maturity\b', + body, + re.IGNORECASE, + ) + if clo: + return clo.group('company').strip(), clo.group('type') + + joint_venture = re.match(r'(?P.+?)\s+Joint Venture$', body, re.IGNORECASE) + if joint_venture: + return joint_venture.group('company').strip(), 'Joint Venture' + + company_name = _strip_trailing_member_candidate(body, member_candidates) + if category.casefold() == 'asset manager affiliates': + duplicate_name = re.fullmatch( + r'(?P.+)\s+(?P=company)', + company_name, + re.IGNORECASE, + ) + if duplicate_name: + company_name = duplicate_name.group('company') + return company_name, category + + us_equity = re.match( + r'^U\.S\. (?:Preferred Stock|Warrants)\s+(?P.+?)\s+' + r'(?P(?:[A-Z]-\d+\s+)?(?:Preferred|Warrants))\s+' + r'Initial Acquisition Date\b', + identifier, + re.IGNORECASE, + ) + if us_equity: + company_name = _match_company_candidate( + us_equity.group('body'), + member_candidates, + ) + if company_name: + return company_name, us_equity.group('type') + + portfolio_fields = _portfolio_company_fields(identifier, member_candidates) + if portfolio_fields: + return portfolio_fields + + leading_warrant = re.match( + r'^(?PWarrants?)\s+(?P.+)$', + identifier, + re.IGNORECASE, + ) + if leading_warrant: + company_name = _match_company_candidate( + leading_warrant.group('company'), + member_candidates, + ) + if company_name: + company_name = re.sub(r'Investment$', '', company_name).rstrip(',').strip() + return company_name, leading_warrant.group('type') + + hierarchy_result = _parse_percentage_hierarchy(identifier, member_candidates) + if hierarchy_result: + return hierarchy_result + + paired_type = _PAIRED_INVESTMENT_TYPE_RE.search(identifier) + if paired_type: + company_name = _match_company_candidate( + identifier[:paired_type.start()].strip(), + member_candidates, + ) + if company_name: + company_name = re.sub( + r'^\(?[^)]*(?:dba|f/?k/?a)[^)]*\)\s+', + '', + company_name, + flags=re.IGNORECASE, + ) + duplicate_name = re.fullmatch(r'(?P.+)\s+(?P=company)', company_name, re.IGNORECASE) + if duplicate_name: + company_name = duplicate_name.group('company') + company_name = re.sub(r'\s+[-\u2013\u2014]\s*$', '', company_name).strip() + return company_name, paired_type.group('type').strip() + + if not _STRUCTURED_FIELD_RE.search(identifier) and not _known_investment_type_matches(identifier): + return None + + company_window = _structured_company_window(identifier, member_candidates) + if not company_window: + return None + + security_detail = re.search( + r'\s+[-\u2013\u2014]\s+(?PSeries [A-Z0-9-]+|' + r'Class [A-Z0-9-]+ Preferred|Preferred|Warrant|Put Option)$', + company_window, + re.IGNORECASE, + ) + if security_detail: + company_window = company_window[:security_detail.start()].strip() + + portfolio_equity_or_warrant = re.match( + r'^Portfolio Company (?:Equity|Warrant) Investments\s*[-\u2013\u2014]', + identifier, + re.IGNORECASE, + ) + if portfolio_equity_or_warrant: + company_window = re.sub( + r'\s+(?:One|Two|Three)$', + '', + company_window, + flags=re.IGNORECASE, + ) + + company_name = _match_company_candidate(company_window, member_candidates) + if company_name is None and ( + re.search(r'\bIssuer Name\b', identifier, re.IGNORECASE) + or _portfolio_company_fields(identifier, member_candidates) + ): + company_name = company_window + if company_name is None: + return None + + investment_type = _extract_structured_investment_type(identifier, member_candidates) + if investment_type == "Unknown": + return None + if portfolio_equity_or_warrant: + company_name = re.sub( + r'\s+(?:One|Two|Three)$', + '', + company_name, + flags=re.IGNORECASE, + ) + series = re.search(r'\s+Series\s+(?P.+)$', identifier, re.IGNORECASE) + if series: + investment_type = f"{investment_type} - {series.group('series').strip()}" + elif investment_type.casefold() == 'warrant': + warrant_detail = re.search( + r'\s+(?:Expiration|Maturity) Date\s+[A-Za-z]+\s+\d{1,2},\s+\d{4}\s+' + r'(?POrdinary)$', + identifier, + re.IGNORECASE, + ) + if warrant_detail: + investment_type = f"{investment_type} - {warrant_detail.group('series')}" + if security_detail: + investment_type = f"{investment_type} - {security_detail.group('detail')}" + company_name = re.sub(r'(?<=\S)Investment$', '', company_name).rstrip(',').strip() + company_name = re.sub(r'\s+[-\u2013\u2014]\s*$', '', company_name).strip() + company_name = re.sub( + r'\s*\|\s*(?=(?:LLC|L\.L\.C\.|LP|L\.P\.|Inc\.?|Corp\.?)\b)', + ', ', + company_name, + flags=re.IGNORECASE, + ).rstrip('|').strip() + facility = re.search( + r'\s+\((?PRevolver|Delayed Draw|Term Loan)\)$', + company_name, + re.IGNORECASE, + ) + if facility: + company_name = company_name[:facility.start()].strip() + facility_type = facility.group('facility') + if facility_type.casefold() not in investment_type.casefold(): + investment_type = f'{investment_type} - {facility_type}' + return company_name, investment_type + + +def _parse_investment_identifier( + dimension_label: str, + member_candidates: tuple[str, ...] = (), +) -> tuple[str, str, str]: """ Parse the dimension label to extract company name and investment type. @@ -214,7 +987,8 @@ def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: 1. ARCC format: "Company Name, First lien senior secured loan" 2. HTGC format: "Debt Investments Software and Armis, Inc., Senior Secured, Maturity Date..." 3. FDUS format: "Non-control/Non-affiliate Investments Company LLC Industry First Lien Debt ..." - 4. Category rollups: "Debt Investments Software (52.80%)" - treated as Unknown type + 4. Structured format: "... Issuer Name Company LLC ... Maturity Date ..." + 5. Category rollups: "Debt Investments Software (52.80%)" - treated as Unknown type Args: dimension_label: The full dimension label, e.g., @@ -234,6 +1008,25 @@ def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: # These should be excluded as they're not individual investments if re.search(r'\(\d+\.\d+%\)\s*$', identifier): return identifier, identifier, "Unknown" + if re.fullmatch( + r'(?:Control|Affiliate|Control and Affiliate) Investments', + identifier, + re.IGNORECASE, + ): + return identifier, identifier, "Unknown" + if re.fullmatch( + r'(?:Prime Rate|(?:SOFR|CORRA) \d+-Month Term Rate|Bank of England Base Rate|' + r'Foreign Currency Forward Contracts Counterparty|Formation Transactions)', + identifier, + re.IGNORECASE, + ): + return identifier, identifier, "Unknown" + if re.fullmatch( + r'Non Controlled Affiliated(?: and Controlled)? Investments \[Member\]', + identifier, + re.IGNORECASE, + ): + return identifier, identifier, "Unknown" company_name = identifier investment_type = "Unknown" @@ -251,21 +1044,27 @@ def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: # Try FDUS prose format: # "Non-control/Non-affiliate Investments Company Name LLC Industry First Lien Debt ..." # Some labels omit the trailing "Investments" and some use bare "Subordinated". - fdus_match = re.match( - r'^(?P' - r'Non-control/Non-affiliate(?: Investments| Investmnts)?|' - r'Affiliate(?: Investments| InvesAffiliate Investments)?|' - r'Control(?: Investments)?' - r')\s+' - r'(?P.+?)\s+' - r'(?P' - r'First Lien Debt|Second Lien Debt|Subordinated Debt|Subordinated|' - r'Revolving Loan|Term Loan|Unsecured Debt|Unsecured Loan|' - r'Common Equity|Preferred Equity|Warrant|Warrants' - r')\b', + fdus_match = None + if not re.search( + r'(?:Investment Type|\b(?:Type of Investment|Facility Type))\b', normalized_identifier, re.IGNORECASE, - ) + ): + fdus_match = re.match( + r'^(?P' + r'Non-control/Non-affiliate(?: Investments| Investmnts)?|' + r'Affiliate(?: Investments| InvesAffiliate Investments)?|' + r'Control(?: Investments)?' + r')\s+' + r'(?P.+?)\s+' + r'(?P' + r'First Lien Debt|Second Lien Debt|Subordinated Debt|Subordinated|' + r'Revolving Loan|Term Loan|Unsecured Debt|Unsecured Loan|' + r'Common Equity|Preferred Equity|Warrant|Warrants' + r')\b', + normalized_identifier, + re.IGNORECASE, + ) if fdus_match: company_name = normalized_identifier investment_type = fdus_match.group('instrument').strip() @@ -305,6 +1104,94 @@ def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: company_name = body return identifier, company_name, investment_type + relationship_investment = None + if not _STRUCTURED_FIELD_RE.search(identifier): + relationship_investment = re.match( + r'^(?:Affiliated|Controlled) Investments\s+(?P.+),\s*(?P[^,]+)$', + identifier, + re.IGNORECASE, + ) + if relationship_investment: + return ( + identifier, + relationship_investment.group('company').strip(), + relationship_investment.group('type').strip(), + ) + + descriptor_pipe = re.fullmatch( + r'(?P.+?)\s+\|\s+[^|]*?\b(?PDebt|Equity)\s+Investment' + r'(?:\s+\d+(?:\.\d+)*)?(?:\s+\|\s+.+)?', + identifier, + re.IGNORECASE, + ) + if descriptor_pipe: + # Title-cased because the match is case-insensitive and the label's own + # casing varies: OBDC writes "Specialty finance equity investment", so + # returning the captured span verbatim yielded 'equity' and 'debt' — the + # only lowercase-initial types in the vocabulary, sitting beside + # 'Preferred Equity' and 'Secured Debt' from every other branch. Grouping + # by investment_type then splits the same concept across two buckets, + # which is the thing this parsing work is meant to make reliable. + return ( + identifier, + descriptor_pipe.group('company').strip(), + descriptor_pipe.group('type').strip().title(), + ) + + if ' | ' in identifier: + for inv_type in sorted(INVESTMENT_TYPES, key=len, reverse=True): + pipe_investment = re.fullmatch( + rf'(?P.+?)\s+\|\s+(?P{re.escape(inv_type)})' + r'(?P\s+\([^)]*\))?' + r'(?P\s+-\s+.+?)?(?:\s+\d+(?:\.\d+)*)?', + identifier, + re.IGNORECASE, + ) + if pipe_investment: + investment_type = pipe_investment.group('type') + facility = pipe_investment.group('facility') + if facility: + investment_type = f'{investment_type}{facility}' + detail = pipe_investment.group('detail') + if detail: + investment_type = f'{investment_type}{detail}' + return ( + identifier, + pipe_investment.group('company').strip(), + investment_type, + ) + + # Prefer an explicit trailing delimiter over taxonomy-derived company spans. + for inv_type in INVESTMENT_TYPES: + trailing_type = re.fullmatch( + rf'(?P.+),\s*(?P{re.escape(inv_type)})' + r'(?:\s+\d+(?:\.\d+)*)?', + identifier, + re.IGNORECASE, + ) + if trailing_type: + company_name = trailing_type.group('company').strip() + company_name = re.sub( + r'\s*\|\s*(?=(?:LLC|L\.L\.C\.|LP|L\.P\.|Inc\.?|Corp\.?)\b)', + ', ', + company_name, + flags=re.IGNORECASE, + ) + return identifier, company_name, trailing_type.group('type').strip() + + structured_result = _parse_structured_identifier(identifier, member_candidates) + if structured_result: + company_name, investment_type = structured_result + return identifier, company_name, investment_type + + relationship_member = re.fullmatch( + r'(?PControl|Affiliate) Investments\s+(?P.+)', + identifier, + re.IGNORECASE, + ) + if relationship_member: + return identifier, relationship_member.group('company').strip(), 'Unknown' + # Try pipe-separated format (e.g., "Company | Type" or "Company, Type | Industry") # Some BDCs (Blue Owl) put instrument type after pipe; others (FSK) put GICS # industry category after pipe. We check if the pipe-right matches a known @@ -316,7 +1203,12 @@ def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: # Strip numeric suffix for matching (e.g., "Software & Services 1" → "Software & Services") right_base = re.sub(r'\s*\d+\s*$', '', right_side) right_is_instrument = any( - right_base.lower() == inv_type.lower() for inv_type in INVESTMENT_TYPES + re.fullmatch( + rf'{re.escape(inv_type)}(?:\s*\([^)]*\)|\s*[\d.]*)?', + right_base, + re.IGNORECASE, + ) + for inv_type in INVESTMENT_TYPES ) if right_is_instrument: company_name = pipe_parts[0] @@ -337,17 +1229,6 @@ def _parse_investment_identifier(dimension_label: str) -> tuple[str, str, str]: company_name = left_side # Fall through to remaining parsing logic - # Try standard comma-separated format (investment type at end) - for inv_type in INVESTMENT_TYPES: - # Look for the investment type at the end, preceded by comma - # Support optional numeric suffixes like "1", "2", "1.1", "2.1" - pattern = rf',\s*{re.escape(inv_type)}(\s*[\d.]*)?$' - match = re.search(pattern, identifier, re.IGNORECASE) - if match: - company_name = identifier[:match.start()].strip() - investment_type = identifier[match.start() + 1:].strip() - return identifier, company_name, investment_type - # Try HTGC format: "Debt Investments [Industry] and [Company], Senior Secured, ..." # Look for ", Senior Secured" anywhere in the string htgc_match = re.search(r',\s*(Senior Secured)\s*,', identifier, re.IGNORECASE) @@ -1052,6 +1933,7 @@ def from_xbrl( # Group facts by investment identifier investments = {} + member_candidates = _get_investment_member_candidates(xbrl) for fact in all_facts: # Check if this is a relevant concept concept = fact.get('concept') @@ -1074,7 +1956,10 @@ def from_xbrl( # Format: "us-gaap:InvestmentIdentifierAxis: Company Name, Investment Type" # But here we just have the member value, not the axis prefix full_label = f"us-gaap:InvestmentIdentifierAxis: {inv_identifier}" - identifier, company_name, inv_type = _parse_investment_identifier(full_label) + identifier, company_name, inv_type = _parse_investment_identifier( + full_label, + member_candidates=member_candidates, + ) investments[inv_identifier] = { 'identifier': identifier, 'company_name': company_name, diff --git a/tests/test_bdc.py b/tests/test_bdc.py index 37abd06b1..49856885e 100644 --- a/tests/test_bdc.py +++ b/tests/test_bdc.py @@ -493,6 +493,74 @@ def test_portfolio_investments_rich(self): class TestInvestmentIdentifierParsing: """Tests for investment identifier parsing.""" + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Wingspire Capital Holdings LLC | Specialty finance equity investment | Affiliated', + ('specialty finance',), + 'Wingspire Capital Holdings LLC', + 'Equity', + ), + ( + 'Wingspire Capital Holdings LLC | Specialty finance equity investment 1', + ('specialty finance',), + 'Wingspire Capital Holdings LLC', + 'Equity', + ), + ( + 'AAM Series 2.1 Aviation Feeder, LLC | Specialty finance debt investment | Affiliated', + ('specialty finance',), + 'AAM Series 2.1 Aviation Feeder, LLC', + 'Debt', + ), + ( + 'Controlled/affiliated - debt commitments, First lien senior secured revolving loan', + ('debt commitment',), + 'Controlled/affiliated - debt commitments', + 'First lien senior secured revolving loan', + ), + ( + 'DTE Enterprises, LLC | Class AA Preferred Member Units (non-voting)', + ('class aa',), + 'DTE Enterprises, LLC', + 'Class AA Preferred Member Units (non-voting)', + ), + ], + ) + def test_parse_cross_issuer_regressions( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + 'descriptor', + ['equity investment', 'Equity Investment', 'EQUITY INVESTMENT'], + ) + def test_descriptor_type_is_canonically_cased(self, descriptor): + """However the filer cased the label, the type comes back one way. + + The match is case-insensitive, so returning the captured span verbatim + made the filer's typography part of the value — OBDC's lowercase + "equity" became a bucket of its own next to 'Preferred Equity' from + every other branch, and grouping by investment_type split the concept + in two. + """ + _, _company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: Some Holdings LLC | Specialty finance {descriptor}', + member_candidates=('specialty finance',), + ) + assert investment_type == 'Equity' + def test_parse_first_lien_loan(self): """Test parsing first lien loan identifier.""" identifier, company, inv_type = _parse_investment_identifier( @@ -515,7 +583,7 @@ def test_parse_numbered_loan(self): 'us-gaap:InvestmentIdentifierAxis: Big Company Inc., First lien senior secured loan 2' ) assert company == 'Big Company Inc.' - assert 'First lien' in inv_type + assert inv_type == 'First lien senior secured loan' def test_parse_complex_company_name(self): """Test parsing with complex company names containing commas.""" @@ -644,6 +712,1500 @@ def test_parse_fdus_leaked_affiliate_prefix_fragment(self): assert company == 'Medsurant Holdings LLC' assert inv_type == 'Preferred Equity' + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + pytest.param( + 'Debt Investments Business Services Alpha Midco, Inc. Investment First-lien loan ' + '($69,624 par, due 8/2028) Initial Acquisition Date 08/15/2019 Reference Rate and ' + 'Spread SOFR + 6.88% Interest Rate 11.20%', + ('alpha midco inc', 'business service'), + 'Alpha Midco, Inc.', + 'First-lien loan', + id='tslx', + ), + pytest.param( + 'Investments\u2014non-controlled/non-affiliated Debt Investments Professional Services ' + 'KWOR Acquisition, Inc. Investment First Lien Debt Reference Rate and Spread ' + 'S + 6.25% Interest Rate 10.07% Maturity Date 02/28/2030 One', + ('professional service',), + 'KWOR Acquisition, Inc.', + 'First Lien Debt', + id='msdl', + ), + pytest.param( + 'Investments-non-controlled/non-affiliated Debt Investments Commercial Services ' + '& Supplies Hercules Borrower, LLC Investment C26First Lien Debt Reference Rate ' + 'and Spread C + 4.75% Interest Rate 7.04% Maturity Date 12/15/2028', + ('commercial service supplie',), + 'Hercules Borrower, LLC', + 'First Lien Debt', + id='msdl-concatenated-member-code', + ), + pytest.param( + 'vInvestments-non-controlled/non-affiliated Debt Investments Food Products AMCP ' + 'Pet Holdings, Inc. (Brightpet) Investment First Lien Debt Reference Rate and ' + 'Spread S + 7.00% (incl. 3.00% PIK) Interest Rate 10.99% Maturity Date 01/04/2028', + ('food product',), + 'AMCP Pet Holdings, Inc. (Brightpet)', + 'First Lien Debt', + id='msdl-leaked-prefix-character', + ), + pytest.param( + 'Investments-non-controlled/non-affiliated Debt Investments-non-controlled/' + 'non-affiliated Debt Investments Professional Services Deerfield Dakota Holding, ' + 'LLC Investment First Lien Debt Reference Rate and Spread S + 5.75% (incl. 2.75% ' + 'PIK) Interest Rate 9.42% Maturity Date 09/13/2032 One Professional Services ' + 'Deerfield Dakota Holding, LLC Investment First Lien Debt Reference Rate and ' + 'Spread S + 5.75% (incl. 2.75% PIK) Interest Rate 9.42% Maturity Date 09/13/2032 One', + ('professional service',), + 'Deerfield Dakota Holding, LLC', + 'First Lien Debt', + id='msdl-duplicated-investment-path', + ), + pytest.param( + 'Investment Debt Investments - 216.4% United States - 205.6% 1st Lien/Senior ' + 'Secured Debt - 195.3% AAG KP Borrower LLC (dba KUIU) Industry Textiles, Apparel ' + '& Luxury Goods Interest Rate 8.76% Reference Rate and Spread S + 5.00% Maturity 12/05/31', + ('aag kp borrower llc dba kuiu',), + 'AAG KP Borrower LLC (dba KUIU)', + '1st Lien/Senior Secured Debt', + id='gsbd', + ), + pytest.param( + 'Advertising Printing & Publishing Accelerate360 Accelerate360 Holdings, LLC First ' + 'Lien Secured Debt - Term Loan SOFR+600, 1.00% Floor Maturity Date 02/11/27', + ('accelerate360 holding llc',), + 'Accelerate360 Holdings, LLC', + 'First Lien Secured Debt - Term Loan', + id='mfic', + ), + pytest.param( + 'Aerospace & Defense ATS First Lien Senior Secured Loan SOFR Spread 5.75% ' + 'Interest Rate 10.05% Maturity Date 7/12/2029', + ('ats',), + 'ATS', + 'First Lien Senior Secured Loan', + id='bcsf', + ), + pytest.param( + 'Equity Securities Issuer Name 48Forty Intermediate Holdings, Inc. - Common Equity ' + 'Acquisition 11/5/2024 Industry Containers and Packaging', + (), + '48Forty Intermediate Holdings, Inc.', + 'Common Equity', + id='pflt', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies First Lien ' + 'Secured Debt Marketplace Events Acquisition, LLC Acquisition 12/19/2024 ' + 'Maturity 12/19/2030', + (), + 'Marketplace Events Acquisition, LLC', + 'First Lien Secured Debt', + id='pflt-category-first', + ), + pytest.param( + 'CLO Equity BABSN 2018-4A SUB Industry Structured Subordinated Note ' + 'Maturity Date 10/15/2030', + ('babsn 2018 4a sub',), + 'BABSN 2018-4A SUB', + 'Subordinated Note', + id='psbd', + ), + pytest.param( + 'Non-Control/Non-Affiliate Investments Debt Investments Systems Software ' + '3PL Central LLC (dba Extensiv) Investment Type Senior Secured Interest Rate ' + 'SOFR+7.00%, 9.00% floor, 5.00% ETP Initial Acquisition Date 11/9/2022 ' + 'Maturity Date 6/30/2026', + ('system software',), + '3PL Central LLC (dba Extensiv)', + 'Senior Secured', + id='rway', + ), + pytest.param( + 'First Lien Senior Secured Canadian Debt Information Tulip.io Inc. Facility Type ' + 'Term Loan All in Rate 15.00% Benchmark P Spread 4.00% PIK 3.00% Floor 8.00% ' + 'Initial Acquisition Date 11/4/2024 Maturity 11/4/2028', + ('tulip io inc',), + 'Tulip.io Inc.', + 'Term Loan', + id='lien', + ), + pytest.param( + 'First Lien Secured Debt Issuer Name Kinetic Purchaser, LLC Acquisition 07/24/23 ' + 'Maturity 11/10/27 Industry Consumer Products Current Coupon 10.15%', + (), + 'Kinetic Purchaser, LLC', + 'First Lien Secured Debt', + id='pnnt', + ), + pytest.param( + 'Controlled investments ProAir Holdco, LLC Type of Investment Common Stock and ' + 'Membership Units Industry Classification Trading Companies & Distributors', + ('proair holdco llc',), + 'ProAir Holdco, LLC', + 'Common Stock and Membership Units', + id='bcic', + ), + pytest.param( + 'American Coastal Insurance Corp. Industry Insurance Security Unsecured Bond ' + 'Interest Rate 7.25% Initial Acquisition Date 12/20/2022 Maturity 12/15/2027', + ('american coastal insurance corp',), + 'American Coastal Insurance Corp.', + 'Unsecured Bond', + id='gecc', + ), + pytest.param( + '12 Interactive, LLC (D/B/A PerkSpot) | First Lien Debt (Revolver)', + (), + '12 Interactive, LLC (D/B/A PerkSpot)', + 'First Lien Debt (Revolver)', + id='ofs', + ), + pytest.param( + 'Portfolio Company Debt Securities- United States Supply Chain Technology ' + 'Inktavo, LLC Type of Investment Secured Loan Investment Date October 15, 2025 ' + 'Maturity Date October 15, 2031 Interest Rate Variable interest rate SOFR 3 Month ' + 'Term + 6.8%; EOT 0.0%', + ('supply chain technology',), + 'Inktavo, LLC', + 'Secured Loan', + id='trin', + ), + ], + ) + def test_parse_structured_investment_formats( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + """Parse structured investment labels used by the Format 1 tickers.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + pytest.param( + 'Other Investments Apidos CLO, Series 2015-23A Investment Structured Credit ' + '($4,000 par, due 10/2038) Initial Acquisition Date 9/3/2025 Reference Rate and ' + 'Spread SOFR + 5.20% Interest Rate 9.10%', + (), + 'Apidos CLO, Series 2015-23A', + 'Structured Credit', + id='structured-credit-with-investment-anchor', + ), + pytest.param( + 'Other Investments CIFC Funding Ltd, Series 2020 -4A Structured Credit ' + '($4,000 par, due 1/2040) Initial Acquisition Date 7/29/2025 Reference Rate and ' + 'Spread SOFR + 4.90% Interest Rate 8.80%', + (), + 'CIFC Funding Ltd, Series 2020 -4A', + 'Structured Credit', + id='structured-credit-without-investment-anchor', + ), + pytest.param( + 'Debt Investments Business Services BCTO Ignition Purchaser, Inc Investment ' + 'First-lien holdco loan ($54,435 par, due 10/2030) Initial Acquisition Date ' + '4/18/2023 Reference Rate and Spread SOFR + 7.50% Interest Rate 11.37% PIK', + ('business service',), + 'BCTO Ignition Purchaser, Inc', + 'First-lien holdco loan', + id='first-lien-holdco-loan', + ), + pytest.param( + 'Debt Investments Education Astra Acquisition Corp. Investment Second-lien loan ' + '($40,804 par, due 10/2029) Initial Acquisition Date 10/22/2021 Reference Rate and ' + 'Spread P + 9.88% Interest Rate 16.63%', + ('education',), + 'Astra Acquisition Corp.', + 'Second-lien loan', + id='second-lien-loan', + ), + pytest.param( + 'Debt Investments Financial Services Passport Labs, Inc. Investment Convertible ' + 'Promissory Note A ($1,086 par, due 8/2026) Initial Acquisition Date 3/2/2023 ' + 'Reference Rate and Spread 8.00% Interest Rate 8.00%', + ('financial service',), + 'Passport Labs, Inc.', + 'Convertible Promissory Note A', + id='convertible-promissory-note', + ), + pytest.param( + 'Debt Investments Financial Services Payroc Buyer, LLC Investment Promissory Note ' + '($6,000 par, due 9/2030) Initial Acquisition Date 9/30/2025 Reference Rate and ' + 'Spread 5.50% Interest Rate 5.50%', + ('financial service',), + 'Payroc Buyer, LLC', + 'Promissory Note', + id='promissory-note', + ), + pytest.param( + 'Debt Investments Manufacturing ASP Unifrax Holdings, Inc. Second-lien note ' + '($2,024 par, due 9/2029) Initial Acquisition Date 8/31/2023 Reference Rate and ' + 'Spread 7.10% Interest Rate 7.10% (incl. 1.25% PIK)', + ('manufacturing',), + 'ASP Unifrax Holdings, Inc.', + 'Second-lien note', + id='second-lien-note', + ), + pytest.param( + 'Debt Investments Other Boréal Bidco First-lien note (EUR 13,605 par, due 3/2032) ' + 'Initial Acquisition Date 3/24/2025 Reference Rate and Spread E + 7.25% Interest ' + 'Rate 9.27% (inclu. 5.75% PIK)', + ('other',), + 'Boréal Bidco', + 'First-lien note', + id='first-lien-note', + ), + pytest.param( + 'Equity and Other Investments Business Services Newark FP Co-Invest, L.P. ' + 'Partnership (2,527,719 units) Initial Acquisition Date 11/8/2023', + ('business service',), + 'Newark FP Co-Invest, L.P.', + 'Partnership', + id='partnership', + ), + pytest.param( + 'Equity and Other Investments Financial Services TS Imagine, Inc. Class AA Units ' + '(19,093 units) Initial Acquisition Date 11/1/2024 Reference Rate and Spread ' + '20.00% Interest Rate 20.00%', + ('financial service',), + 'TS Imagine, Inc.', + 'Class AA Units', + id='class-aa-units', + ), + pytest.param( + 'Equity and Other Investments Hotel, Gaming and Leisure IRGSE Holding Corp. ' + 'Class C-1 Units (8,800,000 units) Initial Acquisition Date 12/21/2018', + ('hotel gaming and leisure',), + 'IRGSE Holding Corp.', + 'Class C-1 Units', + id='class-c1-units', + ), + pytest.param( + 'Equity and Other Investments Internet Services Khoros, LLC Earnout Interests ' + 'Initial Acquisition Date 5/23/2025', + ('internet service',), + 'Khoros, LLC', + 'Earnout Interests', + id='earnout-interests', + ), + pytest.param( + 'Equity and Other Investments Pharmaceuticals Elysium BidCo Limited Convertible ' + 'Preference Shares (4,976,563 Shares) Initial Acquisition Date 12/11/2024', + ('pharmaceutical',), + 'Elysium BidCo Limited', + 'Convertible Preference Shares', + id='convertible-preference-shares', + ), + pytest.param( + 'Equity and Other Investments Financial Services Passport Labs, Inc. Warrants ' + '(17,534 warrants) Initial Acquisition Date 4/28/2021', + ('financial service',), + 'Passport Labs, Inc.', + 'Warrants', + id='warrant-quantity-is-not-company', + ), + pytest.param( + 'Equity and Other Investments Retail and Consumer Products Copper Bidco, LLC ' + 'Trust Certificates (996,958 Certificates) Initial Acquisition Date 1/30/2021', + ('retail and consumer product',), + 'Copper Bidco, LLC', + 'Trust Certificates', + id='certificate-quantity-is-not-company', + ), + ], + ) + def test_parse_tslx_additional_structured_types( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + """Parse additional TSLX structured instrument variants.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + pytest.param( + '2.9% Canada - 0.0% Common Stock - 0.0% Prairie Provident Resources, Inc.', + 'Prairie Provident Resources, Inc.', + 'Common Stock', + id='common-stock', + ), + pytest.param( + '2.9% United States - 2.9% Preferred Stock - 1.9% CloudBees, Inc.', + 'CloudBees, Inc.', + 'Preferred Stock', + id='preferred-stock', + ), + pytest.param( + '226.3% United States \u2013 214.3% 1st Lien/Senior Secured Debt \u2013 200.8% ' + 'A Place For Mom, Inc.', + 'A Place For Mom, Inc.', + '1st Lien/Senior Secured Debt', + id='first-lien-senior-secured', + ), + pytest.param( + '226.3% United States \u2013 214.3% 2nd Lien/Senior Secured Debt - 3.4% ' + 'MPI Engineered Technologies, LLC', + 'MPI Engineered Technologies, LLC', + '2nd Lien/Senior Secured Debt', + id='second-lien-senior-secured', + ), + pytest.param( + '226.3% United States \u2013 214.3% Unsecured Debt - 0.6% Wine.com, Inc.', + 'Wine.com, Inc.', + 'Unsecured Debt', + id='unsecured-debt', + ), + pytest.param( + 'Investment Debt Investments \u2013 226.3% United States \u2013 214.3% ' + '1st Lien/Last-Out Unitranche (14) - 9.5% EDB Parent, LLC ' + '(dba Enterprise DB) Industry Software Interest Rate 10.84% Reference Rate and ' + 'Spread S + 7.00% Maturity 07/07/28 Two', + 'EDB Parent, LLC (dba Enterprise DB)', + '1st Lien/Last-Out Unitranche', + id='last-out-unitranche', + ), + ], + ) + def test_parse_gsbd_percentage_hierarchy( + self, + raw_identifier, + expected_company, + expected_type, + ): + """Parse GSBD hierarchy paths without retaining percentage rollups.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + def test_parse_gsbd_percentage_hierarchy_uses_company_member_boundary(self): + """Stop the company before an unlabeled industry and rate fields.""" + raw_identifier = ( + 'Investment Debt Investments - 226.3% United States - 214.3% ' + '1st Lien/Senior Secured Debt - 200.8% Rotation Buyer, LLC ' + '(dba Rotating Machinery Services) Machinery Interest Rate 8.47% ' + 'Reference Rate and Spread S + 4.75% Maturity 12/02/31' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('rotation buyer llc dba rotating machinery service',), + ) + assert company == 'Rotation Buyer, LLC (dba Rotating Machinery Services)' + assert investment_type == '1st Lien/Senior Secured Debt' + + def test_parse_gsbd_percentage_hierarchy_handles_joined_industry_field(self): + """Handle source labels that omit whitespace after the Industry field.""" + raw_identifier = ( + 'Investment Debt Investments - 226.3% United States - 214.3% ' + '1st Lien/Senior Secured Debt - 200.8% Vardiman Black Holdings, LLC ' + '(dba Specialty Dental Brands) IndustryHealth Care Providers & Services ' + 'Reference Rate and Spread S + 7.00% PIK Maturity 03/18/27' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == 'Vardiman Black Holdings, LLC (dba Specialty Dental Brands)' + assert investment_type == '1st Lien/Senior Secured Debt' + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + pytest.param( + 'Trading Companies & Distributors Banner Solutions Banner Parent Holdings, Inc. ' + 'Common Equity - Common Stock', + ('trading companie distributor', 'banner solution'), + 'Banner Parent Holdings, Inc.', + 'Common Equity - Common Stock', + id='common-equity-with-portfolio-alias', + ), + pytest.param( + 'Trading Companies & Distributors ORS Nasco WC ORS Holdings, L.P. ' + 'Common Equity - Common Stock', + ('trading companie distributor', 'ors nasco'), + 'WC ORS Holdings, L.P.', + 'Common Equity - Common Stock', + id='company-with-commas', + ), + pytest.param( + 'Pharmaceuticals Alcresta Therapeutics Inc. Alcresta Holdings, LP ' + 'Preferred Equity - Preferred Equity', + ('pharmaceutical', 'alcresta therapeutic inc'), + 'Alcresta Holdings, LP', + 'Preferred Equity - Preferred Equity', + id='preferred-equity', + ), + pytest.param( + 'Passenger Airlines Merx Aviation Finance, LLC Merx Aviation Finance, LLC ' + 'Common Equity - Membership Interests', + ('passenger airline',), + 'Merx Aviation Finance, LLC', + 'Common Equity - Membership Interests', + id='duplicated-company', + ), + pytest.param( + 'Chemicals Carbonfree Chemicals SPE I LLC ' + '(f/k/a Maxus Capital Carbon SPE I LLC) FC2 LLC Secured Debt - Promissory Note ' + 'Maturity Date 10/14/27', + ('chemical', 'carbonfree chemical spe i llc'), + 'FC2 LLC', + 'Secured Debt - Promissory Note', + id='company-after-former-name', + ), + pytest.param( + 'Consumer Finance US Auto Auto Pool 2023 Trust (Del. Stat. Trust) ' + 'Structured Products and Other - Membership Interests Maturity Date 02/28/29', + ('consumer finance', 'us auto'), + 'Auto Pool 2023 Trust (Del. Stat. Trust)', + 'Structured Products and Other - Membership Interests', + id='structured-products', + ), + pytest.param( + 'Ground Transportation Third Lane Mobility Inc. Warrants – Warrants', + ('ground transportation',), + 'Third Lane Mobility Inc.', + 'Warrants – Warrants', + id='unicode-type-separator', + ), + pytest.param( + 'Commercial Services & Supplies Jacent Jacent Strategic Merchandising, LLC ' + 'Common Equity - Common Stock', + ( + 'commercial service supplie', + 'jacent', + 'jacent strategic merchandising', + ), + 'Jacent Strategic Merchandising, LLC', + 'Common Equity - Common Stock', + id='prefer-complete-company-member', + ), + pytest.param( + 'Software Asure Software Asure Software, Inc. First Lien Secured Debt - Term Loan ' + 'SOFR+500, 2.00% Floor Maturity Date 04/01/30', + ('software', 'asure software', 'inc'), + 'Asure Software, Inc.', + 'First Lien Secured Debt - Term Loan', + id='ignore-generic-company-member', + ), + ], + ) + def test_parse_mfic_paired_investment_type( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + """Parse MFIC industry, portfolio company, issuer, and paired type paths.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + ( + 'Controlled Investments Merx Aviation Finance, LLC, Membership Interests', + 'Merx Aviation Finance, LLC', + 'Membership Interests', + ), + ( + 'Affiliated Investments Arrivia, Inc. ' + '(International Cruise & Excursion Gallery, Inc),Membership Interests', + 'Arrivia, Inc. (International Cruise & Excursion Gallery, Inc)', + 'Membership Interests', + ), + ], + ) + def test_parse_mfic_relationship_investment( + self, + raw_identifier, + expected_company, + expected_type, + ): + """Parse MFIC relationship-prefixed comma labels with legal-name commas.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + pytest.param( + 'U.S. Dollar Automotive Cardo First Lien Senior Secured Loan SOFR Spread 5.25% ' + 'Interest Rate 8.98% Maturity Date 5/12/2028', + ('automotive',), + 'Cardo', + 'First Lien Senior Secured Loan', + id='us-dollar-debt', + ), + pytest.param( + 'European Currency Healthcare & Pharmaceuticals Mertus 522. GmbH First Lien ' + 'Senior Secured Loan EURIBOR Spread 4.00% (3.00% PIK) Interest Rate 9.12% ' + 'Maturity Date 5/28/2028', + ('healthcare pharmaceutical',), + 'Mertus 522. GmbH', + 'First Lien Senior Secured Loan', + id='european-currency-debt', + ), + pytest.param( + 'British Pound Services: Business Parcel2Go Equity Interest', + ('service business',), + 'Parcel2Go', + 'Equity Interest', + id='british-pound-equity', + ), + pytest.param( + 'Australian Dollar Media: Advertising, Printing & Publishing T G I Sport Bidco ' + 'Pty Ltd First Lien Senior Secured Loan BBSY Spread 7.00% Interest Rate 10.60% ' + 'Maturity Date 4/30/2026', + ('media advertising printing publishing',), + 'T G I Sport Bidco Pty Ltd', + 'First Lien Senior Secured Loan', + id='australian-dollar-debt', + ), + pytest.param( + 'New Zealand Dollar Beverage, Food & Tobacco Hellers First Lien Senior Secured ' + 'Loan - Delayed Draw BBKM Spread 3.63% (1.88% PIK) Interest Rate 9.29% ' + 'Maturity Date 9/27/2030', + ('beverage food tobacco',), + 'Hellers', + 'First Lien Senior Secured Loan - Delayed Draw', + id='new-zealand-dollar-delayed-draw', + ), + pytest.param( + 'Non-Controlled/Affiliate Investments Aerospace & Defense Ansett Aviation ' + 'Training Equity Interest', + ('aerospace defense',), + 'Ansett Aviation Training', + 'Equity Interest', + id='non-controlled-affiliate-equity', + ), + pytest.param( + 'Non-controlled/Non-Affiliated Investments High Tech Industries Applitools ' + 'Equity Interest One', + ('high tech industrie',), + 'Applitools', + 'Equity Interest', + id='non-affiliated-equity-suffix', + ), + pytest.param( + 'Controlled Affiliate Investments Investment Vehicles Bain Capital Senior Loan ' + 'Program, LLC Preferred Equity Interest Investment Vehicles', + ('investment vehicle',), + 'Bain Capital Senior Loan Program, LLC', + 'Preferred Equity Interest', + id='controlled-affiliate-preferred-equity-interest', + ), + pytest.param( + 'Non-controlled/Non-Affiliated Investments Automotive Gills Point S First Lien ' + 'Senior Secured Loan - Revolver Maturity Date 5/17/2029', + ('automotive',), + 'Gills Point S', + 'First Lien Senior Secured Loan - Revolver', + id='relationship-prefixed-revolver', + ), + pytest.param( + 'High Tech Industries Govineer Solutions (fka Black Mountain) First Lien Senior ' + 'Secured Loan SOFR Spread 5.00% Interest Rate 8.67% Maturity Date 10/7/2030', + ('high tech industrie', 'govineer solution', 'black mountain'), + 'Govineer Solutions (fka Black Mountain)', + 'First Lien Senior Secured Loan', + id='former-name-is-not-company', + ), + pytest.param( + 'European Currency Services: Business Fiduciaire Jean-Marc Faber (FJMF) First ' + 'Lien Senior Secured Loan - Delayed Draw EURIBOR Spread 5.50% Interest Rate ' + '7.58% Maturity Date 4/3/2032', + ('service business', 'fiduciaire jean marc faber', 'fjmf'), + 'Fiduciaire Jean-Marc Faber (FJMF)', + 'First Lien Senior Secured Loan - Delayed Draw', + id='parenthetical-abbreviation-is-not-company', + ), + ], + ) + def test_parse_bcsf_structured_investment( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + """Parse BCSF currency and relationship-prefixed investment paths.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + pytest.param( + 'in Non-Controlled, Non-Affiliated Portfolio Companies Common Equity/Warrants ' + 'Magnolia Topco, LP -', + 'Magnolia Topco, LP', + 'Common Equity/Warrants', + id='truncated-prefix-common-equity-warrants', + ), + pytest.param( + 'in Non-Controlled, Non-Affiliated Portfolio Companies Preferred Equity ' + 'Accounting Platform Holdings, Inc. -', + 'Accounting Platform Holdings, Inc.', + 'Preferred Equity', + id='preferred-equity', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Subordinate ' + 'Debt ORL Holdco, Inc. - Unfunded Convertible Notes Acquisition 8/2/2024 ' + 'Maturity 03/8/2028 Industry Consumer Finance', + 'ORL Holdco, Inc.', + 'Subordinate Debt - Unfunded Convertible Notes', + id='subordinate-debt-detail', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Subordinate ' + 'Debt Wash & Wax Systems, LLC - Subordinate Debt Acquisition 4/30/2025 ' + 'Maturity 07/30/2028 Industry Consumer Services Current Coupon 12.00%', + 'Wash & Wax Systems, LLC', + 'Subordinate Debt', + id='repeated-subordinate-debt-detail', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Preferred ' + 'Equity Magnolia Topco, LP - Preferred Equity - Class A Acquisition 7/25/2023 ' + 'Industry Automobiles', + 'Magnolia Topco, LP', + 'Preferred Equity - Class A', + id='repeated-preferred-equity-prefix', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies First Lien ' + 'Secured Debt North American Rail Solutions, LLC - Funded Revolver Acquisitions ' + '8/29/2025 Maturity 08/29/2031 Industry Manufacturing/Basic Industry', + 'North American Rail Solutions, LLC', + 'First Lien Secured Debt - Funded Revolver', + id='plural-acquisitions', + ), + pytest.param( + 'Investments in Controlled, Affiliated Portfolio Companies Equity Interests ' + 'PennantPark Senior Secured Loan Fund I LLC - Common Equity Acquisition ' + '6/16/2017 Industry Financial Services', + 'PennantPark Senior Secured Loan Fund I LLC', + 'Equity Interests - Common Equity', + id='plural-equity-interests', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Preferred ' + 'Equity AFC Acquisitions, Inc. Preferred Equity - Series F-2 Acquisition ' + '12/7/2023 Industry Distributors', + 'AFC Acquisitions, Inc.', + 'Preferred Equity - Series F-2', + id='repeated-type-without-company-delimiter', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies First Lien ' + 'Secured Debt GGG Midco, LLC – Unfunded Revolver Acquisition 09/27/2024 ' + 'Maturity 09/27/2030 Industry Diversified Consumer Services', + 'GGG Midco, LLC', + 'First Lien Secured Debt - Unfunded Revolver', + id='unicode-facility-delimiter', + ), + pytest.param( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies First Lien ' + 'Secured Debt Meadowlark Acquirer, LLC- Funded Revolver Acquisition 12/9/2021 ' + 'Maturity 12/10/2027 Industry Professional Services', + 'Meadowlark Acquirer, LLC', + 'First Lien Secured Debt - Funded Revolver', + id='unspaced-facility-delimiter', + ), + pytest.param( + '/Warrants Kentucky Racing Holdco, LLC - Warrants', + 'Kentucky Racing Holdco, LLC', + 'Warrants', + id='truncated-warrants', + ), + ], + ) + def test_parse_pflt_category_first_investment( + self, + raw_identifier, + expected_company, + expected_type, + ): + """Parse PFLT category-first labels and optional security details.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + def test_parse_pflt_issuer_name_before_facility_type(self): + raw_identifier = ( + 'First Lien Secured Debt Issuer Name Paving Lessor Corp. First Lien -Term Loan ' + 'Acquisition 8/28/2025 Maturity 7/1/2031 Industry Business Services' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == 'Paving Lessor Corp.' + assert investment_type == 'Term Loan' + + @pytest.mark.parametrize( + 'company_name', + [ + 'Fidelity Investments Money Market Government Portfolio - Institutional Class', + 'Morgan Stanley Liquidity Funds US Dollar Treasury Liquidity Fund - Institutional Class', + ], + ) + def test_parse_psbd_short_term_investment(self, company_name): + raw_identifier = f'Short-Term Investments {company_name}' + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == company_name + assert investment_type == 'Short-Term Investments' + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + pytest.param( + 'CLO Mezzanine AIMCO 2015-AA FR4 Industry Structured Note Interest Rate 11.41% ' + '(S + 7.18%) Maturity Date 10/17/2038', + 'AIMCO 2015-AA FR4', + 'Structured Note', + id='clo-mezzanine', + ), + pytest.param( + 'Debt Investments Corporate Bonds Altice Financing S.A. Industry Diversified ' + 'Telecommunication Services Interest Rate 0.05 Maturity Date 1/15/2028', + 'Altice Financing S.A.', + 'Corporate Bonds', + id='corporate-bonds', + ), + pytest.param( + 'Equity Investments Aimbridge Acquisition Co., Inc. Industry Hotels, Restaurants ' + 'and Leisure', + 'Aimbridge Acquisition Co., Inc.', + 'Equity', + id='equity-investments', + ), + ], + ) + def test_parse_psbd_category_prefixed_investment( + self, + raw_identifier, + expected_company, + expected_type, + ): + """Parse PSBD category-prefixed CLO, bond, and equity labels.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + pytest.param( + 'Warrant Application Software 3DNA Corp. (dba NationBuilder)', + ('application software',), + '3DNA Corp. (dba NationBuilder)', + 'Warrant', + id='singular-warrant', + ), + pytest.param( + 'Warrants Application Software Piano Software, Inc.', + ('application software',), + 'Piano Software, Inc.', + 'Warrants', + id='plural-warrants', + ), + pytest.param( + 'Warrant Technology Hardware & Equipment Brivo, Inc.Investment', + ('technology hardware equipment',), + 'Brivo, Inc.', + 'Warrant', + id='attached-investment-token', + ), + pytest.param( + 'Warrant Technology Hardware & Equipment Linxup,', + ('technology hardware equipment',), + 'Linxup', + 'Warrant', + id='trailing-comma', + ), + ], + ) + def test_parse_rway_leading_warrant( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + """Parse RWAY warrant labels with an industry before the company.""" + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + def test_parse_rway_full_warrant_identifier(self): + raw_identifier = ( + 'Non-Control/Non-Affiliate Investments Warrant Application Software 3DNA Corp. ' + '(dba NationBuilder) Investment Type Warrants Series C-1 Preferred Stock Initial ' + 'Acquisition Date 12/28/2018 Maturity Date 12/28/2028' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('application software',), + ) + assert company == '3DNA Corp. (dba NationBuilder)' + assert investment_type == 'Warrants' + + def test_parse_rway_attached_investment_type_field(self): + raw_identifier = ( + 'Non-Control/Non-Affiliate Investments Warrant Technology Hardware & Equipment ' + 'Linxup, LLCInvestment Type Warrants Success fee Initial Acquisition Date 11/3/2023 ' + 'Maturity Date 11/3/2033' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('technology hardware equipment',), + ) + assert company == 'Linxup, LLC' + assert investment_type == 'Warrants' + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Control Investments Equity Investments Runway-Cadma I LLC', + (), + 'Runway-Cadma I LLC', + 'Equity', + ), + ( + 'Affiliate Investments Debt Investments Senior Secured Gynesonics, Inc.', + (), + 'Gynesonics, Inc.', + 'Senior Secured', + ), + ( + 'Control Investments Equity Investments Multi-Sector Holdings Runway-Cadma I LLC ' + 'Investment Type Equity 50% Equity Interest Initial Acquisition Date 3/6/2024', + ('multi sector holding',), + 'Runway-Cadma I LLC', + 'Equity', + ), + ], + ) + def test_parse_rway_relationship_category( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + def test_parse_rway_revolver_metadata(self): + raw_identifier = ( + 'Non-Control/Non-Affiliate Investments Debt Investments Systems Software Digicert, ' + 'Inc. (Revolver) Investment Type Senior Secured Interest Rate SOFR+5.75%, 6.50% ' + 'floor Initial Acquisition Date 7/30/2025 Maturity Date 7/30/2030' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('system software', 'revolver'), + ) + assert company == 'Digicert, Inc.' + assert investment_type == 'Senior Secured - Revolver' + + def test_parse_rway_revolver_after_company_alias(self): + raw_identifier = ( + 'Non-Control/Non-Affiliate Investments Debt Investments Commercial & Professional ' + 'Services Shepherd Intermediate, LLC (dba FHAS) (Revolver) Investment Type Senior ' + 'Secured Interest Rate SOFR+7.25%, 8.25% floor, Initial Acquisition Date 7/10/2025 ' + 'Maturity Date 7/10/2030' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=( + 'commercial professional service', + 'shepherd intermediate llc dba fha', + ), + ) + assert company == 'Shepherd Intermediate, LLC (dba FHAS)' + assert investment_type == 'Senior Secured - Revolver' + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_type'), + [ + ( + 'U.S. Preferred Stock Real Estate and Rental and Leasing Workbox Holdings Inc. ' + 'A-1 Preferred Initial Acquisition Date 5/20/2024', + 'A-1 Preferred', + ), + ( + 'U.S. Warrants Real Estate and Rental and Leasing Workbox Holdings Inc. A-4 ' + 'Warrants Initial Acquisition Date 5/20/2024', + 'A-4 Warrants', + ), + ], + ) + def test_parse_lien_us_equity(self, raw_identifier, expected_type): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=( + 'real estate and rental and leasing', + 'workbox holding inc member', + ), + ) + assert company == 'Workbox Holdings Inc.' + assert investment_type == expected_type + + def test_parse_lien_second_lien_debt(self): + raw_identifier = ( + 'US Corporate Debt Second Lien Senior Secured Cannabis Remedy - Maryland Wellness, ' + 'LLC Facility Type Delayed Draw Term Loan All in Rate 20.25% Benchmark P Spread 9.00% ' + 'PIK 3.50% Floor 7.75% Initial Acquisition Date 10/1/2024 Maturity 8/1/2028' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('cannabi', 'remedy maryland wellness llc member'), + ) + assert company == 'Remedy - Maryland Wellness, LLC' + assert investment_type == 'Delayed Draw Term Loan' + + def test_parse_lien_company_field_delimiter(self): + raw_identifier = ( + 'US Corporate Debt First Lien Senior Secured U.S. Debt Information Protect Animals ' + 'With Satellites LLC (Halo Collar) - Facility Type Incremental Term Loan All in Rate ' + '13.25% Initial Acquisition Date 10/1/2024 Maturity 11/1/2026' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('information',), + ) + assert company == 'Protect Animals With Satellites LLC (Halo Collar)' + assert investment_type == 'Incremental Term Loan' + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + ( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Second Lien ' + 'Secured Debt of Net Assets Issuer Name Burgess Point Purchaser Corporation ' + 'Acquisition 07/26/2022 Maturity 07/28/2030 Industry Auto Sector Current Coupon ' + '12.77% Basis Point Spread Above Index 3M SOFR+910', + 'Burgess Point Purchaser Corporation', + 'Second Lien Secured Debt', + ), + ( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Subordinate ' + 'Debt/Corporate Notes of Net Assets Issuer Name Beacon Behavioral Holdings, LLC ' + 'Acquisition 06/21/2024 Maturity 06/21/2030 Industry Healthcare, Education and ' + 'Childcare Current Coupon PIK 15.00%', + 'Beacon Behavioral Holdings, LLC', + 'Subordinate Debt/Corporate Notes', + ), + ( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Preferred ' + 'Equity/Partnership Interests of Net Assets Issuer Name AFC Acquisitions, Inc. ' + '(F-2 Series) Acquisition 12/07/2023 Industry Distribution', + 'AFC Acquisitions, Inc.', + 'Preferred Equity/Partnership Interests - F-2 Series', + ), + ( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies Common Equity/' + 'Partnership Interests/Warrants of Net Assets Issuer Name Kentucky Racing Holdco, ' + 'LLC (Warrants) Acquisition 04/16/2019 Industry Hotels, Motels, Inns and Gaming', + 'Kentucky Racing Holdco, LLC', + 'Common Equity/Partnership Interests/Warrants', + ), + ( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies US Government ' + 'Securities of Net Assets Issuer Name U.S. Treasury Bill Acquisition 01/02/2026 ' + 'Maturity 01/27/2026 Industry Short-Term U.S. Government Securities Current Coupon ' + '3.98%', + 'U.S. Treasury Bill', + 'US Government Securities', + ), + ( + 'Equity Securities Issuer Name Wash & Wax Group, LP - Common Equity - Common ' + 'Equity Acquisition 04/30/25 Industry Business Services', + 'Wash & Wax Group, LP', + 'Common Equity - Common Equity', + ), + ( + 'Investments in Non-Controlled, Non-Affiliated Portfolio Companies First Lien ' + 'Secured Debt Issuer PCS MIDCO, Inc. - Unfunded Term Loan - Third Amendment ' + 'Acquisition 03/01/2024 Maturity 03/24/2028 Industry Financial Services', + 'PCS MIDCO, Inc.', + 'First Lien Secured Debt - Unfunded Term Loan - Third Amendment', + ), + ], + ) + def test_parse_pnnt_issuer_path( + self, + raw_identifier, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies Common Stock and ' + 'Membership Units AAPC Holdings, LLC Health Care Providers & Services', + ('health care provider service',), + 'AAPC Holdings, LLC', + 'Common Stock and Membership Units', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies Common Stock and ' + 'Membership Units BGPT Maverick, L.P. (Metric Inc.) Communications Equipment', + ('communication equipment',), + 'BGPT Maverick, L.P. (Metric Inc.)', + 'Common Stock and Membership Units', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies Preferred Stock ' + 'and Units Prosper Marketplace Household Products', + ('household product',), + 'Prosper Marketplace', + 'Preferred Stock and Units', + ), + ], + ) + def test_parse_bcic_continuation_units( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Investments in Affiliate Portfolio Companies Collateralized Loan Obligations ' + 'JMP Credit Advisors CLO IV LTD CLO Fund Securities Maturity 07/17/29', + (), + 'JMP Credit Advisors CLO IV LTD', + 'CLO Fund Securities', + ), + ( + 'Investments in Affiliate Portfolio Companies Derivatives Princeton Medspa ' + 'Partners, LLC Diversified Consumer Services', + ('diversified consumer service',), + 'Princeton Medspa Partners, LLC', + 'Derivatives', + ), + ( + 'Investments in Affiliate Portfolio Companies Joint Ventures Series B-Great ' + 'Lakes Funding II LLC Joint Venture', + (), + 'Series B-Great Lakes Funding II LLC', + 'Joint Venture', + ), + ( + 'Investments in Controlled Afilliated Portfolio Companies Asset Manager ' + 'Affiliates Asset Management Company Asset Management Company', + (), + 'Asset Management Company', + 'Asset Manager Affiliates', + ), + ], + ) + def test_parse_bcic_portfolio_category( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + 'raw_identifier', + [ + 'Non Controlled Affiliated Investments [Member]', + 'Non Controlled Affiliated and Controlled Investments [Member]', + ], + ) + def test_parse_bcic_relationship_rollup(self, raw_identifier): + _, _, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert investment_type == 'Unknown' + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies First Lien /Senior ' + 'Secured Debt Keg Logistics LLC Diversified Consumer Services Interest Rate ' + '10.73% Reference Rate and Spread SOFR + 6.75%, 0.50% PIK Floor 1.00% Maturity ' + '11/23/27', + ('diversified consumer service',), + 'Keg Logistics LLC', + 'First Lien/Senior Secured Debt', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies First Lien /Senior ' + 'Secured Debt Florida Food Products, LLC First Lien, Term Loan A Food Products ' + 'Interest Rate 9.43% Reference Rate and Spread SOFR + 5.50% Floor 2.00% Maturity ' + '10/15/30', + ('food product',), + 'Florida Food Products, LLC', + 'First Lien/Senior Secured Debt - First Lien, Term Loan A', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies First Lien /Senior ' + 'Secured Debt Morae Global Corporation (Revolver) IT Services Interest Rate ' + '12.04% Reference Rate and Spread SOFR + 8.00% Floor 2.00% Maturity 10/31/28', + ('it service',), + 'Morae Global Corporation', + 'First Lien/Senior Secured Debt - Revolver', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies First Lien/Senior ' + 'Secured Debt Bradshaw International Parent Corp. (Revolver) Specialty Retail ' + 'Reference Rate and Spread SOFR + 5.75% Floor 1.00% Maturity 10/21/26', + ('specialty retail',), + 'Bradshaw International Parent Corp.', + 'First Lien/Senior Secured Debt - Revolver', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies First Lien/Senior ' + 'Secured Debt Anthem Sports & Entertainment Inc. (2025 Delayed Draw Term Loan) ' + 'Media Interest Rate 9.43% Reference Rate and Spread SOFR + 5.50%, 9.43% PIK ' + 'Floor 1.00% Maturity 11/15/27', + ('media',), + 'Anthem Sports & Entertainment Inc.', + 'First Lien/Senior Secured Debt - 2025 Delayed Draw Term Loan', + ), + ( + 'Investments in Non-Control, Non-Affiliate Portfolio Companies First Lien/Senior ' + 'Secured Debt Dodge Data & Analytics LLC (Second Out) Professional Services ' + 'Interest Rate 8.75% Reference Rate and Spread SOFR + 4.75% Floor 0.50% Maturity ' + '02/28/29', + ('professional service',), + 'Dodge Data & Analytics LLC', + 'First Lien/Senior Secured Debt - Second Out', + ), + ], + ) + def test_parse_bcic_lien_category( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Non-controlled affiliated investments GreenPark Infrastructure, LLC - Series A ' + 'Type of Investment Preferred Stock and Units Industry Classification Commercial ' + 'Services & Supplies', + ('greenpark infrastructure llc',), + 'GreenPark Infrastructure, LLC', + 'Preferred Stock and Units - Series A', + ), + ( + 'Non-controlled affiliated investments Princeton Medspa Partners, LLC - Put ' + 'Option Type of Investment Derivatives Industry Classification Diversified ' + 'Consumer Services', + ('princeton medspa partner llc',), + 'Princeton Medspa Partners, LLC', + 'Derivatives - Put Option', + ), + ], + ) + def test_parse_bcic_security_detail( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + ( + 'Advancion Industry Chemicals Security 1st Lien, Secured Loan Interest Rate 1M ' + 'SOFR + 4.00% (7.82%) Initial Acquisition Date 08/26/2025 Maturity 11/24/2027', + 'Advancion', + '1st Lien, Secured Loan', + ), + ( + 'Blackstone Secured Lending Fund Industry Closed-End Fund Security Common Equity ' + 'Initial Acquisition Date 09/25/2024', + 'Blackstone Secured Lending Fund', + 'Common Equity', + ), + ( + 'Commercial Vehicle Group, Inc. Industry Transportation Equipment Manufacturing ' + 'Security Tranche 1 Warrants Initial Acquisition Date 07/31/2025', + 'Commercial Vehicle Group, Inc.', + 'Tranche 1 Warrants', + ), + ( + 'Ryan, LLC Industry Business Services Security 1st Lien, Secured Loan 1M SOFR + ' + '3.50% (7.22%) Initial Acquisition Date 11/05/2025 Maturity 11/05/2032', + 'Ryan, LLC', + '1st Lien, Secured Loan', + ), + ( + 'Trident TPI Holding, Inc. Industry Packaging Unsecured Bond Interest Rate 12.75 ' + 'Initial Acquisition Date 11/26/2025 Maturity 12/31/2028', + 'Trident TPI Holding, Inc.', + 'Unsecured Bond', + ), + ( + 'MFB Northern Inst Funds Treas Portfolio Premier CL Short-Term Investments Money ' + 'Market Interest Rate 4.16%%', + 'MFB Northern Inst Funds Treas Portfolio Premier CL', + 'Short-Term Investments - Money Market', + ), + ( + 'CLO Formation JV, LLC CLO Subordinated Notes Apex Credit CLO 2025-12 Ltd', + 'CLO Formation JV, LLC', + 'CLO Subordinated Notes - Apex Credit CLO 2025-12 Ltd', + ), + ], + ) + def test_parse_gecc_labeled_security( + self, + raw_identifier, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + ( + '12 Interactive, LLC | First Lien Debt 1', + '12 Interactive, LLC', + 'First Lien Debt', + ), + ( + '12 Interactive, LLC (D/B/A PerkSpot) | First Lien Debt (Revolver)', + '12 Interactive, LLC (D/B/A PerkSpot)', + 'First Lien Debt (Revolver)', + ), + ( + 'RideNow Group, Inc. (F/K/A RumbleOn, Inc.) | Warrants', + 'RideNow Group, Inc. (F/K/A RumbleOn, Inc.)', + 'Warrants', + ), + ( + 'Contract Datascan Holdings, Inc. | Preferred Equity 2', + 'Contract Datascan Holdings, Inc.', + 'Preferred Equity', + ), + ( + 'Planet Bingo | LLC (F/K/A 3rd Rock Gaming Holdings, LLC), First Lien Debt', + 'Planet Bingo, LLC (F/K/A 3rd Rock Gaming Holdings, LLC)', + 'First Lien Debt', + ), + ( + 'Battalion CLO XI Ltd. | Mezzanine Debt - Class E', + 'Battalion CLO XI Ltd.', + 'Mezzanine Debt - Class E', + ), + ], + ) + def test_parse_ofs_pipe_identifier( + self, + raw_identifier, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'member_candidates', 'expected_company', 'expected_type'), + [ + ( + 'Portfolio Company Equity Investments- Canada Supply Chain Technology GoFor ' + 'Delivers, Inc. Type of Investment Equity Investment Date June 28, 2024 Series ' + 'Preferred Series 2 Seed', + ('supply chain technology',), + 'GoFor Delivers, Inc.', + 'Equity - Preferred Series 2 Seed', + ), + ( + 'Portfolio Company Equity Investments- United States Multi-Sector Holdings ' + 'Eagle Point Trinity Senior Secured Lending Company (fka EPT 16 LLC) Type of ' + 'Investment Equity Investment Date June 28, 2024 Series Member Interest', + ('multi sector holding',), + 'Eagle Point Trinity Senior Secured Lending Company (fka EPT 16 LLC)', + 'Equity - Member Interest', + ), + ( + 'Portfolio Company Warrant Investments- United States Biotechnology Pendulum ' + 'Therapeutics, Inc. One Type of Investment Warrant Investment Date June 1, 2020 ' + 'Expiration Date July 15, 2030 Series Preferred Series B', + ('biotechnology', 'one'), + 'Pendulum Therapeutics, Inc.', + 'Warrant - Preferred Series B', + ), + ( + 'Portfolio Company Warrant Investments – Europe Consumer Products & Services ' + 'Motorway Online, Ltd Type of Investment Warrant Investment Date December 23, ' + '2035 Expiration Date December 23, 2026 Ordinary', + ('consumer product service',), + 'Motorway Online, Ltd', + 'Warrant - Ordinary', + ), + ], + ) + def test_parse_trin_equity_and_warrant( + self, + raw_identifier, + member_candidates, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=member_candidates, + ) + assert company == expected_company + assert investment_type == expected_type + + @pytest.mark.parametrize( + ('raw_identifier', 'expected_company', 'expected_type'), + [ + ( + 'Control Investments Autonomy Data Services, Inc.', + 'Autonomy Data Services, Inc.', + 'Unknown', + ), + ( + 'Affiliate Investments GoFor Delivers, Inc.', + 'GoFor Delivers, Inc.', + 'Unknown', + ), + ( + 'Control and Affiliate Investments', + 'Control and Affiliate Investments', + 'Unknown', + ), + ( + 'SOFR 3-Month Term Rate', + 'SOFR 3-Month Term Rate', + 'Unknown', + ), + ], + ) + def test_parse_trin_relationship_member( + self, + raw_identifier, + expected_company, + expected_type, + ): + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}' + ) + assert company == expected_company + assert investment_type == expected_type + + def test_parse_trin_quoted_industry_acronym(self): + raw_identifier = ( + 'Portfolio Company Debt Securities- United States Software as a Service ("SaaS") ' + 'Hometown Ticketing, Inc. Type of Investment Secured Loan Investment Date November ' + '25, 2024 Maturity Date November 25, 2029 Variable interest rate SOFR 3 Month Term + ' + '7.7%; EOT 0.0%' + ) + _, company, investment_type = _parse_investment_identifier( + f'us-gaap:InvestmentIdentifierAxis: {raw_identifier}', + member_candidates=('saa',), + ) + assert company == 'Hometown Ticketing, Inc.' + assert investment_type == 'Secured Loan' + + class TestPortfolioInvestmentsIntegration: """Integration tests for portfolio investments."""