485 lines
18 KiB
Python
485 lines
18 KiB
Python
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from decimal import Decimal
|
|
from functools import lru_cache
|
|
from typing import List, Union
|
|
|
|
import pyarrow.compute as pc
|
|
|
|
from edgar._party import Address
|
|
|
|
__all__ = [
|
|
'FilingManager',
|
|
'OtherManager',
|
|
'CoverPage',
|
|
'SummaryPage',
|
|
'Signature',
|
|
'PrimaryDocument13F',
|
|
'ThirteenF',
|
|
'THIRTEENF_FORMS',
|
|
'format_date',
|
|
]
|
|
|
|
THIRTEENF_FORMS = ['13F-HR', "13F-HR/A", "13F-NT", "13F-NT/A", "13F-CTR", "13F-CTR/A"]
|
|
|
|
|
|
def format_date(date: Union[str, datetime]) -> str:
|
|
if isinstance(date, str):
|
|
return date
|
|
return date.strftime("%Y-%m-%d")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FilingManager:
|
|
name: str
|
|
address: Address
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OtherManager:
|
|
cik: str
|
|
name: str
|
|
file_number: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CoverPage:
|
|
report_calendar_or_quarter: str
|
|
report_type: str
|
|
filing_manager: FilingManager
|
|
other_managers: List[OtherManager]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SummaryPage:
|
|
other_included_managers_count: int
|
|
total_value: Decimal
|
|
total_holdings: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Signature:
|
|
name: str
|
|
title: str
|
|
phone: str
|
|
signature: str
|
|
city: str
|
|
state_or_country: str
|
|
date: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PrimaryDocument13F:
|
|
report_period: datetime
|
|
cover_page: CoverPage
|
|
summary_page: SummaryPage
|
|
signature: Signature
|
|
additional_information: str
|
|
|
|
|
|
class ThirteenF:
|
|
"""
|
|
A 13F-HR is a quarterly report filed by institutional investment managers that have over $100 million in qualifying
|
|
assets under management. The report is filed with the Securities & Exchange Commission (SEC) and discloses all
|
|
the firm's equity holdings that it held at the end of the quarter. The report is due within 45 days of the end
|
|
of the quarter. The 13F-HR is a public document that is available on the SEC's website.
|
|
"""
|
|
|
|
def __init__(self, filing, use_latest_period_of_report=False):
|
|
from edgar.thirteenf.parsers.primary_xml import parse_primary_document_xml
|
|
|
|
assert filing.form in THIRTEENF_FORMS, f"Form {filing.form} is not a valid 13F form"
|
|
# The filing might not be the filing for the current period. We need to use the related filing filed on the same
|
|
# date as the current filing that has the latest period of report
|
|
self._related_filings = filing.related_filings().filter(filing_date=filing.filing_date, form=filing.form)
|
|
self._actual_filing = filing # The filing passed in
|
|
if use_latest_period_of_report:
|
|
# Use the last related filing.
|
|
# It should also be the one that has the CONFORMED_PERIOD_OF_REPORT closest to filing_date
|
|
self.filing = self._related_filings[-1]
|
|
else:
|
|
# Use the exact filing that was passed in
|
|
self.filing = self._actual_filing
|
|
|
|
# Parse primary document if XML is available (2013+ filings)
|
|
# For older TXT-only filings (2012 and earlier), primary_form_information will be None
|
|
primary_xml = self.filing.xml()
|
|
self.primary_form_information = parse_primary_document_xml(primary_xml) if primary_xml else None
|
|
|
|
def has_infotable(self):
|
|
return self.filing.form in ['13F-HR', "13F-HR/A"]
|
|
|
|
@property
|
|
def form(self):
|
|
return self.filing.form
|
|
|
|
@property
|
|
@lru_cache(maxsize=1)
|
|
def infotable_xml(self):
|
|
"""Returns XML content if available (2013+ filings)"""
|
|
if self.has_infotable():
|
|
result = self._get_infotable_from_attachment()
|
|
if result and result[0] and result[1] == 'xml' and "informationTable" in result[0]:
|
|
return result[0]
|
|
return None
|
|
|
|
def _get_infotable_from_attachment(self):
|
|
"""
|
|
Use the filing homepage to get the infotable file.
|
|
Returns a tuple of (content, format) where format is 'xml' or 'txt'.
|
|
"""
|
|
if self.has_infotable():
|
|
# Try XML format first (2013+)
|
|
query = "document_type=='INFORMATION TABLE' and document.lower().endswith('.xml')"
|
|
attachments = self.filing.attachments.query(query)
|
|
if len(attachments) > 0:
|
|
return (attachments.get_by_index(0).download(), 'xml')
|
|
|
|
# Fall back to TXT format (2012 and earlier)
|
|
# The primary document itself contains the table in TXT format
|
|
# Try various description patterns first
|
|
query = "description=='FORM 13F' or description=='INFORMATION TABLE'"
|
|
attachments = self.filing.attachments.query(query)
|
|
if len(attachments) > 0:
|
|
# Filter for .txt files only
|
|
txt_attachments = [att for att in attachments if att.document.lower().endswith('.txt')]
|
|
if txt_attachments:
|
|
return (txt_attachments[0].download(), 'txt')
|
|
|
|
# Final fallback: For older filings, descriptions may be unreliable
|
|
# Look for sequence number 1 with .txt extension
|
|
try:
|
|
att = self.filing.attachments.get_by_sequence(1)
|
|
if att and att.document.lower().endswith('.txt'):
|
|
return (att.download(), 'txt')
|
|
except (KeyError, AttributeError):
|
|
pass
|
|
|
|
return (None, None)
|
|
|
|
@property
|
|
@lru_cache(maxsize=1)
|
|
def infotable_txt(self):
|
|
"""Returns TXT content if available (pre-2013 filings)"""
|
|
if self.has_infotable():
|
|
result = self._get_infotable_from_attachment()
|
|
if result and result[0] and result[1] == 'txt':
|
|
return result[0]
|
|
|
|
# Fallback: Some filings have the information table embedded in the main HTML
|
|
# instead of as a separate attachment. Try to extract it from the main HTML.
|
|
if not result or not result[0]:
|
|
html = self.filing.html()
|
|
if html and "Form 13F Information Table" in html:
|
|
return html
|
|
return None
|
|
|
|
@property
|
|
@lru_cache(maxsize=1)
|
|
def infotable_html(self):
|
|
if self.has_infotable():
|
|
query = "document_type=='INFORMATION TABLE' and document.lower().endswith('.html')"
|
|
attachments = self.filing.attachments.query(query)
|
|
return attachments[0].download()
|
|
|
|
@property
|
|
@lru_cache(maxsize=1)
|
|
def infotable(self):
|
|
"""
|
|
Returns the information table as a pandas DataFrame.
|
|
Supports both XML format (2013+) and TXT format (2012 and earlier).
|
|
"""
|
|
from edgar.thirteenf.parsers.infotable_xml import parse_infotable_xml
|
|
from edgar.thirteenf.parsers.infotable_txt import parse_infotable_txt
|
|
|
|
if self.has_infotable():
|
|
# Try XML format first
|
|
if self.infotable_xml:
|
|
return parse_infotable_xml(self.infotable_xml)
|
|
# Fall back to TXT format
|
|
elif self.infotable_txt:
|
|
return parse_infotable_txt(self.infotable_txt)
|
|
return None
|
|
|
|
@property
|
|
def accession_number(self):
|
|
return self.filing.accession_no
|
|
|
|
@property
|
|
def total_value(self):
|
|
"""Total value of holdings in thousands of dollars"""
|
|
if self.primary_form_information:
|
|
return self.primary_form_information.summary_page.total_value
|
|
# For TXT-only filings, calculate from infotable
|
|
infotable = self.infotable
|
|
if infotable is not None and len(infotable) > 0:
|
|
return Decimal(int(infotable['Value'].sum()))
|
|
return None
|
|
|
|
@property
|
|
def total_holdings(self):
|
|
"""Total number of holdings"""
|
|
if self.primary_form_information:
|
|
return self.primary_form_information.summary_page.total_holdings
|
|
# For TXT-only filings, count from infotable
|
|
infotable = self.infotable
|
|
if infotable is not None:
|
|
return len(infotable)
|
|
return None
|
|
|
|
@property
|
|
def report_period(self):
|
|
"""Report period end date"""
|
|
if self.primary_form_information:
|
|
return format_date(self.primary_form_information.report_period)
|
|
# For TXT-only filings, use CONFORMED_PERIOD_OF_REPORT from filing header
|
|
if hasattr(self.filing, 'period_of_report') and self.filing.period_of_report:
|
|
return format_date(self.filing.period_of_report)
|
|
return None
|
|
|
|
@property
|
|
def filing_date(self):
|
|
return format_date(self.filing.filing_date)
|
|
|
|
@property
|
|
def investment_manager(self):
|
|
# This is really the firm e.g. Spark Growth Management Partners II, LLC
|
|
if self.primary_form_information:
|
|
return self.primary_form_information.cover_page.filing_manager
|
|
return None
|
|
|
|
@property
|
|
def signer(self):
|
|
# This is the person who signed the filing. Could be the Reporting Manager but could be someone else
|
|
# like the CFO
|
|
if self.primary_form_information:
|
|
return self.primary_form_information.signature.name
|
|
return None
|
|
|
|
# Enhanced manager name properties for better clarity
|
|
@property
|
|
def management_company_name(self) -> str:
|
|
"""
|
|
The legal name of the investment management company that filed the 13F.
|
|
|
|
This is the institutional entity (e.g., "Berkshire Hathaway Inc", "Vanguard Group Inc")
|
|
that is legally responsible for managing the assets, not an individual person's name.
|
|
|
|
Returns:
|
|
str: The legal name of the management company, or company name from filing if not available
|
|
|
|
Example:
|
|
>>> thirteen_f.management_company_name
|
|
'Berkshire Hathaway Inc'
|
|
"""
|
|
if self.investment_manager:
|
|
return self.investment_manager.name
|
|
# For TXT-only filings, use company name from filing
|
|
return self.filing.company
|
|
|
|
@property
|
|
def filing_signer_name(self) -> str:
|
|
"""
|
|
The name of the individual who signed the 13F filing.
|
|
|
|
This is typically an administrative officer (CFO, CCO, Compliance Officer, etc.)
|
|
rather than the famous portfolio manager. For example, Berkshire Hathaway's 13F
|
|
is signed by "Marc D. Hamburg" (SVP), not Warren Buffett.
|
|
|
|
Returns:
|
|
str: The name of the person who signed the filing
|
|
|
|
Example:
|
|
>>> thirteen_f.filing_signer_name
|
|
'Marc D. Hamburg'
|
|
"""
|
|
return self.signer
|
|
|
|
@property
|
|
def filing_signer_title(self) -> str:
|
|
"""
|
|
The business title of the individual who signed the 13F filing.
|
|
|
|
Common titles include: CFO, CCO, Senior Vice President, Chief Compliance Officer,
|
|
Secretary, Treasurer, etc. This helps distinguish administrative signers from
|
|
portfolio managers.
|
|
|
|
Returns:
|
|
str: The business title of the filing signer, or None if not available
|
|
|
|
Example:
|
|
>>> thirteen_f.filing_signer_title
|
|
'Senior Vice President'
|
|
"""
|
|
if self.primary_form_information:
|
|
return self.primary_form_information.signature.title
|
|
return None
|
|
|
|
@property
|
|
def manager_name(self) -> str:
|
|
"""
|
|
DEPRECATED: Use management_company_name instead.
|
|
|
|
Returns the management company name for backwards compatibility.
|
|
This property name was misleading as it suggested an individual manager's name.
|
|
|
|
Returns:
|
|
str: The management company name
|
|
|
|
Warning:
|
|
This property is deprecated and may be removed in future versions.
|
|
Use management_company_name for the company name, or see get_portfolio_managers()
|
|
if you need information about individual portfolio managers.
|
|
"""
|
|
import warnings
|
|
warnings.warn(
|
|
"manager_name is deprecated and misleading. Use management_company_name for the "
|
|
"company name, or get_portfolio_managers() for individual manager information.",
|
|
DeprecationWarning,
|
|
stacklevel=2
|
|
)
|
|
return self.management_company_name
|
|
|
|
def get_portfolio_managers(self, include_approximate: bool = False) -> list[dict]:
|
|
"""
|
|
Get information about the actual portfolio managers for this fund.
|
|
|
|
Note: 13F filings do not contain individual portfolio manager names.
|
|
This method provides a curated mapping for well-known funds based on
|
|
public information. Results may not be current or complete.
|
|
|
|
Args:
|
|
include_approximate (bool): If True, includes approximate/historical
|
|
manager information even if not current
|
|
|
|
Returns:
|
|
list[dict]: List of portfolio manager information with keys:
|
|
'name', 'title', 'status', 'source', 'last_updated'
|
|
|
|
Example:
|
|
>>> thirteen_f.get_portfolio_managers()
|
|
[
|
|
{
|
|
'name': 'Warren Buffett',
|
|
'title': 'Chairman & CEO',
|
|
'status': 'active',
|
|
'source': 'public_records',
|
|
'last_updated': '2024-01-01'
|
|
}
|
|
]
|
|
"""
|
|
from edgar.thirteenf.manager_lookup import lookup_portfolio_managers
|
|
return lookup_portfolio_managers(
|
|
self.management_company_name,
|
|
getattr(self.filing, 'cik', None),
|
|
include_approximate=include_approximate
|
|
)
|
|
|
|
def _lookup_portfolio_managers(self, company_name: str, include_approximate: bool = False) -> list[dict]:
|
|
"""
|
|
Private method for testing - looks up portfolio managers by company name.
|
|
|
|
Args:
|
|
company_name: Name of the management company
|
|
include_approximate: Whether to include approximate/historical data
|
|
|
|
Returns:
|
|
list[dict]: List of portfolio manager information
|
|
"""
|
|
from edgar.thirteenf.manager_lookup import lookup_portfolio_managers
|
|
return lookup_portfolio_managers(company_name, cik=None, include_approximate=include_approximate)
|
|
|
|
def get_manager_info_summary(self) -> dict:
|
|
"""
|
|
Get a comprehensive summary of all available manager information.
|
|
|
|
This provides a clear breakdown of what information is available from the 13F
|
|
filing versus external sources, helping users understand the data limitations.
|
|
|
|
Returns:
|
|
dict: Summary with keys 'from_13f_filing', 'external_sources', 'limitations'
|
|
|
|
Example:
|
|
>>> thirteen_f.get_manager_info_summary()
|
|
{
|
|
'from_13f_filing': {
|
|
'management_company': 'Berkshire Hathaway Inc',
|
|
'filing_signer': 'Marc D. Hamburg',
|
|
'signer_title': 'Senior Vice President'
|
|
},
|
|
'external_sources': {
|
|
'portfolio_managers': [
|
|
{'name': 'Warren Buffett', 'title': 'Chairman & CEO', 'status': 'active'}
|
|
]
|
|
},
|
|
'limitations': [
|
|
'13F filings do not contain individual portfolio manager names',
|
|
'External manager data may not be current or complete',
|
|
'Filing signer is typically an administrative officer, not the portfolio manager'
|
|
]
|
|
}
|
|
"""
|
|
portfolio_managers = self.get_portfolio_managers()
|
|
|
|
return {
|
|
'from_13f_filing': {
|
|
'management_company': self.management_company_name,
|
|
'filing_signer': self.filing_signer_name,
|
|
'signer_title': self.filing_signer_title,
|
|
'form': self.form,
|
|
'period_of_report': str(self.report_period)
|
|
},
|
|
'external_sources': {
|
|
'portfolio_managers': portfolio_managers,
|
|
'manager_count': len(portfolio_managers)
|
|
},
|
|
'limitations': [
|
|
'13F filings do not contain individual portfolio manager names',
|
|
'External manager data may not be current or complete',
|
|
'Filing signer is typically an administrative officer, not the portfolio manager',
|
|
'Portfolio manager information is sourced from public records and may be outdated'
|
|
]
|
|
}
|
|
|
|
def is_filing_signer_likely_portfolio_manager(self) -> bool:
|
|
"""
|
|
Determine if the filing signer is likely to be a portfolio manager.
|
|
|
|
This uses heuristics based on the signer's title to assess whether they
|
|
might be involved in investment decisions rather than just administrative functions.
|
|
|
|
Returns:
|
|
bool: True if signer appears to be investment-focused, False if administrative
|
|
|
|
Example:
|
|
>>> thirteen_f.is_filing_signer_likely_portfolio_manager()
|
|
False # For administrative titles like CFO, CCO, etc.
|
|
"""
|
|
from edgar.thirteenf.manager_lookup import is_filing_signer_likely_portfolio_manager
|
|
return is_filing_signer_likely_portfolio_manager(self.filing_signer_title)
|
|
|
|
@lru_cache(maxsize=8)
|
|
def previous_holding_report(self):
|
|
if len(self.report_period) == 1:
|
|
return None
|
|
# Look in the related filings data for the row with this accession number
|
|
idx = pc.equal(self._related_filings.data['accession_number'], self.accession_number).index(True).as_py()
|
|
if idx == 0:
|
|
return None
|
|
previous_filing = self._related_filings[idx - 1]
|
|
return ThirteenF(previous_filing, use_latest_period_of_report=False)
|
|
|
|
def __rich__(self):
|
|
from edgar.thirteenf.rendering import render_rich
|
|
return render_rich(self)
|
|
|
|
def __repr__(self):
|
|
from edgar.richtools import repr_rich
|
|
return repr_rich(self.__rich__())
|
|
|
|
|
|
# For backward compatibility, expose parse methods as static methods
|
|
ThirteenF.parse_primary_document_xml = staticmethod(lambda xml: __import__('edgar.thirteenf.parsers.primary_xml', fromlist=['parse_primary_document_xml']).parse_primary_document_xml(xml))
|
|
ThirteenF.parse_infotable_xml = staticmethod(lambda xml: __import__('edgar.thirteenf.parsers.infotable_xml', fromlist=['parse_infotable_xml']).parse_infotable_xml(xml))
|
|
ThirteenF.parse_infotable_txt = staticmethod(lambda txt: __import__('edgar.thirteenf.parsers.infotable_txt', fromlist=['parse_infotable_txt']).parse_infotable_txt(txt))
|