""" SEC 8-K Parser Service Parses 8-K primary document HTML to extract Items and create structured events. Supports Item 8.01 standalone (no exhibit required) by reading primary doc body. """ import asyncio import logging import re import time as _time from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple import uuid from bs4 import BeautifulSoup from sqlalchemy import select, and_, func from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from app.models.filing import SECFiling from app.models.filing_event import SECFilingEvent from app.services.sec_http_client import SECHttpClient logger = logging.getLogger(__name__) # Maximum text size for summary storage MAX_SUMMARY_CHARS = 10_000 # Maximum size for primary document fetch MAX_PRIMARY_DOC_SIZE = 5 * 1024 * 1024 # 5 MB # Item regex: matches "Item X.XX" with optional punctuation and title ITEM_RE = re.compile( r"Item\s+(\d+\.\d+)\s*[.\:\u2014\u2013\u2012\-]?\s*(.*)", re.IGNORECASE, ) # End-of-items marker END_MARKER_RE = re.compile(r"\bSIGNATURE[S]?\b", re.IGNORECASE) # Batch size for bulk parse queries _BULK_CHUNK = 50 class SEC8KParser: """Parse 8-K primary documents and persist structured events to DB.""" # Maps 8-K Item numbers to semantic event types ITEM_EVENT_MAP: Dict[str, Optional[str]] = { "1.01": "material_contract", "1.02": "contract_termination", "1.03": "bankruptcy", "1.04": "mine_safety", "2.01": "acquisition_disposition", "2.02": "earnings_result", "2.03": "financial_obligation", "2.04": "triggering_event", "2.05": "exit_activity", "2.06": "material_impairment", "3.01": "delisting_notice", "3.02": "unregistered_equity_sale", "3.03": "rights_modification", "4.01": "accountant_change", "4.02": "financial_restatement", "5.01": "control_change", "5.02": "management_change", "5.03": "articles_amendment", "5.05": "bylaws_amendment", "5.06": "shell_status_change", "5.07": "shareholder_vote", "5.08": "shareholder_nomination", "7.01": "regulation_fd", "8.01": "other_material_event", "9.01": None, # Exhibits listing — not a separate event } # Items that may have richer content in EX-99.1 (press release) EXHIBIT_ENRICHABLE = {"2.02", "7.01", "8.01"} def __init__(self) -> None: self._http = SECHttpClient("Stock Oracle 8K Parser") # ------------------------------------------------------------------ # Public: parse one filing by accession number # ------------------------------------------------------------------ async def parse_filing(self, db: AsyncSession, accession_number: str) -> int: """Parse a single filing. Returns number of events created/updated.""" result = await db.execute( select(SECFiling).where(SECFiling.accession_number == accession_number) ) filing = result.scalar_one_or_none() if not filing: raise ValueError(f"Filing {accession_number} not found in DB") return await self._parse_one(db, filing) # ------------------------------------------------------------------ # Public: parse multiple filings by accession numbers # ------------------------------------------------------------------ async def parse_filings_by_accessions( self, db: AsyncSession, accession_numbers: List[str] ) -> Dict[str, int]: """Parse multiple filings. Returns {accession: events_count}.""" result = await db.execute( select(SECFiling).where(SECFiling.accession_number.in_(accession_numbers)) ) filings = result.scalars().all() results: Dict[str, int] = {} for filing in filings: try: n = await self._parse_one(db, filing) results[filing.accession_number] = n except Exception as e: logger.warning(f"Parse failed for {filing.accession_number}: {e}") results[filing.accession_number] = 0 return results # ------------------------------------------------------------------ # Public: bulk parse pending 8-K filings # ------------------------------------------------------------------ async def parse_bulk( self, db: AsyncSession, tickers: Optional[List[str]] = None, limit: int = 100, ) -> Dict: """Parse pending 8-K filings, optionally filtered by ticker. Returns summary dict with succeeded/failed/skipped counts. """ t0 = _time.monotonic() conditions = [ SECFiling.parsed_status == "pending", SECFiling.form_type.in_(["8-K", "8-K/A"]), ] if tickers: conditions.append(SECFiling.ticker.in_([t.upper() for t in tickers])) result = await db.execute( select(SECFiling) .where(and_(*conditions)) .order_by(SECFiling.filing_date.desc()) .limit(limit) ) filings = result.scalars().all() if not filings: return { "succeeded": 0, "failed": 0, "skipped": 0, "total": 0, "elapsed": 0.0, } sem = asyncio.Semaphore(4) succeeded = failed = skipped = 0 async def _parse_limited(f: SECFiling) -> str: async with sem: from app.core.database import AsyncSessionLocal async with AsyncSessionLocal() as session: try: n = await self._parse_one(session, f) return "succeeded" if n > 0 else "skipped" except Exception as e: logger.warning(f"Bulk parse failed {f.accession_number}: {e}") return "failed" outcomes = await asyncio.gather(*[_parse_limited(f) for f in filings]) for outcome in outcomes: if outcome == "succeeded": succeeded += 1 elif outcome == "failed": failed += 1 else: skipped += 1 return { "succeeded": succeeded, "failed": failed, "skipped": skipped, "total": len(filings), "elapsed": round(_time.monotonic() - t0, 3), } # ------------------------------------------------------------------ # Internal: parse a single SECFiling object # ------------------------------------------------------------------ async def _parse_one(self, db: AsyncSession, filing: SECFiling) -> int: """Fetch and parse the primary document of a filing. Returns event count.""" self._http.set_deadline(60.0) now = datetime.now(timezone.utc) accession_number = filing.accession_number # Re-fetch filing in the current session to ensure it is tracked for updates result = await db.execute( select(SECFiling).where(SECFiling.accession_number == accession_number) ) filing = result.scalar_one_or_none() if not filing: raise ValueError(f"Filing {accession_number} not found in DB") try: # Get document list (cached in documents_json) from app.services.sec_filings_service import sec_filings_service docs = await sec_filings_service.get_filing_documents(db, filing.accession_number) # Find primary document URL primary_url = _find_primary_doc_url(docs, filing) if not primary_url: logger.warning( f"No primary document found for {filing.accession_number}, skipping" ) filing.parsed_status = "skipped" filing.updated_at = now await db.commit() return 0 # Fetch and parse HTML html = await self._http.fetch_text(primary_url, max_bytes=MAX_PRIMARY_DOC_SIZE) items = extract_items(html) if not items: logger.info(f"No items found in {filing.accession_number}, marking skipped") filing.parsed_status = "skipped" filing.items_json = [] filing.updated_at = now await db.commit() return 0 # Build event rows item_numbers = [item["number"] for item in items] events_to_upsert = [] for item in items: event_type = self.ITEM_EVENT_MAP.get(item["number"], "other") if event_type is None: continue # skip 9.01 (exhibits listing) content = item["content"] content_source = "primary_doc" # Optionally enrich with exhibit content for press-release items if item["number"] in self.EXHIBIT_ENRICHABLE: exhibit_content = await _try_exhibit_content( db, filing.accession_number, docs ) if exhibit_content and len(exhibit_content) > len(content): content = exhibit_content content_source = "exhibit" summary = content[:MAX_SUMMARY_CHARS] if content else None title = item.get("title") or filing.filing_description or "" events_to_upsert.append({ "id": uuid.uuid4(), "ticker": filing.ticker, "accession_number": filing.accession_number, "form_type": filing.form_type, "filing_date": filing.filing_date, "item_number": item["number"], "event_type": event_type, "title": title[:512] if title else None, "summary": summary, "content_source": content_source, "created_at": now, "updated_at": now, }) if events_to_upsert: stmt = pg_insert(SECFilingEvent).values(events_to_upsert) stmt = stmt.on_conflict_do_update( constraint="uq_filing_event", set_={ "event_type": stmt.excluded.event_type, "title": stmt.excluded.title, "summary": stmt.excluded.summary, "content_source": stmt.excluded.content_source, "updated_at": stmt.excluded.updated_at, }, ) await db.execute(stmt) # Update filing parse status filing.parsed_status = "succeeded" if events_to_upsert else "skipped" filing.items_json = item_numbers filing.updated_at = now await db.commit() logger.info( f"Parsed {filing.accession_number} ({filing.ticker}): " f"{len(events_to_upsert)} events from items {item_numbers}" ) return len(events_to_upsert) except Exception as e: logger.error(f"Parse error for {filing.accession_number}: {e}") try: filing.parsed_status = "failed" filing.updated_at = now await db.commit() except Exception: await db.rollback() raise finally: self._http.clear_deadline() # ------------------------------------------------------------------ # Module-level helpers # ------------------------------------------------------------------ def extract_items(html: str) -> List[Dict]: """Extract 8-K items from primary document HTML. Returns list of {"number": "8.01", "title": "Other Events", "content": "..."}. Deduplicates item numbers (table of contents entries overwritten by body entries). """ soup = BeautifulSoup(html, "html.parser") # Remove noise tags for tag in soup.find_all(["script", "style", "ix:header"]): tag.decompose() text = soup.get_text(separator="\n") # Find all Item headers all_matches = list(ITEM_RE.finditer(text)) if not all_matches: return [] # Deduplicate: keep the *last* occurrence of each item number # (earlier occurrences are usually the table of contents) seen: Dict[str, re.Match] = {} for m in all_matches: num = m.group(1) seen[num] = m # last match wins deduped = sorted(seen.values(), key=lambda m: m.start()) # Find end-of-body marker (SIGNATURES section) last_item_end = deduped[-1].end() if deduped else 0 sig_match = END_MARKER_RE.search(text, last_item_end) text_end = sig_match.start() if sig_match else len(text) items: List[Dict] = [] for i, m in enumerate(deduped): number = m.group(1) title = m.group(2).strip().rstrip(".").strip() content_start = m.end() content_end = deduped[i + 1].start() if i + 1 < len(deduped) else text_end content = text[content_start:content_end].strip() # Clean up excessive whitespace content = re.sub(r"\n{3,}", "\n\n", content) items.append({"number": number, "title": title, "content": content}) return items def _find_primary_doc_url(docs: List[Dict], filing: SECFiling) -> Optional[str]: """Find the primary 8-K document URL. Prefers the stored primary_document_url (always a direct link from SEC EDGAR submissions JSON). Falls back to documents_json with iXBRL viewer URL stripping. """ # Primary URL from the submissions JSON is always a direct link — prefer it if filing.primary_document_url: return filing.primary_document_url if not docs: return None form_type_upper = filing.form_type.upper() # Exact type match (e.g., "8-K") for doc in docs: doc_type = (doc.get("type") or "").upper() if doc_type == form_type_upper: return _strip_ixbrl_viewer(doc.get("url") or "") # Fallback: type contains form type base (strip "/A") for doc in docs: doc_type = (doc.get("type") or "").upper() if form_type_upper.replace("/A", "") in doc_type: return _strip_ixbrl_viewer(doc.get("url") or "") return None def _strip_ixbrl_viewer(url: str) -> str: """Convert '/ix?doc=/Archives/...' URLs to direct document URLs.""" # Pattern: https://www.sec.gov/ix?doc=/Archives/edgar/data/... if "/ix?doc=" in url: idx = url.index("/ix?doc=") path = url[idx + len("/ix?doc="):] # e.g. /Archives/edgar/... if path.startswith("/"): return "https://www.sec.gov" + path return url async def _try_exhibit_content( db: AsyncSession, accession_number: str, docs: List[Dict], ) -> Optional[str]: """Try to fetch EX-99.1 exhibit content. Returns None on failure.""" has_exhibit = any( (d.get("type") or "").upper() == "EX-99.1" for d in docs ) if not has_exhibit: return None try: from app.services.sec_filings_service import sec_filings_service result = await asyncio.wait_for( sec_filings_service.get_exhibit_content(db, accession_number, "EX-99.1"), timeout=20, ) content = result.get("content", "") if content: # Strip HTML tags from exhibit content soup = BeautifulSoup(content, "html.parser") for tag in soup.find_all(["script", "style"]): tag.decompose() return soup.get_text(separator="\n").strip() return None except Exception: return None # Singleton sec_8k_parser = SEC8KParser()