mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-30 05:52:23 +00:00
* refactor openbb-sec for v5 * linting * ty * normalize compare_company_facts * add workflows * python 3.10 fix * add more thorough url validation and tests * ty check * ty for python 3.10 * reconfigure with apps * temp pyproject overrride * pyproject * pyproject * pyproject * codespell * pyproject * fix uk schema file links in schema_files * test coverage * test_cli * port #7547 into this pr * no hard coded prefix * improve filing rendering capabilities * fix xml text detection * make codeql happy * missed a file in the commit * test coverage * codespell * fix flakey test * some updates * management_ownership --> management_profiles * proxy statement markdown * ruff * fix test coverage * more tests * test coverage * fix lint errors * Add SEC adviser search, profile, and universe routes (#7559) * Add SEC institutional data fetchers * Add SEC filing index and Form D routes * Add SEC investment adviser people data * Expose SEC adviser and institutional routes * fix(sec): address adviser review feedback * refactor(sec): narrow adviser support to IAPD search * feat(sec): add adviser firm and individual searches * feat(sec): add adviser profile drill-down * fix: repair pre-commit configuration * feat(sec): add adviser universe route * feat(sec): expose complete Form ADV data --------- Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com> * fix test coverage --------- Co-authored-by: deeleeramone <> Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""Unit tests for ``openbb_sec.utils.parse_13f``.
|
|
|
|
These exercise the parsing/helper functions directly with crafted synthetic XML
|
|
strings and mocked transport, covering branches the fetcher/VCR suites never
|
|
reach. No real HTTP is performed.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from openbb_core.app.model.abstract.error import OpenBBError
|
|
|
|
from openbb_sec.utils import parse_13f
|
|
|
|
|
|
def test_date_to_quarter_end():
|
|
"""A mid-quarter date snaps to the calendar quarter end."""
|
|
assert parse_13f.date_to_quarter_end("2023-05-15") == "2023-06-30"
|
|
assert parse_13f.date_to_quarter_end("2023-01-01") == "2023-03-31"
|
|
assert parse_13f.date_to_quarter_end("2023-12-31") == "2023-12-31"
|
|
|
|
|
|
def test_get_13f_candidates_requires_input():
|
|
"""get_13f_candidates raises when neither symbol nor cik is given."""
|
|
with pytest.raises(OpenBBError, match="Either symbol or cik"):
|
|
asyncio.run(parse_13f.get_13f_candidates())
|
|
|
|
|
|
def test_get_13f_candidates_no_filings():
|
|
"""No 13F-HR filings raises a descriptive error (cik branch)."""
|
|
|
|
class _Fetcher:
|
|
async def fetch_data(self, params, creds):
|
|
# Assert the cik branch populated params.
|
|
assert params["cik"] == "1067983"
|
|
assert params["form_type"] == "13F-HR"
|
|
return []
|
|
|
|
# The fetcher is imported inside the function from its defining module.
|
|
with patch("openbb_sec.models.company_filings.SecCompanyFilingsFetcher", _Fetcher):
|
|
with pytest.raises(OpenBBError, match="No 13F-HR filings"):
|
|
asyncio.run(parse_13f.get_13f_candidates(cik="1067983"))
|
|
|
|
|
|
def test_get_13f_candidates_no_filings_symbol_branch():
|
|
"""The symbol branch populates params['symbol'] and raises on no filings."""
|
|
|
|
class _Fetcher:
|
|
async def fetch_data(self, params, creds):
|
|
assert params["symbol"] == "AAPL"
|
|
assert "cik" not in params
|
|
return []
|
|
|
|
with patch("openbb_sec.models.company_filings.SecCompanyFilingsFetcher", _Fetcher):
|
|
with pytest.raises(OpenBBError, match="No 13F-HR filings found for AAPL"):
|
|
asyncio.run(parse_13f.get_13f_candidates(symbol="AAPL"))
|
|
|
|
|
|
def test_get_13f_candidates_returns_filings():
|
|
"""Filings return as a report-date-indexed URL series; pre-2013 dropped."""
|
|
from datetime import date
|
|
from types import SimpleNamespace
|
|
|
|
def _mk(report_date, url):
|
|
return SimpleNamespace(
|
|
model_dump=lambda rd=report_date, u=url: {
|
|
"report_date": rd,
|
|
"complete_submission_url": u,
|
|
}
|
|
)
|
|
|
|
class _Fetcher:
|
|
async def fetch_data(self, params, creds):
|
|
return [
|
|
_mk(date(2023, 9, 30), "https://sec.gov/new.txt"),
|
|
_mk(date(2010, 3, 31), "https://sec.gov/old.txt"),
|
|
]
|
|
|
|
with patch("openbb_sec.models.company_filings.SecCompanyFilingsFetcher", _Fetcher):
|
|
out = asyncio.run(parse_13f.get_13f_candidates(symbol="BRK-A"))
|
|
assert out.loc[date(2023, 9, 30)] == "https://sec.gov/new.txt"
|
|
assert date(2010, 3, 31) not in out.index
|
|
|
|
|
|
def test_get_complete_submission_uses_amake_request(monkeypatch):
|
|
"""get_complete_submission calls the rate-limited request with the callback."""
|
|
captured = {}
|
|
|
|
async def _amake(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["kwargs"] = kwargs
|
|
return "FILING TEXT"
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.ratelimit.sec_amake_request", _amake)
|
|
out = asyncio.run(parse_13f.get_complete_submission("https://sec.gov/x.txt"))
|
|
assert out == "FILING TEXT"
|
|
assert captured["url"] == "https://sec.gov/x.txt"
|
|
assert (
|
|
captured["kwargs"]["response_callback"]
|
|
is parse_13f.complete_submission_callback
|
|
)
|
|
|
|
|
|
def test_complete_submission_callback_status():
|
|
"""complete_submission_callback returns text on 200, raises otherwise."""
|
|
|
|
class _Ok:
|
|
status = 200
|
|
|
|
async def text(self):
|
|
return "BODY"
|
|
|
|
class _Bad:
|
|
status = 503
|
|
|
|
async def text(self):
|
|
return ""
|
|
|
|
assert asyncio.run(parse_13f.complete_submission_callback(_Ok(), None)) == "BODY"
|
|
with pytest.raises(OpenBBError, match="status code 503"):
|
|
asyncio.run(parse_13f.complete_submission_callback(_Bad(), None))
|
|
|
|
|
|
_HEADER_XML = (
|
|
"<edgarSubmission>"
|
|
"<headerData><submissionType>13F-HR</submissionType>"
|
|
"<filerInfo><periodOfReport>03-31-2023</periodOfReport></filerInfo>"
|
|
"</headerData></edgarSubmission>"
|
|
)
|
|
|
|
|
|
def test_parse_header_and_submission_type():
|
|
"""parse_header extracts headerData; submission type & period read from it."""
|
|
header = parse_13f.parse_header(_HEADER_XML)
|
|
assert header["submissionType"] == "13F-HR"
|
|
assert parse_13f.get_submission_type(_HEADER_XML) == "13F-HR"
|
|
assert parse_13f.get_period_ending(_HEADER_XML) == "03-31-2023"
|
|
|
|
|
|
def test_get_submission_type_text_fallback():
|
|
"""When submissionType is absent, the '#text' value is returned."""
|
|
# headerData with a text body + attribute parses to {'@a':..,'#text':..}.
|
|
xml = '<edgarSubmission><headerData a="x">MYFORM</headerData></edgarSubmission>'
|
|
assert parse_13f.get_submission_type(xml) == "MYFORM"
|
|
|
|
|
|
def test_parse_header_namespaced_falls_back_to_type():
|
|
"""A namespaced <headerData> trips the KeyError fallback, which then reads
|
|
the <type> element instead."""
|
|
filing_str = (
|
|
'<?xml version="1.0"?>'
|
|
'<edgarSubmission xmlns:ns1="http://www.sec.gov/edgar/x">'
|
|
"<ns1:headerData><a>1</a></ns1:headerData>"
|
|
"<type><submissionType>13F-HR</submissionType></type>"
|
|
"</edgarSubmission>"
|
|
)
|
|
header = parse_13f.parse_header(filing_str)
|
|
assert header == {"submissionType": "13F-HR"}
|
|
|
|
|
|
def test_parse_header_empty_header_raises():
|
|
"""An empty <headerData> parses to a falsy dict and raises."""
|
|
xml = "<edgarSubmission><headerData></headerData></edgarSubmission>"
|
|
with pytest.raises(OpenBBError, match="Failed to parse the form header"):
|
|
parse_13f.parse_header(xml)
|
|
|
|
|
|
def test_get_period_ending_raises_without_filer_info():
|
|
"""A header lacking filerInfo raises a period-of-report error."""
|
|
xml = "<edgarSubmission><headerData><submissionType>4</submissionType></headerData></edgarSubmission>"
|
|
with pytest.raises(OpenBBError, match="period of report"):
|
|
parse_13f.get_period_ending(xml)
|
|
|
|
|
|
_INFO_TABLE_XML = """<?xml version="1.0"?>
|
|
<edgarSubmission xmlns="http://www.sec.gov/edgar/thirteenffiler">
|
|
<headerData>
|
|
<submissionType>13F-HR</submissionType>
|
|
<filerInfo><periodOfReport>03-31-2023</periodOfReport></filerInfo>
|
|
</headerData>
|
|
<formData>
|
|
<informationTable>
|
|
<infoTable>
|
|
<nameOfIssuer>ACME CORP</nameOfIssuer>
|
|
<titleOfClass>COM</titleOfClass>
|
|
<cusip>000000000</cusip>
|
|
<value>1000</value>
|
|
<shrsOrPrnAmt><sshPrnamt>500</sshPrnamt><sshPrnamtType>SH</sshPrnamtType></shrsOrPrnAmt>
|
|
<putCall>Put</putCall>
|
|
<investmentDiscretion>SOLE</investmentDiscretion>
|
|
<votingAuthority><Sole>500</Sole><Shared>0</Shared><None>0</None></votingAuthority>
|
|
</infoTable>
|
|
<infoTable>
|
|
<nameOfIssuer>ACME CORP</nameOfIssuer>
|
|
<titleOfClass>COM</titleOfClass>
|
|
<cusip>000000000</cusip>
|
|
<value>2000</value>
|
|
<shrsOrPrnAmt><sshPrnamt>1000</sshPrnamt><sshPrnamtType>SH</sshPrnamtType></shrsOrPrnAmt>
|
|
<investmentDiscretion>SOLE</investmentDiscretion>
|
|
<votingAuthority><Sole>1000</Sole><Shared>0</Shared><None>0</None></votingAuthority>
|
|
</infoTable>
|
|
</informationTable>
|
|
</formData>
|
|
</edgarSubmission>"""
|
|
|
|
|
|
def test_parse_13f_hr_aggregates_and_weights():
|
|
"""parse_13f_hr unpacks nested objects, aggregates by CUSIP, and weights."""
|
|
records = asyncio.run(parse_13f.parse_13f_hr(_INFO_TABLE_XML))
|
|
assert len(records) == 2
|
|
# Sorted by weight descending; the 2000-value (no put/call) row is first.
|
|
assert records[0]["value"] == 2000
|
|
assert records[0]["principal_amount"] == 1000
|
|
assert records[0]["security_type"] == "SH"
|
|
assert records[0]["putCall"] is None # "--" replaced with None
|
|
assert records[0]["voting_authority_sole"] == 1000
|
|
assert str(records[0]["period_ending"]) == "2023-03-31"
|
|
assert round(sum(r["weight"] for r in records), 6) == 1.0
|
|
# The put option row carries its putCall value.
|
|
put_row = next(r for r in records if r["value"] == 1000)
|
|
assert put_row["putCall"] == "Put"
|
|
|
|
|
|
def test_parse_13f_hr_url_fetches_submission():
|
|
"""A https filing argument is downloaded via get_complete_submission."""
|
|
|
|
async def _fake_get(url):
|
|
return _INFO_TABLE_XML
|
|
|
|
with patch.object(parse_13f, "get_complete_submission", _fake_get):
|
|
records = asyncio.run(
|
|
parse_13f.parse_13f_hr("https://sec.gov/filing.txt", use_cache=False)
|
|
)
|
|
assert len(records) == 2
|
|
|
|
|
|
def test_parse_13f_hr_url_cache_hit(monkeypatch):
|
|
"""A cached parse is returned without downloading the filing."""
|
|
|
|
async def _aget(key):
|
|
return [{"value": 1}]
|
|
|
|
async def _boom(url):
|
|
raise AssertionError("should not download on a cache hit")
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.cache.aget_cached", _aget)
|
|
monkeypatch.setattr(parse_13f, "get_complete_submission", _boom)
|
|
records = asyncio.run(parse_13f.parse_13f_hr("https://sec.gov/x.txt"))
|
|
assert records == [{"value": 1}]
|
|
|
|
|
|
def test_parse_13f_hr_url_cache_write(monkeypatch):
|
|
"""A freshly parsed URL filing is written to the cache."""
|
|
saved: dict = {}
|
|
|
|
async def _aget(key):
|
|
return None
|
|
|
|
async def _aset(key, value, expire=None):
|
|
saved["key"] = key
|
|
saved["value"] = value
|
|
|
|
async def _fake_get(url):
|
|
return _INFO_TABLE_XML
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.cache.aget_cached", _aget)
|
|
monkeypatch.setattr("openbb_sec.utils.cache.aset_cached", _aset)
|
|
monkeypatch.setattr(parse_13f, "get_complete_submission", _fake_get)
|
|
records = asyncio.run(parse_13f.parse_13f_hr("https://sec.gov/x.txt"))
|
|
assert len(records) == 2
|
|
assert saved["key"] == "GET https://sec.gov/x.txt ::13f-parsed"
|
|
assert saved["value"] == records
|
|
|
|
|
|
def test_parse_13f_hr_period_regex_fallback():
|
|
"""A period tag the fast regex misses falls back to header parsing."""
|
|
xml = (
|
|
'<?xml version="1.0"?>\n<edgarSubmission>\n'
|
|
"<headerData><filerInfo>"
|
|
"<periodOfReport >03-31-2023</periodOfReport>"
|
|
"</filerInfo></headerData>\n"
|
|
"<informationTable><infoTable>"
|
|
"<nameOfIssuer>X CORP</nameOfIssuer><titleOfClass>COM</titleOfClass>"
|
|
"<cusip>000000000</cusip><value>10</value>"
|
|
"<shrsOrPrnAmt><sshPrnamt>5</sshPrnamt><sshPrnamtType>SH</sshPrnamtType>"
|
|
"</shrsOrPrnAmt><investmentDiscretion>SOLE</investmentDiscretion>"
|
|
"<votingAuthority><Sole>5</Sole></votingAuthority>"
|
|
"</infoTable></informationTable>\n</edgarSubmission>"
|
|
)
|
|
records = asyncio.run(parse_13f.parse_13f_hr(xml))
|
|
assert str(records[0]["period_ending"]) == "2023-03-31"
|
|
|
|
|
|
def test_parse_13f_hr_single_info_table():
|
|
"""A single infoTable (dict, not list) is wrapped and parsed."""
|
|
single = """<?xml version="1.0"?>
|
|
<edgarSubmission>
|
|
<headerData><filerInfo><periodOfReport>06-30-2023</periodOfReport></filerInfo></headerData>
|
|
<informationTable>
|
|
<infoTable>
|
|
<nameOfIssuer>SOLO CORP</nameOfIssuer>
|
|
<titleOfClass>COM</titleOfClass>
|
|
<cusip>111111111</cusip>
|
|
<value>4242</value>
|
|
<shrsOrPrnAmt><sshPrnamt>42</sshPrnamt><sshPrnamtType>SH</sshPrnamtType></shrsOrPrnAmt>
|
|
<investmentDiscretion>SOLE</investmentDiscretion>
|
|
<votingAuthority><Sole>42</Sole></votingAuthority>
|
|
</infoTable>
|
|
</informationTable>
|
|
</edgarSubmission>"""
|
|
records = asyncio.run(parse_13f.parse_13f_hr(single))
|
|
assert len(records) == 1
|
|
assert records[0]["value"] == 4242
|
|
assert records[0]["weight"] == 1.0
|
|
|
|
|
|
def test_parse_13f_hr_non_numeric_share_and_voting_values():
|
|
"""Non-numeric share/voting values trigger the ``except ValueError`` branches.
|
|
|
|
A non-numeric ``sshPrnamt`` makes ``int(...)`` raise inside the shrsOrPrnAmt
|
|
unpack, so no ``principal_amount`` / ``security_type`` columns are produced;
|
|
likewise a non-numeric ``Sole`` skips the voting-authority columns. The row
|
|
still parses with its integer ``value``.
|
|
"""
|
|
xml = """<?xml version="1.0"?>
|
|
<edgarSubmission>
|
|
<headerData><filerInfo><periodOfReport>03-31-2023</periodOfReport></filerInfo></headerData>
|
|
<informationTable>
|
|
<infoTable>
|
|
<nameOfIssuer>X CORP</nameOfIssuer>
|
|
<titleOfClass>COM</titleOfClass>
|
|
<cusip>000000000</cusip>
|
|
<value>10</value>
|
|
<shrsOrPrnAmt><sshPrnamt>NOTANUMBER</sshPrnamt><sshPrnamtType>SH</sshPrnamtType></shrsOrPrnAmt>
|
|
<investmentDiscretion>SOLE</investmentDiscretion>
|
|
<votingAuthority><Sole>BAD</Sole><Shared>0</Shared><None>0</None></votingAuthority>
|
|
</infoTable>
|
|
</informationTable>
|
|
</edgarSubmission>"""
|
|
records = asyncio.run(parse_13f.parse_13f_hr(xml))
|
|
assert len(records) == 1
|
|
assert records[0]["value"] == 10
|
|
# The ValueError branches leave these derived columns out entirely.
|
|
assert "principal_amount" not in records[0]
|
|
assert "voting_authority_sole" not in records[0]
|
|
|
|
|
|
def test_parse_13f_hr_empty_info_table_raises():
|
|
"""An <infoTable> with no children yields parsed_xml=None and raises."""
|
|
xml = """<?xml version="1.0"?>
|
|
<edgarSubmission>
|
|
<headerData><filerInfo><periodOfReport>03-31-2023</periodOfReport></filerInfo></headerData>
|
|
<informationTable><infoTable></infoTable></informationTable>
|
|
</edgarSubmission>"""
|
|
with pytest.raises(
|
|
OpenBBError, match="Failed to parse the 13F-HR information table"
|
|
):
|
|
asyncio.run(parse_13f.parse_13f_hr(xml))
|
|
|
|
|
|
def test_parse_13f_hr_table_fallback_indexes_as_list():
|
|
"""When no <informationTable> element is present the parser falls back to the
|
|
last <table>. That fallback must remain a *list* so ``info_table[0]`` selects
|
|
the element; previously it assigned a bare ``Tag`` and ``Tag[0]`` raised
|
|
``KeyError: 0`` before the downstream lookup ran. With the fix, execution
|
|
reaches the ``["informationTable"]`` lookup, which raises ``KeyError`` on the
|
|
key name (not ``0``) for this minimal input."""
|
|
filing = "<edgarSubmission><table><row>1</row></table></edgarSubmission>"
|
|
with pytest.raises(KeyError, match="informationTable"):
|
|
asyncio.run(parse_13f.parse_13f_hr(filing))
|