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
I Luk Kim 4 months ago
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")

@ -18,14 +18,21 @@ from app.schemas.filing import (
BulkFilingSearchItem, BulkFilingSearchItem,
BulkFilingSearchRequest, BulkFilingSearchRequest,
BulkFilingSearchResponse, BulkFilingSearchResponse,
BulkParseRequest,
BulkParseResponse,
ExhibitContentResponse, ExhibitContentResponse,
FilingDocumentInfo, FilingDocumentInfo,
FilingDocumentListResponse, FilingDocumentListResponse,
FilingEventResponse,
FilingEventsSearchResponse,
FilingSearchResponse, FilingSearchResponse,
FilingSummary, FilingSummary,
) )
from app.models.filing_event import SECFilingEvent
from app.services.sec_filings_service import sec_filings_service from app.services.sec_filings_service import sec_filings_service
from app.services.sec_8k_parser import sec_8k_parser
from app.utils.cache import with_cache, build_cache_key, get_negative_cached, set_negative_cached from app.utils.cache import with_cache, build_cache_key, get_negative_cached, set_negative_cached
from sqlalchemy import select, and_, func as sql_func
router = APIRouter() router = APIRouter()
logger = logging.getLogger("app.api.v1.filings") logger = logging.getLogger("app.api.v1.filings")
@ -126,6 +133,8 @@ async def search_filings(
primary_document=f.primary_document, primary_document=f.primary_document,
filing_description=f.filing_description, filing_description=f.filing_description,
documents_count=docs_count, documents_count=docs_count,
parsed_status=f.parsed_status,
items=f.items_json if isinstance(f.items_json, list) else None,
) )
) )
@ -431,3 +440,139 @@ async def get_exhibit_bulk(
failed_count=len(results) - successful, failed_count=len(results) - successful,
query_time_seconds=round(elapsed, 3), query_time_seconds=round(elapsed, 3),
) )
# ── Filing Events ────────────────────────────────────────────────────────────
@router.get(
"/events/{ticker}",
response_model=FilingEventsSearchResponse,
summary="Get parsed 8-K events for a ticker",
description=(
"Returns structured events parsed from 8-K filings. Each event corresponds to one "
"8-K Item (e.g., Item 8.01 → other_material_event, Item 2.02 → earnings_result).\\n\\n"
"If there are unprocessed (pending) filings, they are lazily parsed on first request.\\n\\n"
"**Example**: `GET /filings/events/AVGO?start_date=2026-04-01&event_type=other_material_event`"
),
)
@with_cache(namespace="filings:events", ttl=1800, key_params=["ticker", "start_date", "end_date", "event_type", "limit", "offset"])
async def get_filing_events(
ticker: str,
response: Response,
start_date: Optional[str] = Query(None, description="Start date YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="End date YYYY-MM-DD"),
event_type: Optional[str] = Query(None, description="Filter by event type (e.g. other_material_event)"),
limit: int = Query(20, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
"""Return parsed 8-K events for a ticker. Lazy-parses any pending filings first."""
ticker_upper = ticker.upper()
start_dt = end_dt = None
try:
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
# Lazy-parse any pending 8-Ks for this ticker (limit 20 to avoid long waits)
try:
await asyncio.wait_for(
sec_8k_parser.parse_bulk(db, tickers=[ticker_upper], limit=20),
timeout=60,
)
except (asyncio.TimeoutError, Exception) as e:
logger.warning(f"Lazy parse timed out/failed for {ticker_upper}: {e}")
# Query events
conditions = [SECFilingEvent.ticker == ticker_upper]
if start_dt:
conditions.append(SECFilingEvent.filing_date >= start_dt)
if end_dt:
conditions.append(SECFilingEvent.filing_date <= end_dt)
if event_type:
conditions.append(SECFilingEvent.event_type == event_type)
where_clause = and_(*conditions)
count_result = await db.execute(
select(sql_func.count()).select_from(SECFilingEvent).where(where_clause)
)
total_count = count_result.scalar() or 0
rows_result = await db.execute(
select(SECFilingEvent)
.where(where_clause)
.order_by(SECFilingEvent.filing_date.desc())
.limit(limit)
.offset(offset)
)
rows = rows_result.scalars().all()
events = [
FilingEventResponse(
id=str(row.id),
ticker=row.ticker,
accession_number=row.accession_number,
form_type=row.form_type,
filing_date=row.filing_date.strftime("%Y-%m-%d"),
item_number=row.item_number,
event_type=row.event_type,
title=row.title,
summary=row.summary,
content_source=row.content_source,
)
for row in rows
]
return FilingEventsSearchResponse(
ticker=ticker_upper,
events=events,
total_count=total_count,
metadata={
"limit": limit,
"offset": offset,
"event_type": event_type,
},
)
@router.post(
"/events/parse/bulk",
response_model=BulkParseResponse,
summary="Bulk parse pending 8-K filings",
description=(
"Parse pending 8-K filings and create structured events. "
"Use this to backfill events for existing DB records.\\n\\n"
"**Example body**: `{\"tickers\": [\"AVGO\", \"AAPL\"], \"limit\": 50}`\\n"
"Omit `tickers` to parse all pending filings (up to `limit`)."
),
)
async def parse_8k_bulk(
request: BulkParseRequest,
db: AsyncSession = Depends(get_db),
):
"""Bulk parse pending 8-K filings and persist events."""
try:
result = await asyncio.wait_for(
sec_8k_parser.parse_bulk(db, tickers=request.tickers, limit=request.limit),
timeout=600,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Bulk parse timed out after 600s")
except Exception as e:
logger.error(f"Bulk parse failed: {e}")
raise HTTPException(status_code=502, detail=f"Bulk parse failed: {e}")
return BulkParseResponse(
succeeded=result["succeeded"],
failed=result["failed"],
skipped=result["skipped"],
total=result["total"],
query_time_seconds=result["elapsed"],
)

@ -1,6 +1,7 @@
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData, DataUpdateLog from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData, DataUpdateLog
from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding
from app.models.filing import SECFiling from app.models.filing import SECFiling
from app.models.filing_event import SECFilingEvent
from app.models.finra_short_volume import FinraShortVolume from app.models.finra_short_volume import FinraShortVolume
from app.models.alpaca_price import AlpacaPriceData from app.models.alpaca_price import AlpacaPriceData
from app.models.overlay_registry import CompanyAlias, YouTubeChannelRegistry, WikiPageMap, ThemeTopicMap from app.models.overlay_registry import CompanyAlias, YouTubeChannelRegistry, WikiPageMap, ThemeTopicMap
@ -9,6 +10,7 @@ from app.models.overlay_feature import OverlayFeatureRecord, OverlayJobLog
from app.models.insider_transaction import InsiderTransaction from app.models.insider_transaction import InsiderTransaction
from app.models.earnings_surprise import EarningsSurprise from app.models.earnings_surprise import EarningsSurprise
from app.models.universe_snapshot import UniverseTickerRegistry, UniverseSnapshot from app.models.universe_snapshot import UniverseTickerRegistry, UniverseSnapshot
from app.models.dividend_calendar import DividendCalendar
__all__ = [ __all__ = [
"Company", "Company",
@ -22,6 +24,7 @@ __all__ = [
"ETFHoldingsSnapshot", "ETFHoldingsSnapshot",
"ETFHolding", "ETFHolding",
"SECFiling", "SECFiling",
"SECFilingEvent",
"FinraShortVolume", "FinraShortVolume",
"AlpacaPriceData", "AlpacaPriceData",
# Overlay # Overlay
@ -41,4 +44,6 @@ __all__ = [
# Universe # Universe
"UniverseTickerRegistry", "UniverseTickerRegistry",
"UniverseSnapshot", "UniverseSnapshot",
# Dividends
"DividendCalendar",
] ]

@ -2,7 +2,7 @@
Database model for SEC filings (8-K, 6-K, 20-F, 40-F, 10-K, 10-Q, etc.) Database model for SEC filings (8-K, 6-K, 20-F, 40-F, 10-K, 10-Q, etc.)
""" """
from sqlalchemy import Column, String, JSON, Index from sqlalchemy import Column, String, JSON, Index, Text
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone from datetime import datetime, timezone
import uuid import uuid
@ -23,6 +23,8 @@ class SECFiling(Base):
primary_document_url = Column(String(512)) primary_document_url = Column(String(512))
filing_description = Column(String(512)) filing_description = Column(String(512))
documents_json = Column(JSON) # Cached document list documents_json = Column(JSON) # Cached document list
parsed_status = Column(String(20), default="pending") # pending|succeeded|failed|skipped
items_json = Column(JSON) # Extracted item numbers, e.g. ["8.01", "9.01"]
indexed_at = Column(TIMESTAMP(timezone=True)) indexed_at = Column(TIMESTAMP(timezone=True))
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)) 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)) updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))

