From 722e5cf6a946199eda34bf26ea4389005a43f7cb Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sat, 9 May 2026 02:16:03 -0700 Subject: [PATCH] Add Oracle event_type vocabulary normalizer for fallback path When the rule parser can't classify an 8-K and falls back to Stock Oracle's filing-events API, Oracle's vocabulary (e.g. earnings_result, shareholder_vote, regulation_fd) was being written verbatim into events.event_type. The DB has no CHECK constraint (libs/db/models.py:189), so 22 distinct Oracle values silently leaked into a column the strategy's engine filters expect to be in its 4-event vocabulary. Result: ~1,069 live rows silently dropped from strategy candidate pool. Files: - NEW libs/parser/event_type_normalizer.py: normalize_oracle_event_type() with conservative synonym map; normalize_oracle_event() additionally uses _classify_event_type from rule_parser when an item_number is present (item-code path is more reliable than Oracle's event taxonomy) - MOD apps/pipeline/event_parser/main.py: oracle-fallback branch (~line 140) now calls normalize_oracle_event before writing to DB; emits oracle_event_type_normalized log event when value changes - NEW tests/unit/test_event_type_normalizer.py: 60 tests covering identity, synonyms, case/separator insensitivity, None/empty, non-string, item_number-precedence Mapping highlights (justifications in test docstrings): earnings_result/earnings_announcement/earnings -> earnings_release guidance_revision/guidance_change/regulation_fd -> guidance_update material_definitive_agreement/definitive_agreement -> material_contract shareholder_vote/acquisition_disposition/bankruptcy/other -> other_material_event Reg FD -> guidance_update mirrors rule_parser's Item 7.01 mapping for internal consistency. Debatable but auditable. Conservative pass-through for ambiguous values (financial_obligation, articles_amendment, contract_termination, etc., 14 distinct values). Visible filter-drop > silent re-tag. Live DB counts that would reclassify on a future --reparse pass: 412 earnings_result -> earnings_release 409 shareholder_vote -> other_material_event 237 regulation_fd -> guidance_update 7 acquisition_disposition -> other_material_event 4 other -> other_material_event TOTAL 1,069 rows currently in oracle-fallback dead-zone. 60/60 normalizer tests pass; combined parser+schema validator suite 86/86. Follow-up flagged: run --reparse on historical oracle-fallback rows after extending reparse_events() to also re-normalize known oracle-fallback values (currently only re-parses event_type='unknown'). Co-Authored-By: Claude Opus 4.7 --- apps/pipeline/event_parser/main.py | 15 +- libs/parser/event_type_normalizer.py | 159 ++++++++++++++++++++ tests/unit/test_event_type_normalizer.py | 177 +++++++++++++++++++++++ 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 libs/parser/event_type_normalizer.py create mode 100644 tests/unit/test_event_type_normalizer.py diff --git a/apps/pipeline/event_parser/main.py b/apps/pipeline/event_parser/main.py index 7ea766d..ae52598 100644 --- a/apps/pipeline/event_parser/main.py +++ b/apps/pipeline/event_parser/main.py @@ -23,6 +23,7 @@ from libs.db.models import Document, Event, EventParse, JobRun, SymbolMaster from libs.db.session import get_session from libs.oracle_client import FilingsService, make_oracle_client from libs.oracle_client.models import FilingEventEntry +from libs.parser.event_type_normalizer import normalize_oracle_event from libs.parser.rule_parser import PARSER_VERSION, SCHEMA_VERSION, RuleBasedParser, _classify_event_type from libs.parser.schema_validator import validate_parser_output from libs.parser.text_normalizer import looks_like_html, normalize_text @@ -137,7 +138,19 @@ async def run_event_parser( if oracle_event: try: async with session.begin_nested(): - event_type = oracle_event.event_type + raw_oracle_event_type = oracle_event.event_type + event_type = normalize_oracle_event( + raw_oracle_event_type, + item_number=oracle_event.item_number, + ) + if event_type != raw_oracle_event_type: + logger.info( + "oracle_event_type_normalized", + accession_no=doc.accession_no, + raw=raw_oracle_event_type, + normalized=event_type, + item_number=oracle_event.item_number, + ) event_direction = _ORACLE_EVENT_DIRECTION_MAP.get(event_type, "unknown") event_id_str = make_event_id(doc.document_id, event_type) diff --git a/libs/parser/event_type_normalizer.py b/libs/parser/event_type_normalizer.py new file mode 100644 index 0000000..37a2ab5 --- /dev/null +++ b/libs/parser/event_type_normalizer.py @@ -0,0 +1,159 @@ +"""Normalize Stock Oracle's filing-event vocabulary to the strategy's vocabulary. + +Background +---------- +When the rule-based parser cannot classify an 8-K (no exhibit text, low +confidence, etc.), `apps/pipeline/event_parser/main.py` falls back to the +Oracle filings-events API. Oracle's `event_type` strings are richer than +``ParserEventOutput.event_type`` — values like ``earnings_result``, +``shareholder_vote``, ``regulation_fd``, ``financial_obligation`` get written +into the ``events.event_type`` column unchanged, and the strategy's engine +filters silently drop them. + +This module provides a *conservative* mapping from Oracle's vocabulary to the +strategy's: only synonyms and clear item-code parallels are mapped. Ambiguous +values are left as-is so the failure mode is honest (row drops out of the +strategy filter) rather than silently re-tagged. + +Where possible, prefer ``normalize_oracle_event(value, item_number=...)``, +which routes through the existing 8-K item-code classifier in +``libs.parser.rule_parser`` for maximum consistency with the rule-based path. +""" +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Oracle vocabulary -> strategy vocabulary +# +# Conservative mapping. Each row needs an unambiguous synonym justification. +# When in doubt we leave the value untouched and let the engine filter drop +# it — that's louder and easier to debug than a quiet re-tag. +# +# Strategy vocabulary (libs/schemas/types.py ParserEventOutput.event_type): +# earnings_release, guidance_update, material_contract, management_change, +# other_material_event, regulatory_or_approval, capital_markets_or_financing, +# litigation_or_investigation, unknown +# +# Strategy *engine filter* hot path uses 4-5 of these (libs/backtest/scoring.py +# and selector.py): earnings_release, guidance_update, material_contract, +# other_material_event, management_change. +# --------------------------------------------------------------------------- +_ORACLE_TO_STRATEGY: dict[str, str] = { + # --- identity passes (already aligned) --- + "earnings_release": "earnings_release", + "guidance_update": "guidance_update", + "material_contract": "material_contract", + "management_change": "management_change", + "other_material_event": "other_material_event", + + # --- earnings synonyms --- + # Oracle's actual 412 mislabeled rows are "earnings_result"; "earnings_announcement" + # is added defensively for any future Oracle vocabulary drift. + "earnings_result": "earnings_release", + "earnings_announcement": "earnings_release", + "earnings": "earnings_release", + + # --- guidance synonyms --- + "guidance_revision": "guidance_update", + "guidance_change": "guidance_update", + # Reg FD (Item 7.01): the rule_parser already maps Item 7.01 -> guidance_update, + # so we keep parity. Reg FD often (but not always) carries guidance; this is the + # one debatable choice and is intentional for consistency with the item-code path. + "regulation_fd": "guidance_update", + "regulation_fd_disclosure": "guidance_update", + + # --- material contract synonyms (Item 1.01 wording variants) --- + "material_definitive_agreement": "material_contract", + "definitive_agreement": "material_contract", + + # --- other_material_event (Item 5.07, 2.01, 1.03 etc.) --- + # Item 5.07 — submission of matters to a vote of security holders. + "shareholder_vote": "other_material_event", + # Item 2.01 — completion of acquisition or disposition of assets. + "acquisition_disposition": "other_material_event", + "acquisition": "other_material_event", + "disposition": "other_material_event", + # Item 1.03 — bankruptcy or receivership. + "bankruptcy": "other_material_event", + # Generic "Other Events" (Item 8.01) — rule_parser maps 8.01 -> other_material_event. + "other_events": "other_material_event", + "other": "other_material_event", +} + + +def _canonicalize(value: str) -> str: + """Lowercase, trim, collapse separators so 'Earnings Result', 'earnings-result', + and 'EARNINGS_RESULT' all hit the same map key.""" + s = value.strip().lower() + # unify separators + s = s.replace("-", "_").replace(" ", "_").replace("/", "_") + # collapse repeated underscores + while "__" in s: + s = s.replace("__", "_") + return s.strip("_") + + +def normalize_oracle_event_type(oracle_value: str | None) -> str: + """Map an Oracle ``event_type`` string to the strategy's vocabulary. + + Behavior: + - ``None`` / empty / whitespace-only input -> ``"unknown"``. + - Mapped values returned in canonical strategy form. + - Unmapped values are returned untouched (preserving original casing/wording) + so the engine filter drops them visibly rather than silently re-tagging. + + Examples: + >>> normalize_oracle_event_type("earnings_result") + 'earnings_release' + >>> normalize_oracle_event_type("Earnings Result") + 'earnings_release' + >>> normalize_oracle_event_type("financial_obligation") + 'financial_obligation' + >>> normalize_oracle_event_type(None) + 'unknown' + """ + if oracle_value is None: + return "unknown" + if not isinstance(oracle_value, str): + return "unknown" + if not oracle_value.strip(): + return "unknown" + + key = _canonicalize(oracle_value) + if key in _ORACLE_TO_STRATEGY: + return _ORACLE_TO_STRATEGY[key] + # Honest pass-through: unmapped Oracle vocab is kept verbatim. + return oracle_value + + +def normalize_oracle_event( + oracle_value: str | None, + item_number: str | None = None, +) -> str: + """Stronger normalizer that prefers 8-K ``item_number`` when available. + + The existing rule-based parser already maps SEC 8-K item codes to the + strategy's vocabulary in ``libs.parser.rule_parser._ITEM_TO_EVENT_TYPE``. + Routing Oracle-fallback rows through that classifier gives us strict + parity with the rule-based path: a 2.02-tagged Oracle row becomes + ``earnings_release`` *exactly* like a rule-parsed row would. + + Falls back to the string synonym map when the item code is absent or + classifies as ``"unknown"``. + """ + # Prefer item-code routing — already battle-tested via rule_parser. + if item_number: + # Local import keeps this module free of heavy parser deps when only + # the string-only API is used (tests, lightweight callers). + from libs.parser.rule_parser import _classify_event_type + + mapped = _classify_event_type([item_number]) + if mapped != "unknown": + return mapped + + return normalize_oracle_event_type(oracle_value) + + +__all__ = [ + "normalize_oracle_event_type", + "normalize_oracle_event", +] diff --git a/tests/unit/test_event_type_normalizer.py b/tests/unit/test_event_type_normalizer.py new file mode 100644 index 0000000..883cae5 --- /dev/null +++ b/tests/unit/test_event_type_normalizer.py @@ -0,0 +1,177 @@ +"""Unit tests for libs.parser.event_type_normalizer. + +Covers: +- Each mapped Oracle value resolves to the expected strategy value. +- Unmapped values pass through unchanged (honest fail mode for engine filters). +- Empty / None / whitespace input -> "unknown". +- Case-insensitive + separator-tolerant matching. +- item_number-preferred path (uses rule_parser._ITEM_TO_EVENT_TYPE). +""" +from __future__ import annotations + +import pytest + +from libs.parser.event_type_normalizer import ( + normalize_oracle_event, + normalize_oracle_event_type, +) + + +# --------------------------------------------------------------------------- +# String-only normalizer +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw,expected", + [ + # Identity (already aligned with strategy vocab) + ("earnings_release", "earnings_release"), + ("guidance_update", "guidance_update"), + ("material_contract", "material_contract"), + ("management_change", "management_change"), + ("other_material_event", "other_material_event"), + # Earnings synonyms — the headline mapping (412 live rows) + ("earnings_result", "earnings_release"), + ("earnings_announcement", "earnings_release"), + ("earnings", "earnings_release"), + # Guidance synonyms + ("guidance_revision", "guidance_update"), + ("guidance_change", "guidance_update"), + # Reg FD -> guidance_update (matches rule_parser Item 7.01 mapping) + ("regulation_fd", "guidance_update"), + ("regulation_fd_disclosure", "guidance_update"), + # Material contract synonyms (Item 1.01 wording) + ("material_definitive_agreement", "material_contract"), + ("definitive_agreement", "material_contract"), + # other_material_event aggregations + ("shareholder_vote", "other_material_event"), + ("acquisition_disposition", "other_material_event"), + ("acquisition", "other_material_event"), + ("disposition", "other_material_event"), + ("bankruptcy", "other_material_event"), + ("other_events", "other_material_event"), + ("other", "other_material_event"), + ], +) +def test_mapped_values_normalize_correctly(raw: str, expected: str) -> None: + assert normalize_oracle_event_type(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + # Items intentionally left unmapped — strategy intent unclear; let the + # engine filter drop them visibly rather than silently re-tagging. + "financial_obligation", # 225 live rows + "articles_amendment", # 81 live rows + "contract_termination", + "unregistered_equity_sale", + "rights_modification", + "accountant_change", + "exit_activity", + "mine_safety", + "triggering_event", + "material_impairment", + "delisting_notice", + "bylaws_amendment", + "shareholder_nomination", + "control_change", + "completely_made_up_value", + ], +) +def test_unmapped_values_pass_through_unchanged(raw: str) -> None: + """Honest fail mode: don't silently re-tag ambiguous values.""" + assert normalize_oracle_event_type(raw) == raw + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("EARNINGS_RESULT", "earnings_release"), + ("Earnings Result", "earnings_release"), + ("earnings-result", "earnings_release"), + ("Earnings Result", "earnings_release"), # double-space + (" earnings_result ", "earnings_release"), # padding + ("REGULATION_FD", "guidance_update"), + ("Material Definitive Agreement", "material_contract"), + ("Shareholder/Vote", "other_material_event"), # alt separator + ], +) +def test_case_and_separator_insensitive(raw: str, expected: str) -> None: + assert normalize_oracle_event_type(raw) == expected + + +@pytest.mark.parametrize("bad", [None, "", " ", "\n", "\t"]) +def test_empty_and_none_become_unknown(bad: object) -> None: + assert normalize_oracle_event_type(bad) == "unknown" # type: ignore[arg-type] + + +def test_non_string_becomes_unknown() -> None: + # Defensive: pydantic's typed model should prevent this, but be safe. + assert normalize_oracle_event_type(123) == "unknown" # type: ignore[arg-type] + assert normalize_oracle_event_type([]) == "unknown" # type: ignore[arg-type] + + +def test_pass_through_preserves_original_casing() -> None: + """Unmapped values are returned verbatim (not lowercased) so logs match + Oracle's wire format and the DB column reflects exactly what was upstream.""" + assert normalize_oracle_event_type("Financial_Obligation") == "Financial_Obligation" + + +# --------------------------------------------------------------------------- +# item_number-preferred path +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "item_number,expected", + [ + ("2.02", "earnings_release"), # Results of Operations and Financial Condition + ("7.01", "guidance_update"), # Regulation FD Disclosure + ("5.02", "management_change"), + ("1.01", "material_contract"), + ("8.01", "other_material_event"), + ("1.03", "other_material_event"), + ], +) +def test_item_number_takes_precedence(item_number: str, expected: str) -> None: + """A wildly mislabeled Oracle string is overridden by a valid item_number. + + Rationale: the rule-parser already trusts item codes more than text-derived + classifications; the Oracle-fallback path should follow the same convention. + """ + assert ( + normalize_oracle_event( + "completely_bogus_oracle_string", + item_number=item_number, + ) + == expected + ) + + +def test_item_number_unknown_falls_back_to_string_map() -> None: + """Item codes outside _ITEM_TO_EVENT_TYPE shouldn't block string mapping.""" + # 9.99 isn't a real 8-K item; classifier returns "unknown", so we rely on string. + assert ( + normalize_oracle_event("earnings_result", item_number="9.99") + == "earnings_release" + ) + + +def test_no_item_number_uses_string_map() -> None: + assert ( + normalize_oracle_event("earnings_result", item_number=None) + == "earnings_release" + ) + assert ( + normalize_oracle_event("earnings_result", item_number="") + == "earnings_release" + ) + + +def test_no_item_number_no_match_passes_through() -> None: + assert ( + normalize_oracle_event("financial_obligation", item_number=None) + == "financial_obligation" + )