You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
stock-oracle/tests/test_sec_full_index_parser.py

150 lines
5.9 KiB
Python

"""
Unit tests for SEC full-index (company.idx and form.idx) parsers.
"""
import pytest
from datetime import date
from app.services.sec_full_index_service import (
parse_company_idx,
_parse_form_idx,
FORM4_TYPES,
ACTIVIST_13DG_TYPES,
)
# Realistic company.idx sample — fixed-width, matches real SEC EDGAR format.
# Column offsets: Company(0-61), Form(62-73), CIK(74-85), Date(86-97), File(98+)
# The dashes line is the sentinel that marks the start of data rows.
_H = "Company Name Form Type CIK Date Filed Filename"
_D = "-" * 120
def _row(company, form_type, cik, filing_date, acc):
"""Build a fixed-width company.idx data row matching real EDGAR format.
Real EDGAR layout: company(62) + form_type(12) + 5_spaces + cik(12, left-pad) + date(10) + 2_spaces + filename
CIK always has 5 mandatory leading spaces; date is at position 91, filename at 103.
"""
filename = f"edgar/data/{int(cik)}/{acc}-index.htm"
cik_str = f"{str(int(cik)):<12}" # 12-char left-aligned CIK (trailing spaces)
# 5 mandatory leading spaces + 12-char CIK + date(10) + 2 spaces = positions 74-102
line = f"{company:<62}{form_type:<12} {cik_str}{filing_date} {filename}"
return line
SAMPLE_IDX = "\n".join([
"Full-Index of EDGAR Filings Submitted to the Commission",
"",
_H,
_D,
_row("APPLE INC", "4", "320193", "2026-01-15", "0000320193-26-000001"),
_row("BERKSHIRE HATHAWAY INC", "SC 13G", "1067983", "2026-02-14", "0001067983-26-000042"),
_row("BLACKROCK INC", "SC 13D/A", "1364742", "2026-01-20", "0001364742-26-000010"),
_row("TESLA INC", "4/A", "1318605", "2026-01-18", "0001318605-26-000005"),
_row("MICROSOFT CORP", "10-K", "789019", "2026-02-01", "0000789019-26-000020"),
])
def test_parse_form4_entries():
entries = parse_company_idx(SAMPLE_IDX, form_types=FORM4_TYPES)
assert len(entries) == 2
form_types = {e.form_type for e in entries}
assert "4" in form_types
assert "4/A" in form_types
def test_parse_activist_entries():
entries = parse_company_idx(SAMPLE_IDX, form_types=ACTIVIST_13DG_TYPES)
assert len(entries) == 2
form_types = {e.form_type for e in entries}
assert "SC 13G" in form_types
assert "SC 13D/A" in form_types
def test_parse_all_entries_no_filter():
entries = parse_company_idx(SAMPLE_IDX)
# 4, SC 13G, SC 13D/A, 4/A, 10-K
assert len(entries) == 5
def test_parse_entry_fields():
entries = parse_company_idx(SAMPLE_IDX, form_types=FORM4_TYPES)
apple = next((e for e in entries if "APPLE" in e.company_name), None)
assert apple is not None, f"APPLE not found in {[e.company_name for e in entries]}"
assert apple.cik == "0000320193"
assert apple.filing_date == date(2026, 1, 15)
assert "0000320193-26-000001" in apple.accession_number
assert "edgar/data" in apple.filename
def test_parse_skips_short_lines():
idx = "short\n" + SAMPLE_IDX
entries = parse_company_idx(idx, form_types=FORM4_TYPES)
# Should not crash; just skip the malformed line
assert isinstance(entries, list)
def test_parse_empty_text():
entries = parse_company_idx("", form_types=FORM4_TYPES)
assert entries == []
def test_parse_header_only():
# Only the 4 header lines (no data rows after the dash separator)
lines = SAMPLE_IDX.splitlines()
dash_idx = next(i for i, l in enumerate(lines) if set(l.strip()) == {"-"} and len(l.strip()) > 20)
header_only = "\n".join(lines[:dash_idx + 1]) # up to and including dash line
entries = parse_company_idx(header_only, form_types=FORM4_TYPES)
assert entries == []
# ------------------------------------------------------------------
# form.idx parser (form345.zip column layout: Form Type is FIRST)
# ------------------------------------------------------------------
def _form_row(form_type, company, cik, filing_date, acc):
"""Build a fixed-width form.idx row (Form Type first, then Company Name).
Same EDGAR offsets from position 74 onward as company.idx.
"""
filename = f"edgar/data/{int(cik)}/{acc}-index.htm"
cik_str = f"{str(int(cik)):<12}" # 12-char left-aligned CIK
line = f"{form_type:<12}{company:<62} {cik_str}{filing_date} {filename}"
return line
_FORM_IDX = "\n".join([
"Form Type Company Name CIK Date Filed Filename",
"-" * 120,
_form_row("4", "TIM COOK", "320193", "2026-01-15", "0000320193-26-000001"),
_form_row("4/A", "ELON MUSK", "1318605", "2026-01-18", "0001318605-26-000005"),
_form_row("10-K", "MICROSOFT CORP", "789019", "2026-02-01", "0000789019-26-000020"),
])
def test_form_idx_parse_form4():
entries = _parse_form_idx(_FORM_IDX, form_types=FORM4_TYPES)
assert len(entries) == 2
form_types = {e.form_type for e in entries}
assert "4" in form_types
assert "4/A" in form_types
def test_form_idx_company_name_correct():
entries = _parse_form_idx(_FORM_IDX, form_types=FORM4_TYPES)
cook = next((e for e in entries if "TIM COOK" in e.company_name), None)
assert cook is not None
assert cook.cik == "0000320193"
assert cook.filing_date == date(2026, 1, 15)
assert "0000320193-26-000001" in cook.accession_number
def test_form_idx_does_not_mix_offsets_with_company_idx():
# Same data fed to company.idx parser would produce garbled output;
# form_idx parser must correctly extract form_type from cols 0-12.
company_idx_entries = parse_company_idx(_FORM_IDX, form_types=FORM4_TYPES)
form_idx_entries = _parse_form_idx(_FORM_IDX, form_types=FORM4_TYPES)
# company.idx parser treats "4 TIM COOK..." as company_name="4"
# and form_type from offset 62 — misses "4" form type → 0 entries
# form.idx parser correctly gets 2 Form 4 entries
assert len(form_idx_entries) == 2
assert len(company_idx_entries) == 0 # wrong parser on wrong format = empty