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.
383 lines
12 KiB
Python
383 lines
12 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|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,
|
|
)
|