"""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 = (
""
"13F-HR"
"03-31-2023"
""
)
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 = 'MYFORM'
assert parse_13f.get_submission_type(xml) == "MYFORM"
def test_parse_header_namespaced_falls_back_to_type():
"""A namespaced trips the KeyError fallback, which then reads
the element instead."""
filing_str = (
''
''
"1"
"13F-HR"
""
)
header = parse_13f.parse_header(filing_str)
assert header == {"submissionType": "13F-HR"}
def test_parse_header_empty_header_raises():
"""An empty parses to a falsy dict and raises."""
xml = ""
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 = "4"
with pytest.raises(OpenBBError, match="period of report"):
parse_13f.get_period_ending(xml)
_INFO_TABLE_XML = """
13F-HR
03-31-2023
ACME CORP
COM
000000000
1000
500SH
Put
SOLE
50000
ACME CORP
COM
000000000
2000
1000SH
SOLE
100000
"""
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 = (
'\n\n'
""
"03-31-2023"
"\n"
""
"X CORPCOM"
"00000000010"
"5SH"
"SOLE"
"5"
"\n"
)
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 = """
06-30-2023
SOLO CORP
COM
111111111
4242
42SH
SOLE
42
"""
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_zero_value_filing_weights_zero():
"""A filing whose only row is the zero-value placeholder weights it 0.0."""
xml = """
03-31-2026
NA
COM
000000000
0
0SH
SOLE
000
"""
records = asyncio.run(parse_13f.parse_13f_hr(xml))
assert len(records) == 1
assert records[0]["value"] == 0
assert records[0]["weight"] == 0.0
@pytest.mark.parametrize("prefix", ["", "ns1", "n1"])
def test_parse_13f_hr_sgml_submission_keeps_ampersands(prefix):
"""Entities in an SGML-wrapped complete submission decode to ``&``."""
p = f"{prefix}:" if prefix else ""
xmlns = f"xmlns:{prefix}" if prefix else "xmlns"
filing = (
"0000000000-26-000001.txt : 20260515\n\n\n"
"\n13F-HR\n\n\n"
'\n'
""
"03-31-2026"
"\n\n\n\n"
"\nINFORMATION TABLE\n\n\n"
'\n'
f'<{p}informationTable {xmlns}="http://www.sec.gov/edgar/x">'
f"<{p}infoTable><{p}nameOfIssuer>BABCOCK & WILCOX{p}nameOfIssuer>"
f"<{p}titleOfClass>S&P500 EQL WGT{p}titleOfClass>"
f"<{p}cusip>05614L209{p}cusip><{p}value>100{p}value>"
f"<{p}shrsOrPrnAmt><{p}sshPrnamt>10{p}sshPrnamt>"
f"<{p}sshPrnamtType>SH{p}sshPrnamtType>{p}shrsOrPrnAmt>"
f"<{p}investmentDiscretion>SOLE{p}investmentDiscretion>"
f"<{p}votingAuthority><{p}Sole>10{p}Sole>{p}votingAuthority>{p}infoTable>"
f"{p}informationTable>\n\n\n\n\n"
)
records = asyncio.run(parse_13f.parse_13f_hr(filing))
assert records[0]["nameOfIssuer"] == "BABCOCK & WILCOX"
assert records[0]["titleOfClass"] == "S&P500 EQL WGT"
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 = """
03-31-2023
X CORP
COM
000000000
10
NOTANUMBERSH
SOLE
BAD00
"""
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 with no children yields parsed_xml=None and raises."""
xml = """
03-31-2023
"""
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 element is present the parser falls back to the
last . 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 = ""
with pytest.raises(KeyError, match="informationTable"):
asyncio.run(parse_13f.parse_13f_hr(filing))