"""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" f"<{p}titleOfClass>S&P500 EQL WGT" f"<{p}cusip>05614L209<{p}value>100" f"<{p}shrsOrPrnAmt><{p}sshPrnamt>10" f"<{p}sshPrnamtType>SH" f"<{p}investmentDiscretion>SOLE" f"<{p}votingAuthority><{p}Sole>10" f"\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 = "
1
" with pytest.raises(KeyError, match="informationTable"): asyncio.run(parse_13f.parse_13f_hr(filing))