mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
[Feature] Add form_13f Endpoint to equity.ownership (#6122)
* add form_13f endpoint * review things * option_type validator * black * codespell * add some comments to the code * remove duplicated process * static file * remove warning * static why no one updates you :-( --------- Co-authored-by: Pratyush Shukla <ps4534@nyu.edu> Co-authored-by: Igor Radovanovic <74266147+IgorWounds@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""From 13F-HR Standard Model."""
|
||||
|
||||
from datetime import date as dateType
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from openbb_core.provider.abstract.data import Data
|
||||
from openbb_core.provider.abstract.query_params import QueryParams
|
||||
from openbb_core.provider.utils.descriptions import (
|
||||
QUERY_DESCRIPTIONS,
|
||||
)
|
||||
|
||||
|
||||
class Form13FHRQueryParams(QueryParams):
|
||||
"""Form 13F-HR Query."""
|
||||
|
||||
__validator_dict__ = {"check_single": ("symbol")}
|
||||
|
||||
symbol: str = Field(
|
||||
description=QUERY_DESCRIPTIONS.get("symbol", "")
|
||||
+ " A CIK or Symbol can be used."
|
||||
)
|
||||
date: Optional[dateType] = Field(
|
||||
default=None,
|
||||
description=QUERY_DESCRIPTIONS.get("date", "")
|
||||
+ " The date represents the end of the reporting period."
|
||||
+ " All form 13F-HR filings are based on the calendar year"
|
||||
+ " and are reported quarterly."
|
||||
+ " If a date is not supplied, the most recent filing is returned."
|
||||
+ " Submissions beginning 2013-06-30 are supported.",
|
||||
)
|
||||
limit: Optional[int] = Field(
|
||||
default=1,
|
||||
description=QUERY_DESCRIPTIONS.get("limit", "")
|
||||
+ " The number of previous filings to return."
|
||||
+ " The date parameter takes priority over this parameter.",
|
||||
)
|
||||
|
||||
@field_validator("symbol", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def upper_symbol(cls, v: str):
|
||||
"""Convert symbol to uppercase."""
|
||||
return str(v).upper()
|
||||
|
||||
|
||||
class Form13FHRData(Data):
|
||||
"""
|
||||
Form 13F-HR Data.
|
||||
|
||||
Detailed documentation of the filing can be found here:
|
||||
https://www.sec.gov/pdf/form13f.pdf
|
||||
"""
|
||||
|
||||
period_ending: dateType = Field(
|
||||
description="The end-of-quarter date of the filing."
|
||||
)
|
||||
issuer: str = Field(description="The name of the issuer.")
|
||||
cusip: str = Field(description="The CUSIP of the security.")
|
||||
asset_class: str = Field(
|
||||
description="The title of the asset class for the security."
|
||||
)
|
||||
security_type: Optional[Literal["SH", "PRN"]] = Field(
|
||||
default=None,
|
||||
description="The total number of shares of the class of security"
|
||||
+ " or the principal amount of such class."
|
||||
+ " 'SH' for shares. 'PRN' for principal amount."
|
||||
+ " Convertible debt securities are reported as 'PRN'.",
|
||||
)
|
||||
option_type: Optional[Literal["call", "put"]] = Field(
|
||||
default=None,
|
||||
description="Defined when the holdings being reported are put or call options."
|
||||
+ " Only long positions are reported.",
|
||||
)
|
||||
voting_authority_sole: Optional[int] = Field(
|
||||
default=None,
|
||||
description="The number of shares for which the Manager"
|
||||
+ " exercises sole voting authority (none).",
|
||||
)
|
||||
voting_authority_shared: Optional[int] = Field(
|
||||
default=None,
|
||||
description="The number of shares for which the Manager"
|
||||
+ " exercises a defined shared voting authority (none).",
|
||||
)
|
||||
voting_authority_other: Optional[int] = Field(
|
||||
default=None,
|
||||
description="The number of shares for which the Manager"
|
||||
+ " exercises other shared voting authority (none).",
|
||||
)
|
||||
principal_amount: int = Field(
|
||||
description="The total number of shares of the class of security"
|
||||
+ " or the principal amount of such class."
|
||||
+ " Only long positions are reported"
|
||||
)
|
||||
value: int = Field(
|
||||
description="The fair market value of the holding of the particular class of security."
|
||||
+ " The value reported for options is the fair market value of the underlying security"
|
||||
+ " with respect to the number of shares controlled."
|
||||
+ " Values are rounded to the nearest US dollar"
|
||||
+ " and use the closing price of the last trading day of the calendar year or quarter.",
|
||||
)
|
||||
|
||||
@field_validator("option_type", mode="before", check_fields=False)
|
||||
@classmethod
|
||||
def validate_option_type(cls, v: str):
|
||||
"""Validate and convert to lower case."""
|
||||
return v.lower() if v else None
|
||||
@@ -1775,3 +1775,27 @@ def test_equity_fundamental_reported_financials(params, headers):
|
||||
result = requests.get(url, headers=headers, timeout=10)
|
||||
assert isinstance(result, requests.Response)
|
||||
assert result.status_code == 200
|
||||
|
||||
|
||||
@parametrize(
|
||||
"params",
|
||||
[
|
||||
(
|
||||
{
|
||||
"symbol": "NVDA",
|
||||
"date": None,
|
||||
"limit": 1,
|
||||
"provider": "sec",
|
||||
}
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.integration
|
||||
def test_equity_ownership_form_13f(params, headers):
|
||||
params = {p: v for p, v in params.items() if v}
|
||||
|
||||
query_str = get_querystring(params, [])
|
||||
url = f"http://0.0.0.0:8000/api/v1/equity/ownership/form_13f?{query_str}"
|
||||
result = requests.get(url, headers=headers, timeout=10)
|
||||
assert isinstance(result, requests.Response)
|
||||
assert result.status_code == 200
|
||||
|
||||
@@ -1664,3 +1664,26 @@ def test_equity_fundamental_reported_financials(params, obb):
|
||||
assert result
|
||||
assert isinstance(result, OBBject)
|
||||
assert len(result.results) > 0
|
||||
|
||||
|
||||
@parametrize(
|
||||
"params",
|
||||
[
|
||||
(
|
||||
{
|
||||
"symbol": "NVDA",
|
||||
"date": None,
|
||||
"limit": 1,
|
||||
"provider": "sec",
|
||||
}
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.integration
|
||||
def test_equity_ownership_form_13f(params, obb):
|
||||
params = {p: v for p, v in params.items() if v}
|
||||
|
||||
result = obb.equity.ownership.form_13f(**params)
|
||||
assert result
|
||||
assert isinstance(result, OBBject)
|
||||
assert len(result.results) > 0
|
||||
|
||||
@@ -57,3 +57,34 @@ async def share_statistics(
|
||||
) -> OBBject:
|
||||
"""Get data about share float for a given company."""
|
||||
return await OBBject.from_query(Query(**locals()))
|
||||
|
||||
|
||||
@router.command(
|
||||
model="Form13FHR",
|
||||
exclude_auto_examples=True,
|
||||
examples=[
|
||||
"### Enter the symbol as either the stock ticker or the CIK number as a string. ###",
|
||||
'obb.equity.ownership.form_13f(symbol="NVDA").to_df()',
|
||||
"### Enter a date (calendar quarter ending) for a specific report. ###",
|
||||
'obb.equity.ownership.form_13f(symbol="BRK-A", date="2016-09-30")',
|
||||
"### Use the `limit` parameter to return N number of reports from the most recent. ###",
|
||||
"### Example finding Michael Burry's filings. ###",
|
||||
'cik = obb.regulators.sec.institutions_search("Scion Asset Management").results[0].cik',
|
||||
"obb.equity.ownership.form_13f(cik, limit=2).to_df()",
|
||||
],
|
||||
)
|
||||
async def form_13f(
|
||||
cc: CommandContext,
|
||||
provider_choices: ProviderChoices,
|
||||
standard_params: StandardParams,
|
||||
extra_params: ExtraParams,
|
||||
) -> OBBject:
|
||||
"""
|
||||
The Securities and Exchange Commission's (SEC) Form 13F is a quarterly report
|
||||
that is required to be filed by all institutional investment managers with at least
|
||||
$100 million in assets under management.
|
||||
Managers are required to file Form 13F within 45 days after the last day of the calendar quarter.
|
||||
Most funds wait until the end of this period in order to conceal
|
||||
their investment strategy from competitors and the public.
|
||||
"""
|
||||
return await OBBject.from_query(Query(**locals()))
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"equity_fundamental_transcript": "/equity/fundamental/transcript",
|
||||
"equity_market_snapshots": "/equity/market_snapshots",
|
||||
"equity_ownership": "/equity/ownership",
|
||||
"equity_ownership_form_13f": "/equity/ownership/form_13f",
|
||||
"equity_ownership_insider_trading": "/equity/ownership/insider_trading",
|
||||
"equity_ownership_institutional": "/equity/ownership/institutional",
|
||||
"equity_ownership_major_holders": "/equity/ownership/major_holders",
|
||||
|
||||
@@ -157,7 +157,7 @@ class ROUTER_economy_gdp(Container):
|
||||
The provider to use for the query, by default None.
|
||||
If None, the provider specified in defaults is selected or 'oecd' if there is
|
||||
no default.
|
||||
country : Literal['australia', 'austria', 'belgium', 'brazil', 'canada', 'chile', 'colombia', 'costa_rica', 'czech_republic', 'denmark', 'estonia', 'euro_area', 'european_union', 'finland', 'france', 'germany', 'greece', 'hungary', 'iceland', 'indonesia', 'ireland', 'israel', 'italy', 'japan', 'korea', 'latvia', 'lithuania', 'luxembourg', 'mexico', 'netherlands', 'new_zealand', 'norway', 'poland', 'portugal', 'russia', 'slovak_republic', 'slovenia', 'south_africa', 'spain', 'sweden', 'switzerland', 'turkey', 'united_kingdom', 'united_states']
|
||||
country : Literal['australia', 'austria', 'belgium', 'brazil', 'canada', 'chile', 'colombia', 'costa_rica', 'czech_republic', 'denmark', 'estonia', 'euro_area', 'european_union', 'finland', 'france', 'germany', 'greece', 'hungary', 'iceland', 'indonesia', 'ireland', 'israel', 'italy', 'japan', 'korea', 'latvia', 'lithuania', 'luxembourg', 'mexico', 'netherlands', 'new_zealand', 'norway', 'poland', 'portugal', 'russia', 'slovak_republic', 'slovenia', 'south_africa', 'spain', 'sweden', 'switzerland', 'turkey', 'united_kingdom', 'united_states', 'all']
|
||||
Country to get GDP for. (provider: oecd)
|
||||
|
||||
Returns
|
||||
@@ -245,7 +245,7 @@ class ROUTER_economy_gdp(Container):
|
||||
The provider to use for the query, by default None.
|
||||
If None, the provider specified in defaults is selected or 'oecd' if there is
|
||||
no default.
|
||||
country : Literal['G20', 'G7', 'argentina', 'australia', 'austria', 'belgium', 'brazil', 'bulgaria', 'canada', 'chile', 'china', 'colombia', 'costa_rica', 'croatia', 'czech_republic', 'denmark', 'estonia', 'euro_area_19', 'europe', 'european_union_27', 'finland', 'france', 'germany', 'greece', 'hungary', 'iceland', 'india', 'indonesia', 'ireland', 'israel', 'italy', 'japan', 'korea', 'latvia', 'lithuania', 'luxembourg', 'mexico', 'netherlands', 'new_zealand', 'norway', 'oecd_total', 'poland', 'portugal', 'romania', 'russia', 'saudi_arabia', 'slovak_republic', 'slovenia', 'south_africa', 'spain', 'sweden', 'switzerland', 'turkey', 'united_kingdom', 'united_states']
|
||||
country : Literal['G20', 'G7', 'argentina', 'australia', 'austria', 'belgium', 'brazil', 'bulgaria', 'canada', 'chile', 'china', 'colombia', 'costa_rica', 'croatia', 'czech_republic', 'denmark', 'estonia', 'euro_area_19', 'europe', 'european_union_27', 'finland', 'france', 'germany', 'greece', 'hungary', 'iceland', 'india', 'indonesia', 'ireland', 'israel', 'italy', 'japan', 'korea', 'latvia', 'lithuania', 'luxembourg', 'mexico', 'netherlands', 'new_zealand', 'norway', 'oecd_total', 'poland', 'portugal', 'romania', 'russia', 'saudi_arabia', 'slovak_republic', 'slovenia', 'south_africa', 'spain', 'sweden', 'switzerland', 'turkey', 'united_kingdom', 'united_states', 'all']
|
||||
Country to get GDP for. (provider: oecd)
|
||||
|
||||
Returns
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing_extensions import Annotated
|
||||
|
||||
class ROUTER_equity_ownership(Container):
|
||||
"""/equity/ownership
|
||||
form_13f
|
||||
insider_trading
|
||||
institutional
|
||||
major_holders
|
||||
@@ -22,6 +23,125 @@ class ROUTER_equity_ownership(Container):
|
||||
def __repr__(self) -> str:
|
||||
return self.__doc__ or ""
|
||||
|
||||
@exception_handler
|
||||
@validate
|
||||
def form_13f(
|
||||
self,
|
||||
symbol: Annotated[
|
||||
str,
|
||||
OpenBBCustomParameter(
|
||||
description="Symbol to get data for. A CIK or Symbol can be used."
|
||||
),
|
||||
],
|
||||
date: Annotated[
|
||||
Union[datetime.date, None, str],
|
||||
OpenBBCustomParameter(
|
||||
description="A specific date to get data for. The date represents the end of the reporting period. All form 13F-HR filings are based on the calendar year and are reported quarterly. If a date is not supplied, the most recent filing is returned. Submissions beginning 2013-06-30 are supported."
|
||||
),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
Optional[int],
|
||||
OpenBBCustomParameter(
|
||||
description="The number of data entries to return. The number of previous filings to return. The date parameter takes priority over this parameter."
|
||||
),
|
||||
] = 1,
|
||||
provider: Optional[Literal["sec"]] = None,
|
||||
**kwargs
|
||||
) -> OBBject:
|
||||
"""The Securities and Exchange Commission's (SEC) Form 13F is a quarterly report
|
||||
that is required to be filed by all institutional investment managers with at least
|
||||
$100 million in assets under management.
|
||||
Managers are required to file Form 13F within 45 days after the last day of the calendar quarter.
|
||||
Most funds wait until the end of this period in order to conceal
|
||||
their investment strategy from competitors and the public.
|
||||
|
||||
|
||||
Parameters
|
||||
----------
|
||||
symbol : str
|
||||
Symbol to get data for. A CIK or Symbol can be used.
|
||||
date : Union[datetime.date, None, str]
|
||||
A specific date to get data for. The date represents the end of the reporting period. All form 13F-HR filings are based on the calendar year and are reported quarterly. If a date is not supplied, the most recent filing is returned. Submissions beginning 2013-06-30 are supported.
|
||||
limit : Optional[int]
|
||||
The number of data entries to return. The number of previous filings to return. The date parameter takes priority over this parameter.
|
||||
provider : Optional[Literal['sec']]
|
||||
The provider to use for the query, by default None.
|
||||
If None, the provider specified in defaults is selected or 'sec' if there is
|
||||
no default.
|
||||
|
||||
Returns
|
||||
-------
|
||||
OBBject
|
||||
results : List[Form13FHR]
|
||||
Serializable results.
|
||||
provider : Optional[Literal['sec']]
|
||||
Provider name.
|
||||
warnings : Optional[List[Warning_]]
|
||||
List of warnings.
|
||||
chart : Optional[Chart]
|
||||
Chart object.
|
||||
extra : Dict[str, Any]
|
||||
Extra info.
|
||||
|
||||
Form13FHR
|
||||
---------
|
||||
period_ending : date
|
||||
The end-of-quarter date of the filing.
|
||||
issuer : str
|
||||
The name of the issuer.
|
||||
cusip : str
|
||||
The CUSIP of the security.
|
||||
asset_class : str
|
||||
The title of the asset class for the security.
|
||||
security_type : Optional[Literal['SH', 'PRN']]
|
||||
The total number of shares of the class of security or the principal amount of such class. 'SH' for shares. 'PRN' for principal amount. Convertible debt securities are reported as 'PRN'.
|
||||
option_type : Optional[Literal['call', 'put']]
|
||||
Defined when the holdings being reported are put or call options. Only long positions are reported.
|
||||
voting_authority_sole : Optional[int]
|
||||
The number of shares for which the Manager exercises sole voting authority (none).
|
||||
voting_authority_shared : Optional[int]
|
||||
The number of shares for which the Manager exercises a defined shared voting authority (none).
|
||||
voting_authority_other : Optional[int]
|
||||
The number of shares for which the Manager exercises other shared voting authority (none).
|
||||
principal_amount : int
|
||||
The total number of shares of the class of security or the principal amount of such class. Only long positions are reported
|
||||
value : int
|
||||
The fair market value of the holding of the particular class of security. The value reported for options is the fair market value of the underlying security with respect to the number of shares controlled. Values are rounded to the nearest US dollar and use the closing price of the last trading day of the calendar year or quarter.
|
||||
weight : Optional[float]
|
||||
The weight of the security relative to the market value of all securities in the filing , as a normalized percent. (provider: sec)
|
||||
|
||||
Example
|
||||
-------
|
||||
>>> from openbb import obb
|
||||
>>> ### Enter the symbol as either the stock ticker or the CIK number as a string. ###
|
||||
>>> obb.equity.ownership.form_13f(symbol="NVDA").to_df()
|
||||
>>> ### Enter a date (calendar quarter ending) for a specific report. ###
|
||||
>>> obb.equity.ownership.form_13f(symbol="BRK-A", date="2016-09-30")
|
||||
>>> ### Use the `limit` parameter to return N number of reports from the most recent. ###
|
||||
>>> ### Example finding Michael Burry's filings. ###
|
||||
>>> cik = obb.regulators.sec.institutions_search("Scion Asset Management").results[0].cik
|
||||
>>> obb.equity.ownership.form_13f(cik, limit=2).to_df()
|
||||
""" # noqa: E501
|
||||
|
||||
return self._run(
|
||||
"/equity/ownership/form_13f",
|
||||
**filter_inputs(
|
||||
provider_choices={
|
||||
"provider": self._get_provider(
|
||||
provider,
|
||||
"/equity/ownership/form_13f",
|
||||
("sec",),
|
||||
)
|
||||
},
|
||||
standard_params={
|
||||
"symbol": symbol,
|
||||
"date": date,
|
||||
"limit": limit,
|
||||
},
|
||||
extra_params=kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
@exception_handler
|
||||
@validate
|
||||
def insider_trading(
|
||||
|
||||
@@ -290,7 +290,7 @@ class ROUTER_etf(Container):
|
||||
no default.
|
||||
date : Optional[Union[str, datetime.date]]
|
||||
A specific date to get data for. Entering a date will attempt to return the NPORT-P filing for the entered date. This needs to be _exactly_ the date of the filing. Use the holdings_date command/endpoint to find available filing dates for the ETF. (provider: fmp);
|
||||
A specific date to get data for. The date represents the period ending. The date entered will return the closest filing. (provider: sec)
|
||||
A specific date to get data for. The date represents the period ending. The date entered will return the closest filing. (provider: sec)
|
||||
cik : Optional[str]
|
||||
The CIK of the filing entity. Overrides symbol. (provider: fmp)
|
||||
use_cache : bool
|
||||
@@ -382,7 +382,7 @@ class ROUTER_etf(Container):
|
||||
in_arrears : Optional[str]
|
||||
If the debt security is in arrears. (provider: sec)
|
||||
is_paid_kind : Optional[str]
|
||||
If the debt security payments are are paid in kind. (provider: sec)
|
||||
If the debt security payments are paid in kind. (provider: sec)
|
||||
derivative_category : Optional[str]
|
||||
The derivative category of the holding. (provider: sec)
|
||||
counterparty : Optional[str]
|
||||
|
||||
@@ -6,6 +6,7 @@ from openbb_sec.models.company_filings import SecCompanyFilingsFetcher
|
||||
from openbb_sec.models.equity_ftd import SecEquityFtdFetcher
|
||||
from openbb_sec.models.equity_search import SecEquitySearchFetcher
|
||||
from openbb_sec.models.etf_holdings import SecEtfHoldingsFetcher
|
||||
from openbb_sec.models.form_13FHR import SecForm13FHRFetcher
|
||||
from openbb_sec.models.institutions_search import SecInstitutionsSearchFetcher
|
||||
from openbb_sec.models.rss_litigation import SecRssLitigationFetcher
|
||||
from openbb_sec.models.schema_files import SecSchemaFilesFetcher
|
||||
@@ -24,6 +25,7 @@ sec_provider = Provider(
|
||||
"EquitySearch": SecEquitySearchFetcher,
|
||||
"EtfHoldings": SecEtfHoldingsFetcher,
|
||||
"Filings": SecCompanyFilingsFetcher,
|
||||
"Form13FHR": SecForm13FHRFetcher,
|
||||
"InstitutionsSearch": SecInstitutionsSearchFetcher,
|
||||
"RssLitigation": SecRssLitigationFetcher,
|
||||
"SchemaFiles": SecSchemaFilesFetcher,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""SEC Form 13F-HR Model."""
|
||||
|
||||
# pylint: disable =unused-argument
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openbb_core.provider.abstract.fetcher import Fetcher
|
||||
from openbb_core.provider.standard_models.form_13FHR import (
|
||||
Form13FHRData,
|
||||
Form13FHRQueryParams,
|
||||
)
|
||||
from openbb_sec.utils import parse_13f
|
||||
from pydantic import Field
|
||||
|
||||
|
||||
class SecForm13FHRQueryParams(Form13FHRQueryParams):
|
||||
"""SEC Form 13F-HR Query Params.
|
||||
|
||||
Source: https://www.sec.gov/Archives/edgar/data/
|
||||
"""
|
||||
|
||||
|
||||
class SecForm13FHRData(Form13FHRData):
|
||||
"""SEC Form 13F-HR Data."""
|
||||
|
||||
__alias_dict__ = {
|
||||
"issuer": "nameOfIssuer",
|
||||
"asset_class": "titleOfClass",
|
||||
"option_type": "putCall",
|
||||
}
|
||||
|
||||
weight: float = Field(
|
||||
description="The weight of the security relative to the market value of all securities in the filing"
|
||||
+ " , as a normalized percent.",
|
||||
json_schema_extra={"x-unit_measurement": "percent", "x-frontend_multiply": 100},
|
||||
)
|
||||
|
||||
|
||||
class SecForm13FHRFetcher(Fetcher[SecForm13FHRQueryParams, List[SecForm13FHRData]]):
|
||||
"""SEC Form 13F-HR Fetcher."""
|
||||
|
||||
@staticmethod
|
||||
def transform_query(params: Dict[str, Any]) -> SecForm13FHRQueryParams:
|
||||
"""Transform the query."""
|
||||
return SecForm13FHRQueryParams(**params)
|
||||
|
||||
@staticmethod
|
||||
async def aextract_data(
|
||||
query: SecForm13FHRQueryParams,
|
||||
credentials: Optional[Dict[str, str]],
|
||||
**kwargs: Any,
|
||||
) -> List[Dict]:
|
||||
"""Return the raw data from the SEC endpoint."""
|
||||
symbol = query.symbol
|
||||
urls = []
|
||||
cik = symbol.isnumeric()
|
||||
filings = (
|
||||
parse_13f.get_13f_candidates(symbol=symbol)
|
||||
if cik is False
|
||||
else parse_13f.get_13f_candidates(cik=symbol)
|
||||
)
|
||||
if query.limit and query.date is None:
|
||||
urls = filings.iloc[: query.limit].to_list()
|
||||
if query.date is not None:
|
||||
date = parse_13f.date_to_quarter_end(query.date.strftime("%Y-%m-%d"))
|
||||
urls = [filings.loc[date]]
|
||||
|
||||
results = []
|
||||
|
||||
async def get_filing(url):
|
||||
"""Get a single 13F-HR filing and parse it."""
|
||||
|
||||
data = await parse_13f.parse_13f_hr(url)
|
||||
|
||||
if len(data) > 0:
|
||||
results.extend(data.to_dict("records"))
|
||||
|
||||
await asyncio.gather(*[get_filing(url) for url in urls])
|
||||
|
||||
return sorted(
|
||||
results, key=lambda d: [d["period_ending"], d["weight"]], reverse=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def transform_data(
|
||||
query: SecForm13FHRQueryParams,
|
||||
data: List[Dict],
|
||||
**kwargs: Any,
|
||||
) -> List[SecForm13FHRData]:
|
||||
"""Transform the data."""
|
||||
return [SecForm13FHRData.model_validate(d) for d in data]
|
||||
209
openbb_platform/providers/sec/openbb_sec/utils/parse_13f.py
Normal file
209
openbb_platform/providers/sec/openbb_sec/utils/parse_13f.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Utility functions for parsing SEC Form 13F-HR."""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
import xmltodict
|
||||
from bs4 import BeautifulSoup
|
||||
from openbb_core.provider.utils.helpers import amake_request
|
||||
from openbb_sec.models.company_filings import SecCompanyFilingsFetcher
|
||||
from openbb_sec.utils.definitions import HEADERS
|
||||
from pandas import DataFrame, offsets, to_datetime
|
||||
|
||||
|
||||
def date_to_quarter_end(date: str) -> str:
|
||||
"""Convert a date to the end of the calendar quarter."""
|
||||
return (
|
||||
(to_datetime(date).to_period("Q").to_timestamp("D") + offsets.QuarterEnd())
|
||||
.date()
|
||||
.strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
|
||||
def get_13f_candidates(symbol: Optional[str] = None, cik: Optional[str] = None):
|
||||
"""Get the 13F-HR filings for a given symbol or CIK."""
|
||||
fetcher = SecCompanyFilingsFetcher()
|
||||
params = {}
|
||||
if cik is not None:
|
||||
params["cik"] = str(cik)
|
||||
if symbol is not None:
|
||||
params["symbol"] = symbol
|
||||
if cik is None and symbol is None:
|
||||
raise ValueError("Either symbol or cik must be provided.")
|
||||
|
||||
params["use_cache"] = False
|
||||
params["form_type"] = "13F-HR"
|
||||
query = fetcher.transform_query(params)
|
||||
filings = fetcher.extract_data(query, {})
|
||||
if len(filings) == 0:
|
||||
raise ValueError(f"No 13F-HR filings found for {symbol if symbol else cik}.")
|
||||
|
||||
# Filings before June 30, 2013 are non-structured and are not supported by downstream parsers.
|
||||
return (
|
||||
DataFrame(data=filings)
|
||||
.query("`reportDate` >= '2013-06-30'")
|
||||
.set_index("reportDate")["completeSubmissionUrl"]
|
||||
)
|
||||
|
||||
|
||||
async def complete_submission_callback(response, _):
|
||||
"""Callback function for processing the response object."""
|
||||
if response.status == 200:
|
||||
return await response.text()
|
||||
raise RuntimeError(f"Request failed with status code {response.status}")
|
||||
|
||||
|
||||
async def get_complete_submission(url: str):
|
||||
"""Get the Complete Submission TXT file string from the SEC API."""
|
||||
return await amake_request(
|
||||
url, headers=HEADERS, response_callback=complete_submission_callback
|
||||
)
|
||||
|
||||
|
||||
def parse_header(filing_str: str) -> Dict:
|
||||
"""Parse the header of a Complete Submission TXT file string."""
|
||||
header_dict = {}
|
||||
try:
|
||||
soup = BeautifulSoup(filing_str, "lxml-xml")
|
||||
header_xml = soup.find("headerData")
|
||||
header_dict = xmltodict.parse(str(header_xml))["headerData"]
|
||||
except KeyError:
|
||||
soup = BeautifulSoup(filing_str, features="lxml")
|
||||
header_xml = soup.find("type")
|
||||
header_dict = xmltodict.parse(str(header_xml)).get("type")
|
||||
if header_dict:
|
||||
return header_dict # type: ignore
|
||||
raise ValueError(
|
||||
"Failed to parse the form header."
|
||||
+ " Check the `filing_str` to for the tag, 'headerData'."
|
||||
)
|
||||
|
||||
|
||||
def get_submission_type(filing_str: str):
|
||||
"""Get the submission type of a Complete Submission TXT file string."""
|
||||
header = parse_header(filing_str)
|
||||
if header:
|
||||
try:
|
||||
form_type = header["submissionType"]
|
||||
return form_type
|
||||
except KeyError:
|
||||
form_type = header["#text"]
|
||||
return form_type
|
||||
raise ValueError(
|
||||
"Failed to get the submission type from the form header."
|
||||
+ " Check the response from `parse_header`."
|
||||
)
|
||||
|
||||
|
||||
def get_period_ending(filing_str: str):
|
||||
"""Get the report date from a Complete Submission TXT file string."""
|
||||
header = parse_header(filing_str)
|
||||
if header.get("filerInfo"):
|
||||
return header["filerInfo"].get("periodOfReport")
|
||||
raise ValueError(
|
||||
"Failed to get the period of report from the form header."
|
||||
+ " Check the response from `parse_header`."
|
||||
)
|
||||
|
||||
|
||||
async def parse_13f_hr(filing: str):
|
||||
"""Parses a 13F-HR filing from the Complete Submission TXT file string."""
|
||||
data = DataFrame()
|
||||
|
||||
# Check if the input string is a URL
|
||||
if filing.startswith("https://"):
|
||||
filing = await get_complete_submission(filing) # type: ignore
|
||||
|
||||
# Validate the submission so we know that we can parse it.
|
||||
if get_submission_type(filing) not in ("13F-HR", "13F-HR/A"):
|
||||
raise ValueError("Submission type is not 13F-HR.")
|
||||
|
||||
soup = BeautifulSoup(filing, "lxml-xml")
|
||||
|
||||
info_table = soup.find_all("informationTable")
|
||||
if info_table == []:
|
||||
info_table = soup.find_all("table")[-1]
|
||||
|
||||
parsed_xml = xmltodict.parse(
|
||||
str(info_table[0]).replace("ns1:", "").replace("n1:", "")
|
||||
)["informationTable"]["infoTable"]
|
||||
|
||||
if parsed_xml is None:
|
||||
raise ValueError(
|
||||
"Failed to parse the 13F-HR information table."
|
||||
+ " Check the `filing_str` to make sure it is valid and contains the tag 'informationTable'."
|
||||
+ " Documents filed before Q2 2013 are not supported."
|
||||
)
|
||||
|
||||
period_ending = get_period_ending(filing)
|
||||
data = (
|
||||
DataFrame(parsed_xml)
|
||||
if isinstance(parsed_xml, list)
|
||||
else DataFrame([parsed_xml])
|
||||
)
|
||||
data.columns = data.columns.str.replace("ns1:", "")
|
||||
data["value"] = data["value"].astype(int)
|
||||
security_type = []
|
||||
principal_amount = []
|
||||
|
||||
# Unpack the nested objects
|
||||
try:
|
||||
security_type = [d.get("sshPrnamtType") for d in data["shrsOrPrnAmt"]]
|
||||
data["security_type"] = security_type
|
||||
principal_amount = [d.get("sshPrnamt") for d in data["shrsOrPrnAmt"]]
|
||||
data["principal_amount"] = principal_amount
|
||||
data.pop("shrsOrPrnAmt")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
sole = [d.get("Sole") for d in data["votingAuthority"]]
|
||||
shared = [d.get("Shared") for d in data["votingAuthority"]]
|
||||
none = [d.get("None") for d in data["votingAuthority"]]
|
||||
data["voting_authority_sole"] = [int(s) for s in sole]
|
||||
data["voting_authority_shared"] = [int(s) for s in shared]
|
||||
data["voting_authority_none"] = [int(s) for s in none]
|
||||
data.pop("votingAuthority")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Add the period ending so that the filing is identified when multiple are requested.
|
||||
data["period_ending"] = to_datetime(period_ending, yearfirst=False).date()
|
||||
|
||||
df = DataFrame(data)
|
||||
df["principal_amount"] = df["principal_amount"].astype(int)
|
||||
|
||||
# Aggregate the data because there are multiple entries for each security and we need the totals.
|
||||
# We break it down by CUSIP, security type, and option type.
|
||||
agg_index = ["cusip", "security_type", "putCall"]
|
||||
agg_columns = {
|
||||
"period_ending": "first",
|
||||
"nameOfIssuer": "first",
|
||||
"titleOfClass": "first",
|
||||
"value": "sum",
|
||||
"principal_amount": "sum",
|
||||
"voting_authority_sole": "sum",
|
||||
"voting_authority_shared": "sum",
|
||||
"voting_authority_none": "sum",
|
||||
}
|
||||
|
||||
# Only aggregate columns that exist in the DataFrame
|
||||
agg_columns = {k: v for k, v in agg_columns.items() if k in df.columns}
|
||||
agg_index = [k for k in agg_index if k in df.columns]
|
||||
df = df.groupby([*agg_index]).agg(agg_columns)
|
||||
|
||||
for col in [
|
||||
"voting_authority_sole",
|
||||
"voting_authority_shared",
|
||||
"voting_authority_none",
|
||||
]:
|
||||
if col in df.columns and all(df[col] == 0):
|
||||
df.drop(columns=col, inplace=True)
|
||||
|
||||
total_value = df["value"].sum()
|
||||
df["weight"] = round(df["value"] / total_value, 6)
|
||||
|
||||
return (
|
||||
df.reset_index()
|
||||
.fillna("N/A")
|
||||
.sort_values(by="weight", ascending=False)
|
||||
.replace("N/A", None)
|
||||
)
|
||||
5082
openbb_platform/providers/sec/tests/record/http/test_sec_fetchers/test_sec_form_13FHR_fetcher.yaml
vendored
Normal file
5082
openbb_platform/providers/sec/tests/record/http/test_sec_fetchers/test_sec_form_13FHR_fetcher.yaml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from openbb_sec.models.company_filings import SecCompanyFilingsFetcher
|
||||
from openbb_sec.models.equity_ftd import SecEquityFtdFetcher
|
||||
from openbb_sec.models.equity_search import SecEquitySearchFetcher
|
||||
from openbb_sec.models.etf_holdings import SecEtfHoldingsFetcher
|
||||
from openbb_sec.models.form_13FHR import SecForm13FHRFetcher
|
||||
from openbb_sec.models.institutions_search import SecInstitutionsSearchFetcher
|
||||
from openbb_sec.models.rss_litigation import SecRssLitigationFetcher
|
||||
from openbb_sec.models.schema_files import SecSchemaFilesFetcher
|
||||
@@ -112,3 +113,12 @@ def test_sec_cik_map_fetcher(credentials=test_credentials):
|
||||
fetcher = SecCikMapFetcher()
|
||||
result = fetcher.test(params, credentials)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.record_http
|
||||
def test_sec_form_13FHR_fetcher(credentials=test_credentials):
|
||||
params = {"symbol": "NVDA"}
|
||||
|
||||
fetcher = SecForm13FHRFetcher()
|
||||
result = fetcher.test(params, credentials)
|
||||
assert result is None
|
||||
|
||||
Reference in New Issue
Block a user