feat: 8-K filing parser — Item 추출 + event 생성
AVGO 8-K (accession 0001193125-26-144028, Item 8.01, Google TPU 공급계약)이
DB에 수집은 되었으나 파싱/event 생성이 불가했던 문제 해결.
구현:
- sec_8k_parser.py: 8-K primary document HTML 파싱
- extract_items(): regex 기반 Item 헤더 추출, 목차 중복 제거 (last-wins)
- _strip_ixbrl_viewer(): documents_json의 /ix?doc=... URL → 직접 URL 변환
- _find_primary_doc_url(): primary_document_url 우선 사용 (iXBRL viewer 회피)
- Item 8.01 단독 filing: exhibit(9.01) 없이 본문에서 직접 content 추출
- Item 9.01 skip, 나머지는 ITEM_EVENT_MAP으로 event_type 분류
- Exhibit enrichment: 2.02/7.01/8.01 + EX-99.1 있을 때 exhibit content 우선
- sec_filing_events 테이블 신설 (UniqueConstraint: accession_number + item_number)
- sec_filings 테이블에 parsed_status / items_json 컬럼 추가
- index_filings() 완료 후 신규 8-K auto-parse 트리거
- GET /filings/events/{ticker}: lazy parse + 조회
- POST /filings/events/parse/bulk: backfill용 일괄 파싱
- FilingSummary에 parsed_status / items 필드 포함
- alembic migration: g8a9b0c1d2e3
- 테스트 22개 추가 (extract_items, strip_ixbrl, find_primary_doc, parse_filing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
parent
3eb736445b
commit
25603c390c
@ -0,0 +1,81 @@
|
|||||||
|
"""add 8-K parsing: parsed_status/items_json + sec_filing_events
|
||||||
|
|
||||||
|
Revision ID: g8a9b0c1d2e3
|
||||||
|
Revises: f7a8b9c0d1e2
|
||||||
|
Create Date: 2026-04-08
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "g8a9b0c1d2e3"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "f7a8b9c0d1e2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Add parsed_status + items_json to sec_filings
|
||||||
|
col_names = [row[0] for row in conn.execute(
|
||||||
|
sa.text("SELECT column_name FROM information_schema.columns WHERE table_name='sec_filings'")
|
||||||
|
)]
|
||||||
|
if "parsed_status" not in col_names:
|
||||||
|
op.add_column(
|
||||||
|
"sec_filings",
|
||||||
|
sa.Column("parsed_status", sa.String(20), server_default="pending", nullable=True),
|
||||||
|
)
|
||||||
|
if "items_json" not in col_names:
|
||||||
|
op.add_column(
|
||||||
|
"sec_filings",
|
||||||
|
sa.Column("items_json", postgresql.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark all existing 8-K filings as 'pending' so the parser can backfill them
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE sec_filings SET parsed_status = 'pending' "
|
||||||
|
"WHERE form_type IN ('8-K', '8-K/A') AND parsed_status IS NULL"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create sec_filing_events table
|
||||||
|
if not conn.dialect.has_table(conn, "sec_filing_events"):
|
||||||
|
op.create_table(
|
||||||
|
"sec_filing_events",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("ticker", sa.String(10), nullable=False),
|
||||||
|
sa.Column("accession_number", sa.String(30), nullable=False),
|
||||||
|
sa.Column("form_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("filing_date", postgresql.TIMESTAMP(timezone=True), nullable=False),
|
||||||
|
sa.Column("item_number", sa.String(10), nullable=False),
|
||||||
|
sa.Column("event_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("title", sa.String(512), nullable=True),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("content_source", sa.String(20), nullable=True),
|
||||||
|
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint("accession_number", "item_number", name="uq_filing_event"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_filing_events_ticker", "sec_filing_events", ["ticker"])
|
||||||
|
op.create_index("ix_filing_events_accession", "sec_filing_events", ["accession_number"])
|
||||||
|
op.create_index(
|
||||||
|
"idx_filing_events_ticker_date",
|
||||||
|
"sec_filing_events",
|
||||||
|
["ticker", "filing_date"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_filing_events_ticker_type",
|
||||||
|
"sec_filing_events",
|
||||||
|
["ticker", "event_type"],
|
||||||
|
)
|
||||||
|
op.create_index("idx_filing_events_date", "sec_filing_events", ["filing_date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("sec_filing_events")
|
||||||
|
op.drop_column("sec_filings", "items_json")
|
||||||
|
op.drop_column("sec_filings", "parsed_status")
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Database model for parsed SEC 8-K filing events.
|
||||||
|
Each row represents one Item extracted from an 8-K primary document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Text, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP, JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SECFilingEvent(Base):
|
||||||
|
__tablename__ = "sec_filing_events"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
ticker = Column(String(10), nullable=False, index=True)
|
||||||
|
accession_number = Column(String(30), nullable=False, index=True)
|
||||||
|
form_type = Column(String(20), nullable=False)
|
||||||
|
filing_date = Column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
item_number = Column(String(10), nullable=False) # e.g. "8.01"
|
||||||
|
event_type = Column(String(50), nullable=False) # e.g. "other_material_event"
|
||||||
|
title = Column(String(512)) # Item title from document
|
||||||
|
summary = Column(Text) # Extracted text content (truncated)
|
||||||
|
content_source = Column(String(20)) # "primary_doc" or "exhibit"
|
||||||
|
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_filing_events_ticker_date", "ticker", "filing_date"),
|
||||||
|
Index("idx_filing_events_ticker_type", "ticker", "event_type"),
|
||||||
|
Index("idx_filing_events_date", "filing_date"),
|
||||||
|
)
|
||||||
@ -0,0 +1,431 @@
|
|||||||
|
"""
|
||||||
|
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()
|
||||||
@ -0,0 +1,395 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for SEC 8-K parser service.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- extract_items(): HTML parsing, item extraction, deduplication
|
||||||
|
- parse_filing(): end-to-end with mocked HTTP
|
||||||
|
- _find_primary_doc_url(): iXBRL URL stripping
|
||||||
|
- _strip_ixbrl_viewer(): URL conversion
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from app.services.sec_8k_parser import (
|
||||||
|
SEC8KParser,
|
||||||
|
extract_items,
|
||||||
|
_find_primary_doc_url,
|
||||||
|
_strip_ixbrl_viewer,
|
||||||
|
)
|
||||||
|
from app.models.filing import SECFiling
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_filing(**kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
ticker="AVGO",
|
||||||
|
cik="1730168",
|
||||||
|
accession_number="0001193125-26-144028",
|
||||||
|
form_type="8-K",
|
||||||
|
filing_date=datetime(2026, 4, 6, tzinfo=timezone.utc),
|
||||||
|
primary_document="d87999d8k.htm",
|
||||||
|
primary_document_url="https://www.sec.gov/Archives/edgar/data/1730168/000119312526144028/d87999d8k.htm",
|
||||||
|
filing_description=None,
|
||||||
|
documents_json=None,
|
||||||
|
parsed_status="pending",
|
||||||
|
items_json=None,
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
f = MagicMock(spec=SECFiling)
|
||||||
|
for k, v in defaults.items():
|
||||||
|
setattr(f, k, v)
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestExtractItems: HTML parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExtractItems:
|
||||||
|
|
||||||
|
def test_single_item_8_01(self):
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Broadcom and Google signed a TPU supply agreement.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["number"] == "8.01"
|
||||||
|
assert "Broadcom" in items[0]["content"]
|
||||||
|
assert "SIGNATURES" not in items[0]["content"]
|
||||||
|
|
||||||
|
def test_multiple_items(self):
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<p>Item 2.02 Results of Operations.</p>
|
||||||
|
<p>Q1 revenue was $14.9 billion.</p>
|
||||||
|
<p>Item 9.01 Financial Statements and Exhibits.</p>
|
||||||
|
<p>(d) Exhibits.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
assert len(items) == 2
|
||||||
|
assert items[0]["number"] == "2.02"
|
||||||
|
assert "revenue" in items[0]["content"]
|
||||||
|
assert items[1]["number"] == "9.01"
|
||||||
|
|
||||||
|
def test_deduplication_removes_toc_entries(self):
|
||||||
|
"""Table of contents entries appear before body — last occurrence wins."""
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<p>TABLE OF CONTENTS</p>
|
||||||
|
<p>Item 8.01 Other Events...1</p>
|
||||||
|
<p>Item 9.01 Financial Statements...2</p>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>This is the actual body content.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
# Only body occurrences should survive
|
||||||
|
numbers = [i["number"] for i in items]
|
||||||
|
assert numbers.count("8.01") == 1
|
||||||
|
# The body occurrence should have actual content
|
||||||
|
item_8 = next(i for i in items if i["number"] == "8.01")
|
||||||
|
assert "body content" in item_8["content"]
|
||||||
|
|
||||||
|
def test_content_stops_at_signatures(self):
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Material contract signed.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
<p>John Doe, CEO</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
assert "SIGNATURES" not in items[0]["content"]
|
||||||
|
assert "John Doe" not in items[0]["content"]
|
||||||
|
|
||||||
|
def test_empty_html_returns_empty(self):
|
||||||
|
items = extract_items("<html><body><p>No items here.</p></body></html>")
|
||||||
|
assert items == []
|
||||||
|
|
||||||
|
def test_item_title_extracted(self):
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Some content.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
assert items[0]["title"] == "Other Events"
|
||||||
|
|
||||||
|
def test_script_tags_removed(self):
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<script>var x = 'Item 5.02 Fake';</script>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Real content here.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
# Script tag contents should not produce items
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["number"] == "8.01"
|
||||||
|
|
||||||
|
def test_three_items_boundaries(self):
|
||||||
|
html = """
|
||||||
|
<html><body>
|
||||||
|
<p>Item 2.02 Results of Operations.</p>
|
||||||
|
<p>Revenue $10B.</p>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Partnership signed.</p>
|
||||||
|
<p>Item 9.01 Financial Statements.</p>
|
||||||
|
<p>Exhibit list.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
items = extract_items(html)
|
||||||
|
assert len(items) == 3
|
||||||
|
assert "Revenue" in items[0]["content"]
|
||||||
|
assert "Partnership" in items[1]["content"]
|
||||||
|
assert "Exhibit" in items[2]["content"]
|
||||||
|
# No cross-contamination
|
||||||
|
assert "Partnership" not in items[0]["content"]
|
||||||
|
assert "Revenue" not in items[1]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestStripIxbrlViewer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStripIxbrlViewer:
|
||||||
|
|
||||||
|
def test_strips_ix_doc_prefix(self):
|
||||||
|
url = "https://www.sec.gov/ix?doc=/Archives/edgar/data/1730168/000119312526144028/d87999d8k.htm"
|
||||||
|
result = _strip_ixbrl_viewer(url)
|
||||||
|
assert result == "https://www.sec.gov/Archives/edgar/data/1730168/000119312526144028/d87999d8k.htm"
|
||||||
|
|
||||||
|
def test_passthrough_for_direct_url(self):
|
||||||
|
url = "https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/a8k.htm"
|
||||||
|
assert _strip_ixbrl_viewer(url) == url
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert _strip_ixbrl_viewer("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestFindPrimaryDocUrl
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFindPrimaryDocUrl:
|
||||||
|
|
||||||
|
def test_prefers_primary_document_url(self):
|
||||||
|
filing = _make_filing(
|
||||||
|
primary_document_url="https://www.sec.gov/Archives/edgar/data/123/000123/doc.htm"
|
||||||
|
)
|
||||||
|
docs = [
|
||||||
|
{"type": "8-K", "url": "https://www.sec.gov/ix?doc=/Archives/edgar/data/123/000123/doc.htm"}
|
||||||
|
]
|
||||||
|
result = _find_primary_doc_url(docs, filing)
|
||||||
|
# Should use the stored primary_document_url, not the ix?doc= URL
|
||||||
|
assert result == "https://www.sec.gov/Archives/edgar/data/123/000123/doc.htm"
|
||||||
|
|
||||||
|
def test_falls_back_to_docs_when_no_primary_url(self):
|
||||||
|
filing = _make_filing(primary_document_url=None)
|
||||||
|
docs = [
|
||||||
|
{"type": "8-K", "url": "https://www.sec.gov/Archives/edgar/data/123/000123/doc.htm"}
|
||||||
|
]
|
||||||
|
result = _find_primary_doc_url(docs, filing)
|
||||||
|
assert result == "https://www.sec.gov/Archives/edgar/data/123/000123/doc.htm"
|
||||||
|
|
||||||
|
def test_strips_ixbrl_in_fallback(self):
|
||||||
|
filing = _make_filing(primary_document_url=None)
|
||||||
|
docs = [
|
||||||
|
{"type": "8-K", "url": "https://www.sec.gov/ix?doc=/Archives/edgar/data/123/000123/doc.htm"}
|
||||||
|
]
|
||||||
|
result = _find_primary_doc_url(docs, filing)
|
||||||
|
assert "/ix?doc=" not in result
|
||||||
|
|
||||||
|
def test_returns_none_for_empty_docs_and_no_url(self):
|
||||||
|
filing = _make_filing(primary_document_url=None)
|
||||||
|
result = _find_primary_doc_url([], filing)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_form_type_8k_a_matching(self):
|
||||||
|
filing = _make_filing(form_type="8-K/A", primary_document_url=None)
|
||||||
|
docs = [
|
||||||
|
{"type": "8-K/A", "url": "https://www.sec.gov/Archives/edgar/data/123/000123/doc.htm"}
|
||||||
|
]
|
||||||
|
result = _find_primary_doc_url(docs, filing)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestSEC8KParserItemEventMap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestItemEventMap:
|
||||||
|
|
||||||
|
def test_key_items_present(self):
|
||||||
|
parser = SEC8KParser()
|
||||||
|
assert parser.ITEM_EVENT_MAP["8.01"] == "other_material_event"
|
||||||
|
assert parser.ITEM_EVENT_MAP["2.02"] == "earnings_result"
|
||||||
|
assert parser.ITEM_EVENT_MAP["5.02"] == "management_change"
|
||||||
|
assert parser.ITEM_EVENT_MAP["1.01"] == "material_contract"
|
||||||
|
assert parser.ITEM_EVENT_MAP["9.01"] is None # skip
|
||||||
|
|
||||||
|
def test_exhibit_enrichable_set(self):
|
||||||
|
parser = SEC8KParser()
|
||||||
|
assert "8.01" in parser.EXHIBIT_ENRICHABLE
|
||||||
|
assert "2.02" in parser.EXHIBIT_ENRICHABLE
|
||||||
|
assert "9.01" not in parser.EXHIBIT_ENRICHABLE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestParseFiling: end-to-end with mocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AVGO_8K_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<p>FORM 8-K CURRENT REPORT</p>
|
||||||
|
<p>Date of Report: April 6, 2026</p>
|
||||||
|
<p>Broadcom Inc.</p>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Broadcom Inc. and Google LLC have entered into a Long Term Agreement
|
||||||
|
for Broadcom to develop and supply custom Tensor Processing Units (TPUs)
|
||||||
|
for Google's future generations of TPUs.</p>
|
||||||
|
<p>Cautionary Note Regarding Forward-Looking Statements</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
<p>John Doe, CEO</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
MULTI_ITEM_HTML = """
|
||||||
|
<html><body>
|
||||||
|
<p>Item 2.02 Results of Operations.</p>
|
||||||
|
<p>Q1 2026 revenue was $14.9 billion.</p>
|
||||||
|
<p>Item 8.01 Other Events.</p>
|
||||||
|
<p>Google partnership announced.</p>
|
||||||
|
<p>Item 9.01 Financial Statements and Exhibits.</p>
|
||||||
|
<p>(d) Exhibits. See exhibit index.</p>
|
||||||
|
<p>SIGNATURES</p>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseFiling:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_item_8_01_standalone(self):
|
||||||
|
"""Item 8.01 standalone — no exhibit needed, content from primary doc."""
|
||||||
|
parser = SEC8KParser()
|
||||||
|
filing = _make_filing()
|
||||||
|
|
||||||
|
docs = [
|
||||||
|
{"type": "8-K", "url": "https://www.sec.gov/Archives/edgar/data/1730168/000119312526144028/d87999d8k.htm"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# lazy import patched at the source module
|
||||||
|
with patch("app.services.sec_filings_service.sec_filings_service") as mock_svc:
|
||||||
|
mock_svc.get_filing_documents = AsyncMock(return_value=docs)
|
||||||
|
with patch.object(parser._http, "fetch_text", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = AVGO_8K_HTML
|
||||||
|
with patch("app.services.sec_8k_parser.pg_insert") as mock_pg_insert:
|
||||||
|
mock_stmt = MagicMock()
|
||||||
|
mock_stmt.on_conflict_do_update.return_value = mock_stmt
|
||||||
|
mock_pg_insert.return_value = mock_stmt
|
||||||
|
db = AsyncMock()
|
||||||
|
db.execute = AsyncMock(side_effect=[
|
||||||
|
_make_db_result(filing), # re-fetch filing
|
||||||
|
MagicMock(), # pg_insert execute
|
||||||
|
])
|
||||||
|
db.commit = AsyncMock()
|
||||||
|
|
||||||
|
n = await parser._parse_one(db, filing)
|
||||||
|
|
||||||
|
assert n == 1
|
||||||
|
assert filing.parsed_status == "succeeded"
|
||||||
|
assert filing.items_json == ["8.01"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_multiple_items_skips_9_01(self):
|
||||||
|
"""Multiple items: 2.02+8.01+9.01, 9.01 should be skipped (no event created)."""
|
||||||
|
parser = SEC8KParser()
|
||||||
|
filing = _make_filing(accession_number="0001730168-26-000011")
|
||||||
|
docs = [
|
||||||
|
{"type": "8-K", "url": "https://www.sec.gov/Archives/edgar/data/1730168/000173016826000011/avgo.htm"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("app.services.sec_filings_service.sec_filings_service") as mock_svc:
|
||||||
|
mock_svc.get_filing_documents = AsyncMock(return_value=docs)
|
||||||
|
with patch.object(parser._http, "fetch_text", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = MULTI_ITEM_HTML
|
||||||
|
with patch("app.services.sec_8k_parser.pg_insert") as mock_pg_insert:
|
||||||
|
mock_stmt = MagicMock()
|
||||||
|
mock_stmt.on_conflict_do_update.return_value = mock_stmt
|
||||||
|
mock_pg_insert.return_value = mock_stmt
|
||||||
|
db = AsyncMock()
|
||||||
|
db.execute = AsyncMock(side_effect=[
|
||||||
|
_make_db_result(filing),
|
||||||
|
MagicMock(),
|
||||||
|
])
|
||||||
|
db.commit = AsyncMock()
|
||||||
|
|
||||||
|
n = await parser._parse_one(db, filing)
|
||||||
|
|
||||||
|
# 2 events (2.02 and 8.01), 9.01 skipped
|
||||||
|
assert n == 2
|
||||||
|
assert filing.parsed_status == "succeeded"
|
||||||
|
assert "9.01" in filing.items_json # items_json includes 9.01 (for reference)
|
||||||
|
assert "2.02" in filing.items_json
|
||||||
|
assert "8.01" in filing.items_json
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_filing_sets_failed_on_error(self):
|
||||||
|
"""HTTP fetch failure → parsed_status = 'failed'."""
|
||||||
|
parser = SEC8KParser()
|
||||||
|
filing = _make_filing()
|
||||||
|
docs = [{"type": "8-K", "url": "https://www.sec.gov/Archives/doc.htm"}]
|
||||||
|
|
||||||
|
with patch("app.services.sec_filings_service.sec_filings_service") as mock_svc:
|
||||||
|
mock_svc.get_filing_documents = AsyncMock(return_value=docs)
|
||||||
|
with patch.object(parser._http, "fetch_text", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.side_effect = RuntimeError("network error")
|
||||||
|
db = AsyncMock()
|
||||||
|
db.execute = AsyncMock(return_value=_make_db_result(filing))
|
||||||
|
db.commit = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="network error"):
|
||||||
|
await parser._parse_one(db, filing)
|
||||||
|
|
||||||
|
assert filing.parsed_status == "failed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_filing_not_found_raises(self):
|
||||||
|
"""Filing not in DB → ValueError."""
|
||||||
|
parser = SEC8KParser()
|
||||||
|
filing = _make_filing()
|
||||||
|
db = AsyncMock()
|
||||||
|
db.execute = AsyncMock(return_value=_make_db_result(None))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="not found in DB"):
|
||||||
|
await parser._parse_one(db, filing)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_result(value):
|
||||||
|
# SQLAlchemy result methods (scalar_one_or_none, scalars, etc.) are synchronous,
|
||||||
|
# so use MagicMock (not AsyncMock) to avoid returning unawaited coroutines.
|
||||||
|
result = MagicMock()
|
||||||
|
result.scalar_one_or_none.return_value = value
|
||||||
|
return result
|
||||||
Loading…
Reference in New Issue