"""Rule-based event parser for SEC 8-K/6-K exhibits.""" from __future__ import annotations import datetime as dt import re from typing import Any from libs.common.time_utils import filing_time_bucket from libs.schemas.types import ( ConfidenceOutput, EvidenceItem, GuidanceOutput, ParserEventOutput, RiskFlagsOutput, SignalsOutput, ) PARSER_VERSION = "rule-1.0.0" SCHEMA_VERSION = "1.0.0" # Item number patterns _ITEM_RE = re.compile(r"item\s+(\d+\.\d+)", re.IGNORECASE) # Guidance _GUIDANCE_RAISED = re.compile( r"(guidance raised|above prior outlook|raised.*guidance|increased.*guidance" r"|raised.*forecast|above.*expectations|above.*consensus|raised.*outlook" r"|above.*prior.*guidance|exceed.*guidance)", re.IGNORECASE, ) _GUIDANCE_LOWERED = re.compile( r"(guidance lowered|revised down|lowered.*guidance|reduced.*guidance" r"|below.*expectations|below.*prior.*guidance|cut.*guidance|lowered.*outlook" r"|lowered.*forecast)", re.IGNORECASE, ) _GUIDANCE_MAINTAINED = re.compile( r"(reaffirm|maintain.*guidance|in.line.*outlook|inline.*guidance|on.track)", re.IGNORECASE, ) _GUIDANCE_WITHDRAWN = re.compile(r"(withdraw.*guidance|suspend.*guidance)", re.IGNORECASE) # Signals _DEMAND_STRONG = re.compile( r"(demand remains strong|strong demand|robust demand|record demand" r"|demand acceleration|pipeline.*strong|strong.*pipeline)", re.IGNORECASE, ) _DEMAND_WEAK = re.compile( r"(demand softness|weak demand|softer demand|demand weakness" r"|elongated sales cycles|slower.*demand)", re.IGNORECASE, ) _PRICING_POWER = re.compile( r"(pricing strength|price realization|pricing power|favorable.*pricing" r"|pricing.*favorable|price.*increase|raised.*prices)", re.IGNORECASE, ) _PRICING_ABSENT = re.compile( r"(pricing pressure|price.*compression|competitive.*pricing|price.*decline)", re.IGNORECASE, ) _BACKLOG_PRESENT = re.compile( r"(backlog.*increas|increased.*backlog|bookings.*acceler|record.*backlog" r"|strong.*backlog|ARR.*grew|deferred.*revenue.*increas|bookings.*grew)", re.IGNORECASE, ) _CUSTOMER_EXPANSION = re.compile( r"(customer.*expand|new.*customer|customer.*addition|customer.*grow" r"|expanded.*customer|added.*customer|net.*new.*customer)", re.IGNORECASE, ) _MARGIN_IMPROVING = re.compile( r"(margin.*expan|expanding.*margin|margin.*improv|gross.*margin.*increas" r"|operating.*margin.*improv|profitability.*improv)", re.IGNORECASE, ) _MARGIN_DETERIORATING = re.compile( r"(margin.*compress|margin.*declin|margin.*contract|gross.*margin.*declin" r"|operating.*margin.*declin)", re.IGNORECASE, ) # Risk flags _ONEOFF = re.compile( r"(one.time|one.off|non.recurring|special.*charge|restructuring.*charge" r"|impairment.*charge|write.down|write.off)", re.IGNORECASE, ) _TAX_BENEFIT = re.compile( r"(tax.*benefit|deferred.*tax.*asset|tax.*credit|favorable.*tax)", re.IGNORECASE ) _VALUATION_GAIN = re.compile( r"(fair value.*gain|gain on.*sale|unrealized.*gain|valuation.*gain" r"|mark.to.market.*gain)", re.IGNORECASE, ) _NON_GAAP = re.compile( r"(non.GAAP|adjusted.*earnings|adjusted.*EPS|adjusted.*EBITDA" r"|excluding.*items|excluding.*charges)", re.IGNORECASE, ) _FINANCING = re.compile( r"(secondary.*offering|convertible.*note|equity.*offering|debt.*financing" r"|new.*shares|dilut)", re.IGNORECASE, ) _LEGAL = re.compile( r"(litigation|investigation|SEC.*inquiry|DOJ|legal.*proceeding" r"|regulatory.*action|enforcement.*action)", re.IGNORECASE, ) def _extract_item_numbers(text: str) -> list[str]: return list(dict.fromkeys(_ITEM_RE.findall(text))) 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" return "unknown" def _detect_guidance(text: str) -> GuidanceOutput: if _GUIDANCE_WITHDRAWN.search(text): return GuidanceOutput(status="withdrawn", scope="unknown", notes="Guidance withdrawn.") if _GUIDANCE_RAISED.search(text): m = _GUIDANCE_RAISED.search(text) return GuidanceOutput( status="raised", scope="unknown", notes=m.group(0) if m else "", ) if _GUIDANCE_LOWERED.search(text): m = _GUIDANCE_LOWERED.search(text) return GuidanceOutput( status="lowered", scope="unknown", notes=m.group(0) if m else "", ) if _GUIDANCE_MAINTAINED.search(text): return GuidanceOutput( status="inline_or_maintained", scope="unknown", notes="Guidance maintained." ) return GuidanceOutput(status="not_provided", scope="unknown", notes="") def _detect_signals(text: str) -> SignalsOutput: demand: str if _DEMAND_STRONG.search(text): demand = "strong" elif _DEMAND_WEAK.search(text): demand = "weakening" else: demand = "unknown" pricing: str if _PRICING_POWER.search(text): pricing = "present" elif _PRICING_ABSENT.search(text): pricing = "absent" else: pricing = "unknown" backlog = "present" if _BACKLOG_PRESENT.search(text) else "unknown" customer = "present" if _CUSTOMER_EXPANSION.search(text) else "unknown" margin: str if _MARGIN_IMPROVING.search(text): margin = "improving" elif _MARGIN_DETERIORATING.search(text): margin = "deteriorating" else: margin = "unknown" return SignalsOutput( demand_strength=demand, pricing_power=pricing, backlog_or_bookings=backlog, customer_expansion=customer, margin_quality=margin, ) def _detect_risk_flags(text: str) -> RiskFlagsOutput: return RiskFlagsOutput( oneoff_item=bool(_ONEOFF.search(text)), tax_benefit=bool(_TAX_BENEFIT.search(text)), valuation_gain=bool(_VALUATION_GAIN.search(text)), non_gaap_heavy=bool(_NON_GAAP.search(text)), financing_related=bool(_FINANCING.search(text)), legal_or_regulatory_overhang=bool(_LEGAL.search(text)), ) def _classify_direction( guidance: GuidanceOutput, signals: SignalsOutput, risk_flags: RiskFlagsOutput, ) -> str: bullish_signals = 0 bearish_signals = 0 if guidance.status == "raised": bullish_signals += 2 elif guidance.status == "lowered" or guidance.status == "withdrawn": bearish_signals += 2 if signals.demand_strength == "strong": bullish_signals += 1 elif signals.demand_strength == "weakening": bearish_signals += 1 if signals.pricing_power == "present": bullish_signals += 1 elif signals.pricing_power == "absent": bearish_signals += 1 if signals.margin_quality == "improving": bullish_signals += 1 elif signals.margin_quality == "deteriorating": bearish_signals += 1 if risk_flags.financing_related: bearish_signals += 1 if bullish_signals > bearish_signals + 1: return "bullish" if bearish_signals > bullish_signals + 1: return "bearish" if bullish_signals > 0 or bearish_signals > 0: return "mixed" return "unknown" def _compute_confidence( items: list[str], guidance: GuidanceOutput, signals: SignalsOutput, risk_flags: RiskFlagsOutput, ) -> ConfidenceOutput: event_type_conf = 0.9 if items else 0.4 guidance_conf = 0.0 if guidance.status in ("not_provided", "unclear") else 0.8 direction_conf = 0.5 known_signals = sum( 1 for v in [ signals.demand_strength, signals.pricing_power, signals.backlog_or_bookings, signals.customer_expansion, signals.margin_quality, ] if v != "unknown" ) direction_conf = min(0.9, 0.3 + known_signals * 0.12) risk_count = sum( [ risk_flags.oneoff_item, risk_flags.tax_benefit, risk_flags.valuation_gain, risk_flags.non_gaap_heavy, risk_flags.financing_related, risk_flags.legal_or_regulatory_overhang, ] ) risk_conf = max(0.2, 1.0 - risk_count * 0.1) overall = (event_type_conf * 0.3 + direction_conf * 0.4 + guidance_conf * 0.2 + risk_conf * 0.1) return ConfidenceOutput( overall=round(overall, 3), event_type=round(event_type_conf, 3), event_direction=round(direction_conf, 3), guidance=round(guidance_conf, 3), risk_flags=round(risk_conf, 3), ) def _build_evidence(text: str, guidance: GuidanceOutput, signals: SignalsOutput) -> list[EvidenceItem]: evidence: list[EvidenceItem] = [] if guidance.notes: evidence.append( EvidenceItem( label="guidance_signal", text_span=guidance.notes[:200], section_hint="guidance", confidence=0.8, ) ) for pattern, label in [ (_DEMAND_STRONG, "demand_strong"), (_BACKLOG_PRESENT, "backlog_present"), (_CUSTOMER_EXPANSION, "customer_expansion"), (_MARGIN_IMPROVING, "margin_improving"), ]: m = pattern.search(text) if m: evidence.append( EvidenceItem( label=label, text_span=m.group(0)[:200], section_hint="body", confidence=0.7, ) ) return evidence[:10] class RuleBasedParser: """Deterministic rule-based parser for SEC 8-K/6-K documents.""" def parse( self, document_id: str, form_type: str, text: str, metadata: dict[str, Any] | None = None, ) -> ParserEventOutput: metadata = metadata or {} filing_date_str = metadata.get("filing_date", dt.date.today().isoformat()) accepted_at_str = metadata.get("accepted_at_utc") # Determine filing time bucket time_bucket = "unknown" if accepted_at_str: try: import datetime as dt2 accepted_dt = dt2.datetime.fromisoformat(accepted_at_str.replace("Z", "+00:00")) time_bucket = filing_time_bucket(accepted_dt) except Exception: time_bucket = "unknown" items = metadata.get("item_numbers") or _extract_item_numbers(text) event_type = _classify_event_type(items) guidance = _detect_guidance(text) signals = _detect_signals(text) risk_flags = _detect_risk_flags(text) direction = _classify_direction(guidance, signals, risk_flags) confidence = _compute_confidence(items, guidance, signals, risk_flags) evidence = _build_evidence(text, guidance, signals) # Build summary from first 500 chars first_para = text[:500].strip().replace("\n", " ") summary = first_para if first_para else f"{form_type} document, event: {event_type}" warnings: list[str] = [] if event_type == "unknown": warnings.append("Could not determine event type from item numbers or content.") if direction == "unknown": warnings.append("Could not determine event direction from content signals.") return ParserEventOutput( schema_version=SCHEMA_VERSION, document_id=document_id, parser_kind="rule", event_type=event_type, event_direction=direction, event_date=filing_date_str, filing_time_bucket=time_bucket, headline=summary[:120], summary=summary, guidance=guidance, signals=signals, risk_flags=risk_flags, evidence=evidence, confidence=confidence, warnings=warnings, )