Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions openbb_platform/providers/sec/openbb_sec/utils/parse_13f.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ def get_period_ending(filing_str: str):
async def parse_13f_hr(filing: str):
"""Parse a 13F-HR filing from the Complete Submission TXT file string."""
# pylint: disable=import-outside-toplevel
import re

import xmltodict
from bs4 import BeautifulSoup
from numpy import nan
Expand All @@ -134,6 +136,15 @@ async def parse_13f_hr(filing: str):
if filing.startswith("https://"):
filing = await get_complete_submission(filing) # type: ignore

# The Complete Submission TXT file is SGML-wrapped and not well-formed XML, so
# lxml falls back to recover mode, which silently drops character entities like
# '&amp;'. Reassemble the embedded well-formed <XML>...</XML> blocks under a
# synthetic root and parse that instead, to preserve entities such as '&'.
xml_blocks = re.findall(r"<XML>(.*?)</XML>", filing, re.DOTALL | re.IGNORECASE)
if xml_blocks:
decl = re.compile(r"<\?xml[^>]*\?>")
filing = "<root>" + "".join(decl.sub("", b) for b in xml_blocks) + "</root>"

soup = BeautifulSoup(filing, "xml")

info_table = soup.find_all("informationTable")
Expand Down
83 changes: 83 additions & 0 deletions openbb_platform/providers/sec/tests/test_parse_13f.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Test parsing of SEC Form 13F-HR Complete Submission TXT files."""

import pytest
from openbb_sec.utils.parse_13f import parse_13f_hr

# A Complete Submission TXT file is SGML-wrapped and not well-formed XML, so
# soup-ing it directly makes lxml fall back to recover mode, which silently
# drops character entities such as '&amp;'. This fixture reproduces that
# envelope, including an issuer name and title of class containing '&'.
SGML_FILING = """<SEC-DOCUMENT>0001234567-26-000001.txt : 20260401
<SEC-HEADER>0001234567-26-000001.hdr.sgml : 20260401
<ACCESSION-NUMBER>0001234567-26-000001
<TYPE>13F-HR
</SEC-HEADER>
<DOCUMENT>
<TYPE>13F-HR
<SEQUENCE>1
<XML>
<?xml version="1.0" encoding="UTF-8"?>
<edgarSubmission>
<headerData>
<submissionType>13F-HR</submissionType>
<filerInfo>
<periodOfReport>03-31-2026</periodOfReport>
</filerInfo>
</headerData>
</edgarSubmission>
</XML>
</DOCUMENT>
<DOCUMENT>
<TYPE>INFORMATION TABLE
<SEQUENCE>2
<XML>
<?xml version="1.0" encoding="UTF-8"?>
<informationTable xmlns="http://www.sec.gov/edgar/document/thirteenf/informationtable">
<infoTable>
<nameOfIssuer>S&amp;P500 EQL WGT</nameOfIssuer>
<titleOfClass>COM</titleOfClass>
<cusip>123456789</cusip>
<value>1000</value>
<shrsOrPrnAmt>
<sshPrnamt>500</sshPrnamt>
<sshPrnamtType>SH</sshPrnamtType>
</shrsOrPrnAmt>
<investmentDiscretion>SOLE</investmentDiscretion>
<votingAuthority>
<Sole>500</Sole>
<Shared>0</Shared>
<None>0</None>
</votingAuthority>
</infoTable>
<infoTable>
<nameOfIssuer>BABCOCK &amp; WILCOX ENTERPRISES</nameOfIssuer>
<titleOfClass>COM</titleOfClass>
<cusip>987654321</cusip>
<value>2000</value>
<shrsOrPrnAmt>
<sshPrnamt>800</sshPrnamt>
<sshPrnamtType>SH</sshPrnamtType>
</shrsOrPrnAmt>
<investmentDiscretion>SOLE</investmentDiscretion>
<votingAuthority>
<Sole>800</Sole>
<Shared>0</Shared>
<None>0</None>
</votingAuthority>
</infoTable>
</informationTable>
</XML>
</DOCUMENT>
</SEC-DOCUMENT>
"""


@pytest.mark.asyncio
async def test_parse_13f_hr_preserves_ampersand_entities():
"""Issuer/title names containing '&' must not be corrupted or dropped."""
result = await parse_13f_hr(SGML_FILING)
names = {r["nameOfIssuer"] for r in result}

assert "S&P500 EQL WGT" in names
assert "BABCOCK & WILCOX ENTERPRISES" in names
assert sum(r["weight"] for r in result) == pytest.approx(1.0)