@@ -134,6 +134,21 @@ async def parse_13f_hr(filing: str):
134134 if filing .startswith ("https://" ):
135135 filing = await get_complete_submission (filing ) # type: ignore
136136
137+ # A Complete Submission TXT file is SGML-wrapped and not well-formed XML.
138+ # Feeding it to the XML parser as-is triggers lxml's recover mode, which
139+ # silently drops character entities: "S&P500" becomes "SP500" and
140+ # "BABCOCK & WILCOX" loses its ampersand in nameOfIssuer/titleOfClass.
141+ # Extract the embedded well-formed <XML> blocks (form header + information
142+ # table) and reassemble them under a synthetic root so the strict XML path
143+ # is used and entities are preserved. Inputs that are already bare XML
144+ # (no <XML> wrapper) are passed through unchanged.
145+ import re as _re # noqa: PLC0415
146+
147+ _xml_blocks = _re .findall (r"<XML>(.*?)</XML>" , filing , _re .DOTALL | _re .IGNORECASE )
148+ if _xml_blocks :
149+ _decl = _re .compile (r"<\?xml[^>]*\?>" )
150+ filing = "<root>" + "" .join (_decl .sub ("" , b ) for b in _xml_blocks ) + "</root>"
151+
137152 soup = BeautifulSoup (filing , "xml" )
138153
139154 info_table = soup .find_all ("informationTable" )
@@ -221,7 +236,13 @@ async def parse_13f_hr(filing: str):
221236 df .drop (columns = col , inplace = True )
222237
223238 total_value = df .value .sum ()
224- df ["weight" ] = round (df .value .astype (float ) / total_value , 6 )
239+ # Guard against empty filings: managers with no reportable holdings file a
240+ # single placeholder row (value=0), making total_value 0. Without the guard,
241+ # 0/0 becomes NaN, which `.replace({nan: None})` below turns into None and
242+ # the required `weight: float` field then fails validation.
243+ df ["weight" ] = (
244+ round (df .value .astype (float ) / total_value , 6 ) if total_value else 0.0
245+ )
225246
226247 return (
227248 df .reset_index ()
0 commit comments