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.

410 lines
13 KiB
Python

"""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|raising.*guidance"
r"|increased.*guidance|raised.*forecast|raising.*forecast"
r"|above.*expectations|above.*consensus|raised.*outlook|raising.*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"\b(?:withdrawn|withdraw|withdrawing|suspended|suspend|suspending)\b"
r"(?:\W+\w+){0,3}\W+guidance\b",
re.IGNORECASE,
)
_EARNINGS_RELEASE_HINT = re.compile(
r"((?:reports?|reported|announced)\s+(?:its\s+)?(?:fiscal\s+)?"
r"(?:first|second|third|fourth|q[1-4]|[1-4](?:st|nd|rd|th))?.{0,40}financial results)"
r"|financial results for (?:its\s+)?(?:fiscal\s+)?"
r"(?:first|second|third|fourth|q[1-4]|[1-4](?:st|nd|rd|th)).{0,40}quarter",
re.IGNORECASE,
)
# Signals
_DEMAND_STRONG = re.compile(
r"(demand remains strong|strong demand|robust demand|record demand"
r"|demand acceleration|pipeline.*strong|strong.*pipeline|broad.based demand)",
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 _infer_event_type_from_text(text: str) -> str:
if _EARNINGS_RELEASE_HINT.search(text):
return "earnings_release"
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 signals.backlog_or_bookings == "present":
bullish_signals += 1
if signals.customer_expansion == "present":
bullish_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)
if event_type == "unknown":
event_type = _infer_event_type_from_text(text)
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,
)