@ -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"),
)

@ -35,6 +35,8 @@ class FilingSummary(BaseModel):
"primary_document": "a8-k20240201.htm", "primary_document": "a8-k20240201.htm",
"filing_description": "Results of Operations and Financial Condition", "filing_description": "Results of Operations and Financial Condition",
"documents_count": 4, "documents_count": 4,
"parsed_status": "succeeded",
"items": ["2.02", "9.01"],
} }
}) })
@ -45,6 +47,8 @@ class FilingSummary(BaseModel):
primary_document: Optional[str] = None primary_document: Optional[str] = None
filing_description: Optional[str] = None filing_description: Optional[str] = None
documents_count: Optional[int] = None documents_count: Optional[int] = None
parsed_status: Optional[str] = None
items: Optional[List[str]] = None
class FilingSearchResponse(BaseModel): class FilingSearchResponse(BaseModel):
@ -192,3 +196,57 @@ class BulkExhibitResponse(BaseModel):
successful_count: int successful_count: int
failed_count: int failed_count: int
query_time_seconds: float query_time_seconds: float
# ── Filing Events ────────────────────────────────────────────────────────────
class FilingEventResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"ticker": "AVGO",
"accession_number": "0001193125-26-144028",
"form_type": "8-K",
"filing_date": "2026-04-06",
"item_number": "8.01",
"event_type": "other_material_event",
"title": "Other Events",
"summary": "Broadcom Inc. and Google LLC have entered into a Long Term Agreement...",
"content_source": "primary_doc",
}
})
id: str
ticker: str
accession_number: str
form_type: str
filing_date: str
item_number: str
event_type: str
title: Optional[str] = None
summary: Optional[str] = None
content_source: Optional[str] = None
class FilingEventsSearchResponse(BaseModel):
ticker: str
events: List[FilingEventResponse]
total_count: int
metadata: Dict[str, Any] = Field(default_factory=dict)
class BulkParseRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {"tickers": ["AVGO", "AAPL"], "limit": 50}
})
tickers: Optional[List[str]] = None # None = all pending
limit: int = Field(default=100, ge=1, le=1000)
class BulkParseResponse(BaseModel):
succeeded: int
failed: int
skipped: int
total: int
query_time_seconds: float

