diff --git a/libs/parser/rule_parser.py b/libs/parser/rule_parser.py index c0b9791..ab78e2c 100644 --- a/libs/parser/rule_parser.py +++ b/libs/parser/rule_parser.py @@ -129,19 +129,54 @@ def _extract_item_numbers(text: str) -> list[str]: return list(dict.fromkeys(_ITEM_RE.findall(text))) +# Pattern used to harvest a clean N.NN item code from an arbitrarily formatted +# entry such as "2.02", "Item 2.02", "2.02 - Results of Operations and ...", +# or "Item 2.02 Results of Operations and Financial Condition". +_ITEM_NUMBER_TOKEN_RE = re.compile(r"\b(\d+\.\d+)\b") + + +def _normalize_item_codes(items: list[str]) -> list[str]: + """Normalize raw item entries into a list of clean N.NN codes. + + Tolerates upstream variation: + - "2.02" -> "2.02" + - "Item 2.02" -> "2.02" + - "2.02 - Results of Operations" -> "2.02" + - "Item 2.02 Results of Operations" -> "2.02" + - "Results of Operations and Financial..." -> "" (dropped) + Order-preserving, deduplicated. + """ + normalized: list[str] = [] + for raw in items or []: + if not isinstance(raw, str): + continue + m = _ITEM_NUMBER_TOKEN_RE.search(raw) + if m: + code = m.group(1) + if code not in normalized: + normalized.append(code) + return normalized + + +# 8-K item code -> ParserEventOutput.event_type vocabulary. +# Earnings (2.02) takes precedence over downstream items like 9.01 because a +# 2.02 release is conventionally co-filed with 9.01 (Financial Statements and +# Exhibits) — the strategy must see those as earnings_release, not "unknown". +_ITEM_TO_EVENT_TYPE: tuple[tuple[str, str], ...] = ( + ("2.02", "earnings_release"), + ("7.01", "guidance_update"), + ("5.02", "management_change"), + ("1.01", "material_contract"), + ("1.03", "other_material_event"), + ("8.01", "other_material_event"), +) + + def _classify_event_type(items: list[str]) -> str: - if "2.02" in items: - return "earnings_release" - if "7.01" in items: - return "guidance_update" - if "1.01" in items: - return "material_contract" - if "8.01" in items: - return "other_material_event" - if "5.02" in items: - return "management_change" - if "1.03" in items: - return "other_material_event" + codes = _normalize_item_codes(items) + for code, event_type in _ITEM_TO_EVENT_TYPE: + if code in codes: + return event_type return "unknown" diff --git a/tests/unit/test_rule_parser.py b/tests/unit/test_rule_parser.py index 072912a..5983874 100644 --- a/tests/unit/test_rule_parser.py +++ b/tests/unit/test_rule_parser.py @@ -165,3 +165,124 @@ def test_positive_backlog_and_customer_signals_outweigh_single_financing_flag(): 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 = """ +
+