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.
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""Unit tests for rule-based parser."""
|
|
|
|
SAMPLE_TEXT_POSITIVE = """
|
|
Item 2.02 Results of Operations
|
|
|
|
Apple today announced record quarterly revenue of $124.3 billion, up 9 percent year over year.
|
|
|
|
Guidance raised for Q2: The Company expects revenue to be between $125 billion and $131 billion.
|
|
Demand remains strong across all product categories.
|
|
Gross margin expansion driven by Services mix.
|
|
Customer additions in enterprise continue to accelerate.
|
|
Backlog increased significantly year over year.
|
|
Adjusted EPS excludes certain non-GAAP items.
|
|
"""
|
|
|
|
SAMPLE_TEXT_NEGATIVE = """
|
|
Item 2.02 Results of Operations
|
|
|
|
Company reported revenue of $5.2 billion, missing expectations.
|
|
Guidance lowered for next quarter due to demand softness.
|
|
Margin compression continues. Financing need announced with dilutive convertible note offering.
|
|
"""
|
|
|
|
SAMPLE_TEXT_CONTRACT = """
|
|
Item 1.01 Entry into Material Agreement
|
|
|
|
The Company has entered into a definitive agreement with a major enterprise customer
|
|
for a multi-year supply contract worth $500 million. The contract includes recurring revenue.
|
|
"""
|
|
|
|
|
|
def test_parse_earnings_positive():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_POSITIVE, {"filing_date": "2026-01-29"})
|
|
assert out.event_type == "earnings_release"
|
|
assert out.guidance.status == "raised"
|
|
assert out.signals.demand_strength == "strong"
|
|
assert out.event_direction == "bullish"
|
|
|
|
|
|
def test_parse_earnings_negative():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_NEGATIVE, {"filing_date": "2026-02-01"})
|
|
assert out.event_type == "earnings_release"
|
|
assert out.guidance.status == "lowered"
|
|
assert out.event_direction in ("bearish", "mixed")
|
|
|
|
|
|
def test_parse_material_contract():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_CONTRACT, {"filing_date": "2026-02-15"})
|
|
assert out.event_type == "material_contract"
|
|
|
|
|
|
def test_parse_non_gaap_flag():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_POSITIVE, {"filing_date": "2026-01-29"})
|
|
assert out.risk_flags.non_gaap_heavy is True
|
|
|
|
|
|
def test_parse_margin_improving():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_POSITIVE, {"filing_date": "2026-01-29"})
|
|
assert out.signals.margin_quality == "improving"
|
|
|
|
|
|
def test_parse_confidence_range():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_POSITIVE, {"filing_date": "2026-01-29"})
|
|
assert 0.0 <= out.confidence.overall <= 1.0
|
|
|
|
|
|
def test_parse_document_id_preserved():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::my-id", "8-K", SAMPLE_TEXT_POSITIVE, {"filing_date": "2026-01-29"})
|
|
assert out.document_id == "DOC::my-id"
|
|
|
|
|
|
def test_parse_schema_version():
|
|
from libs.parser.rule_parser import SCHEMA_VERSION, RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", SAMPLE_TEXT_POSITIVE, {"filing_date": "2026-01-29"})
|
|
assert out.schema_version == SCHEMA_VERSION
|
|
|
|
|
|
def test_parse_unknown_event():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse("DOC::test", "8-K", "No items mentioned here.", {"filing_date": "2026-01-29"})
|
|
assert out.event_type == "unknown"
|
|
assert len(out.warnings) > 0
|
|
|
|
|
|
def test_parse_filing_time_bucket_set():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
p = RuleBasedParser()
|
|
out = p.parse(
|
|
"DOC::test",
|
|
"8-K",
|
|
SAMPLE_TEXT_POSITIVE,
|
|
{"filing_date": "2026-01-29", "accepted_at_utc": "2026-01-29T21:05:00Z"},
|
|
)
|
|
assert out.filing_time_bucket == "post_market"
|
|
|
|
|
|
def test_parse_html_earnings_release_without_item_numbers():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
from libs.parser.text_normalizer import normalize_text
|
|
|
|
p = RuleBasedParser()
|
|
html = """
|
|
<html><body>
|
|
<div>FOR IMMEDIATE RELEASE</div>
|
|
<div>Ciena Reports Fiscal First Quarter 2026 Financial Results</div>
|
|
<div>Providing revenue guidance of $1.5 billion plus or minus $50 million.</div>
|
|
<div>Raising revenue guidance range for fiscal year 2026.</div>
|
|
</body></html>
|
|
"""
|
|
|
|
out = p.parse(
|
|
"DOC::test",
|
|
"8-K",
|
|
normalize_text(html, is_html=True),
|
|
{"filing_date": "2026-03-05"},
|
|
)
|
|
|
|
assert out.event_type == "earnings_release"
|
|
assert out.guidance.status == "raised"
|
|
assert out.event_direction == "bullish"
|
|
|
|
|
|
def test_guidance_withdrawn_does_not_match_distant_words():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
|
|
p = RuleBasedParser()
|
|
text = (
|
|
"The company may withdraw from certain trade agreements. "
|
|
"Management also discussed guidance measures for fiscal 2026."
|
|
)
|
|
|
|
out = p.parse("DOC::test", "8-K", text, {"filing_date": "2026-03-05"})
|
|
|
|
assert out.guidance.status == "not_provided"
|
|
|
|
|
|
def test_positive_backlog_and_customer_signals_outweigh_single_financing_flag():
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
|
|
p = RuleBasedParser()
|
|
text = """
|
|
Company reports first quarter financial results.
|
|
Raising full-year guidance.
|
|
Backlog increased significantly year over year.
|
|
Added new enterprise customers during the quarter.
|
|
The release also references refinancing our term loan.
|
|
"""
|
|
|
|
out = p.parse("DOC::test", "8-K", text, {"filing_date": "2026-03-05"})
|
|
|
|
assert out.event_direction == "bullish"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Item -> event_type classification (regression tests for unknown-class bug
|
|
# where AMD/MNST 2.02 + 9.01 earnings filings were tagged "unknown").
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_classify_event_type_amd_style_earnings_release_with_9_01():
|
|
"""AMD/MNST file 2.02 alongside 9.01; must classify as earnings_release."""
|
|
from libs.parser.rule_parser import _classify_event_type
|
|
|
|
# Order in either direction — earnings still wins.
|
|
assert _classify_event_type(["2.02", "9.01"]) == "earnings_release"
|
|
assert _classify_event_type(["9.01", "2.02"]) == "earnings_release"
|
|
|
|
|
|
def test_classify_event_type_tolerates_dirty_item_strings():
|
|
"""Upstream may store entries like 'Item 2.02' or '2.02 - Results...'."""
|
|
from libs.parser.rule_parser import _classify_event_type
|
|
|
|
assert _classify_event_type(["Item 2.02", "Item 9.01"]) == "earnings_release"
|
|
assert (
|
|
_classify_event_type(
|
|
["Item 2.02 Results of Operations and Financial Condition"]
|
|
)
|
|
== "earnings_release"
|
|
)
|
|
assert _classify_event_type(["2.02 - Results of Operations"]) == "earnings_release"
|
|
|
|
|
|
def test_classify_event_type_known_mappings_each_item():
|
|
from libs.parser.rule_parser import _classify_event_type
|
|
|
|
assert _classify_event_type(["7.01"]) == "guidance_update"
|
|
assert _classify_event_type(["1.01"]) == "material_contract"
|
|
assert _classify_event_type(["8.01"]) == "other_material_event"
|
|
assert _classify_event_type(["1.03"]) == "other_material_event"
|
|
# 5.02 (departure of officers) -> management_change, even when co-filed
|
|
# with 9.01 financial-statements-and-exhibits.
|
|
assert _classify_event_type(["5.02", "9.01"]) == "management_change"
|
|
|
|
|
|
def test_classify_event_type_negative_cases_remain_unknown():
|
|
"""Items that aren't in the strategy vocabulary must remain 'unknown'.
|
|
|
|
This guards against accidental over-eager relabeling.
|
|
"""
|
|
from libs.parser.rule_parser import _classify_event_type
|
|
|
|
# 9.01 alone (financial statements & exhibits) is not its own event type
|
|
assert _classify_event_type(["9.01"]) == "unknown"
|
|
# 2.03 (financial obligation), 3.01 (delisting), 5.07 (shareholder vote)
|
|
assert _classify_event_type(["2.03"]) == "unknown"
|
|
assert _classify_event_type(["3.01"]) == "unknown"
|
|
assert _classify_event_type(["5.07"]) == "unknown"
|
|
# No items at all
|
|
assert _classify_event_type([]) == "unknown"
|
|
# Description-only with no extractable code
|
|
assert (
|
|
_classify_event_type(["Results of Operations and Financial Condition"])
|
|
== "unknown"
|
|
)
|
|
|
|
|
|
def test_parse_amd_style_earnings_with_only_9_01_in_item_numbers_uses_text_fallback():
|
|
"""If the SGML loss left only 9.01 in item_numbers but the body says
|
|
'AMD Reports Q3 Financial Results', the body regex must catch it."""
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
from libs.parser.text_normalizer import normalize_text
|
|
|
|
p = RuleBasedParser()
|
|
html = """
|
|
<html><body>
|
|
<div>NEWS RELEASE</div>
|
|
<div>AMD Reports Third Quarter 2025 Financial Results</div>
|
|
<div>Revenue of $7.7 billion grew year over year.</div>
|
|
</body></html>
|
|
"""
|
|
out = p.parse(
|
|
"DOC::test",
|
|
"8-K",
|
|
normalize_text(html, is_html=True),
|
|
{"filing_date": "2025-10-29", "item_numbers": ["9.01"]},
|
|
)
|
|
assert out.event_type == "earnings_release"
|
|
|
|
|
|
def test_parse_mnst_style_earnings_dirty_item_format_classifies_correctly():
|
|
"""MNST-style: 'Item 2.02 Results of Operations and Financial Condition'
|
|
plus 'Item 9.01 ...' as raw entries must classify earnings_release."""
|
|
from libs.parser.rule_parser import RuleBasedParser
|
|
|
|
p = RuleBasedParser()
|
|
text = (
|
|
"Monster Beverage Corporation Reports 2025 Third Quarter Financial Results\n"
|
|
"Net sales of $1.97 billion."
|
|
)
|
|
out = p.parse(
|
|
"DOC::test",
|
|
"8-K",
|
|
text,
|
|
{
|
|
"filing_date": "2025-11-06",
|
|
"item_numbers": [
|
|
"Item 2.02 Results of Operations and Financial Condition",
|
|
"Item 9.01 Financial Statements and Exhibits",
|
|
],
|
|
},
|
|
)
|
|
assert out.event_type == "earnings_release"
|
|
|
|
|
|
def test_normalize_item_codes_drops_non_string_and_dedupes():
|
|
from libs.parser.rule_parser import _normalize_item_codes
|
|
|
|
# Order-preserving + dedup + non-string ignored
|
|
assert _normalize_item_codes(["Item 2.02", "2.02", "9.01", None, 7]) == [
|
|
"2.02",
|
|
"9.01",
|
|
]
|