@ -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()

@ -9,6 +9,8 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from app.core.config import settings
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from sqlalchemy import select, and_, func from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -32,6 +34,7 @@ class SECFilingsService:
def __init__(self): def __init__(self):
self._http = SECHttpClient("Stock Oracle Filings Service") self._http = SECHttpClient("Stock Oracle Filings Service")
self._doc_fetch_locks: Dict[str, asyncio.Lock] = {} self._doc_fetch_locks: Dict[str, asyncio.Lock] = {}
self._reindex_locks: Dict[str, asyncio.Lock] = {}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# index_filings: fetch from SEC submissions and upsert to DB # index_filings: fetch from SEC submissions and upsert to DB
@ -43,12 +46,20 @@ class SECFilingsService:
ticker: str, ticker: str,
form_types: Optional[Set[str]] = None, form_types: Optional[Set[str]] = None,
force_refresh: bool = False, force_refresh: bool = False,
skip_cache: bool = False,
) -> int: ) -> int:
"""Index filings from SEC submissions endpoint into sec_filings table. """Index filings from SEC submissions endpoint into sec_filings table.
Returns the number of filings indexed (inserted or updated). Returns the number of filings indexed (inserted or updated).
Args:
force_refresh: Update existing DB records in addition to inserting new ones.
Also implies skip_cache=True.
skip_cache: Bypass HTTP cache reads to fetch fresh data from SEC EDGAR.
""" """
ticker = ticker.upper() ticker = ticker.upper()
# force_refresh always implies fresh data from SEC
effective_skip_cache = skip_cache or force_refresh
self._http.set_deadline(120.0) self._http.set_deadline(120.0)
try: try:
cik = await self._http.get_company_cik(ticker) cik = await self._http.get_company_cik(ticker)
@ -57,7 +68,7 @@ class SECFilingsService:
cik_int = int(cik) cik_int = int(cik)
url = f"{self._http.sec_base_data}/submissions/CIK{cik_int:010d}.json" url = f"{self._http.sec_base_data}/submissions/CIK{cik_int:010d}.json"
data = await self._http.fetch_json(url) data = await self._http.fetch_json(url, skip_cache=effective_skip_cache)
# Determine which form types to index # Determine which form types to index
target_forms = form_types or self.SUPPORTED_FORM_TYPES target_forms = form_types or self.SUPPORTED_FORM_TYPES
@ -120,7 +131,7 @@ class SECFilingsService:
continue continue
older_url = f"{self._http.sec_base_data}/submissions/{name}" older_url = f"{self._http.sec_base_data}/submissions/{name}"
try: try:
older = await self._http.fetch_json(older_url) older = await self._http.fetch_json(older_url, skip_cache=effective_skip_cache)
add_from_block(older) add_from_block(older)
except Exception: except Exception:
continue continue
@ -147,6 +158,9 @@ class SECFilingsService:
existing_accs = {row[0] for row in acc_result.fetchall()} existing_accs = {row[0] for row in acc_result.fetchall()}
existing_map = {} existing_map = {}
# Snapshot pre-existing accessions to identify truly new inserts later
_pre_existing_accs: Set[str] = set(existing_accs)
# Upsert in chunks to avoid all-or-nothing transaction failures # Upsert in chunks to avoid all-or-nothing transaction failures
for chunk_start in range(0, len(raw_filings), self.CHUNK_SIZE): for chunk_start in range(0, len(raw_filings), self.CHUNK_SIZE):
chunk = raw_filings[chunk_start: chunk_start + self.CHUNK_SIZE] chunk = raw_filings[chunk_start: chunk_start + self.CHUNK_SIZE]
@ -180,6 +194,7 @@ class SECFilingsService:
primary_document=rf["primary_document"], primary_document=rf["primary_document"],
primary_document_url=rf["primary_document_url"], primary_document_url=rf["primary_document_url"],
filing_description=rf["filing_description"], filing_description=rf["filing_description"],
parsed_status="pending" if rf["form_type"] in ("8-K", "8-K/A") else None,
indexed_at=now, indexed_at=now,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@ -199,6 +214,21 @@ class SECFilingsService:
await db.rollback() await db.rollback()
logger.info(f"Indexed {indexed_count} filings for {ticker}") logger.info(f"Indexed {indexed_count} filings for {ticker}")
# Auto-parse newly inserted 8-K filings (non-blocking; failures are logged)
truly_new_8k = [
rf["accession_number"]
for rf in raw_filings
if rf["form_type"] in ("8-K", "8-K/A")
and rf["accession_number"] not in _pre_existing_accs
]
if truly_new_8k:
from app.services.sec_8k_parser import sec_8k_parser
try:
await sec_8k_parser.parse_filings_by_accessions(db, truly_new_8k)
except Exception as e:
logger.warning(f"Auto-parse failed for {ticker} 8-Ks: {e}")
return indexed_count return indexed_count
finally: finally:
self._http.clear_deadline() self._http.clear_deadline()
@ -252,6 +282,56 @@ class SECFilingsService:
if total_count == 0: if total_count == 0:
return [], 0 return [], 0
else:
# Ticker already has filings — check for staleness and incrementally re-index
# if the data is older than SEC_DATA_REFRESH_HOURS.
staleness_result = await db.execute(
select(func.max(SECFiling.indexed_at)).where(SECFiling.ticker == ticker)
)
last_indexed = staleness_result.scalar()
now_utc = datetime.now(timezone.utc)
refresh_seconds = settings.SEC_DATA_REFRESH_HOURS * 3600
is_stale = last_indexed is None or (
(now_utc - last_indexed).total_seconds() > refresh_seconds
)
if is_stale:
# Use per-ticker lock to prevent thundering herd.
# If another coroutine is already re-indexing this ticker, skip and
# serve stale data rather than stacking up duplicate SEC requests.
if ticker not in self._reindex_locks:
self._reindex_locks[ticker] = asyncio.Lock()
lock = self._reindex_locks[ticker]
if not lock.locked():
async with lock:
try:
new_count = await self.index_filings(
db, ticker, form_types=form_types, skip_cache=True
)
if new_count == 0:
# No new filings from SEC — touch indexed_at on one row so
# the next query doesn't immediately re-trigger staleness.
await db.execute(
SECFiling.__table__.update()
.where(
SECFiling.id.in_(
select(SECFiling.id)
.where(SECFiling.ticker == ticker)
.order_by(SECFiling.filing_date.desc())
.limit(1)
)
)
.values(indexed_at=now_utc)
)
await db.commit()
# Re-count to include any newly inserted filings
count_result = await db.execute(
select(func.count()).select_from(SECFiling).where(where_clause)
)
total_count = count_result.scalar() or 0
except Exception as e:
logger.warning(f"Staleness re-index failed for {ticker}: {e}")
# Serve existing stale data rather than returning empty
# Query with pagination # Query with pagination
result = await db.execute( result = await db.execute(
select(SECFiling) select(SECFiling)
@ -446,24 +526,44 @@ class SECFilingsService:
indexed_counts = {row.ticker: row.cnt for row in count_result} indexed_counts = {row.ticker: row.cnt for row in count_result}
missing = [t for t in tickers if indexed_counts.get(t, 0) == 0] missing = [t for t in tickers if indexed_counts.get(t, 0) == 0]
# Index missing tickers in parallel (Semaphore(4) to not overwhelm SEC) # Check staleness for tickers that already have filings
stale: List[str] = []
present_tickers = [t for t in tickers if indexed_counts.get(t, 0) > 0]
if present_tickers:
staleness_rows = await db.execute(
select(SECFiling.ticker, func.max(SECFiling.indexed_at))
.where(SECFiling.ticker.in_(present_tickers))
.group_by(SECFiling.ticker)
)
now_utc = datetime.now(timezone.utc)
refresh_seconds = settings.SEC_DATA_REFRESH_HOURS * 3600
for row in staleness_rows:
t_name, last_indexed = row[0], row[1]
if last_indexed is None or (now_utc - last_indexed).total_seconds() > refresh_seconds:
stale.append(t_name)
# Index missing tickers and re-index stale tickers in parallel (Semaphore(4))
# Each coroutine uses its own session to avoid concurrent-session conflicts # Each coroutine uses its own session to avoid concurrent-session conflicts
if missing: to_index = [(t, False) for t in missing] + [(t, True) for t in stale]
if to_index:
sem = asyncio.Semaphore(4) sem = asyncio.Semaphore(4)
async def _index_one(ticker: str) -> None: async def _index_one(ticker: str, is_stale: bool) -> None:
async with sem: async with sem:
try: try:
from app.core.database import AsyncSessionLocal from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
await asyncio.wait_for( await asyncio.wait_for(
self.index_filings(session, ticker, form_types=form_types), self.index_filings(
session, ticker, form_types=form_types,
skip_cache=is_stale,
),
timeout=120, timeout=120,
) )
except Exception as e: except Exception as e:
logger.warning(f"Bulk index failed for {ticker}: {e}") logger.warning(f"Bulk index failed for {ticker}: {e}")
await asyncio.gather(*[_index_one(t) for t in missing]) await asyncio.gather(*[_index_one(t, s) for t, s in to_index])
# Fetch results for all tickers from DB # Fetch results for all tickers from DB
results = [] results = []

@ -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…
Cancel
Save