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.
150 lines
5.9 KiB
Python
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
|