Files
stonks-oracle/services/intelligence_pipeline_v3/parsing/financial_parser.py
T
Celes Renata a72f336ad1 feat: Intelligence Pipeline v3 — full implementation
Multi-stage evidence-grounded inference architecture replacing the
monolithic 9B model extraction pipeline. CPU-first specialist services
handle routine extraction while the 9B vLLM model is preserved for
semantic adjudication of ambiguous cases.

Key components:
- Capability-aware inference gateway (OpenAI-compatible + Ollama)
- Endpoint registry with DB migrations and REST API
- Sentence-aware document segmenter (property tests)
- Deterministic financial parsing with offset integrity
- Symbol resolution with ambiguity detection
- Specialist service (GLiNER2, dynamic batching, K8s deployment)
- Company-specific sentiment (FinBERT, calibration)
- Retrieval-based novelty and duplicate detection
- Confidence calibration pipeline
- Deterministic routing engine (property tests)
- 9B adjudication layer with VRAM gating
- Stock-specific impact model (features, labels, baseline, trained)
- Pipeline orchestrator (state machine, queues, leases, feature flags)
- Bounded parallelism (async workers, semaphore, load shedding)
- Observability (tracing, metrics, alerts)
- Compatibility adapter (v3→v2 golden mapping tests)
- Shadow/canary promotion framework
- Active learning and fine-tuning pipeline

Test results: 1,161 tests pass, ruff lint clean.
All 282 spec tasks completed.
2026-07-13 02:14:59 +00:00

427 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deterministic financial parser using regex-based detection.
Detects tickers, currencies, money amounts, percentages, basis points,
ranges, EPS, revenue, dates, and fiscal periods from source text.
Each match returns exact character offsets into the source text.
"""
from __future__ import annotations
import re
from services.intelligence_pipeline_v3.parsing.models import (
CandidateType,
ParsedCandidate,
PeriodAnnotation,
)
from services.intelligence_pipeline_v3.parsing.normalizer import (
normalize_basis_points,
normalize_money,
normalize_percentage,
normalize_range,
)
# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
# Ticker: $AAPL or standalone AAPL-like (1-5 uppercase letters)
_TICKER_DOLLAR_RE = re.compile(r"\$([A-Z]{1,5})\b")
_TICKER_BARE_RE = re.compile(r"\b([A-Z]{1,5})\b")
# Common English words that look like tickers but aren't
_TICKER_STOPWORDS = frozenset({
"A", "I", "AM", "AN", "AS", "AT", "BE", "BY", "DO", "GO", "HE", "IF",
"IN", "IS", "IT", "ME", "MY", "NO", "OF", "OK", "ON", "OR", "OUR", "SO",
"THE", "TO", "UP", "US", "WE", "CEO", "CFO", "COO", "CTO", "EPS", "ETF",
"GDP", "IPO", "LLC", "LTD", "NYSE", "SEC", "USA", "AND", "ARE", "BUT",
"CAN", "DID", "FOR", "GET", "GOT", "HAD", "HAS", "HER", "HIS", "HOW",
"ITS", "LET", "MAY", "NEW", "NOT", "NOW", "OLD", "OUR", "OWN", "PUT",
"RAN", "SAY", "SHE", "TOO", "TWO", "USE", "WAS", "WAY", "WHO", "WHY",
"WIN", "WON", "YET", "YOU", "ALL", "ANY", "BIG", "DAY", "END", "FEW",
"FAR", "HIT", "LOW", "MET", "NET", "OUT", "RUN", "SET", "TOP", "TRY",
"ALSO", "BACK", "BEEN", "BEST", "BOTH", "CAME", "COME", "DOWN", "EACH",
"FROM", "GAVE", "GOOD", "HAVE", "HERE", "HIGH", "INTO", "JUST", "KEEP",
"LAST", "LONG", "MADE", "MAKE", "MANY", "MORE", "MOST", "MUCH", "MUST",
"NEED", "NEXT", "ONLY", "OVER", "SAID", "SAME", "SOME", "SUCH", "TAKE",
"THAN", "THAT", "THEM", "THEN", "THEY", "THIS", "VERY", "WANT", "WELL",
"WENT", "WERE", "WHAT", "WHEN", "WILL", "WITH", "WORK", "YEAR", "YOUR",
"ITEM", "CASH", "FLOW", "FREE", "FULL", "HALF", "RISE", "ROSE", "FELL",
"BEAT", "MISS", "GREW", "GROW", "LOST", "LOSS", "GAIN", "HOLD", "SELL",
"CALL", "BUY", "FUND", "BOND", "RATE", "DEBT", "DEAL", "RISK",
"Q", "H", "FY", "YOY", "QOQ", "AI", "R", "D",
})
# Fiscal period: Q1 2024, Q4'24, FY2025, FY25, H1 2024
_FISCAL_PERIOD_RE = re.compile(
r"\b(Q[1-4]|H[12]|FY)\s*['\u2019]?\s*(\d{4}|\d{2})\b"
)
# Date patterns: January 15, 2024 / Jan 15, 2024 / 2024-01-15
_MONTH_NAMES = (
r"(?:January|February|March|April|May|June|July|August|September|October|November|December"
r"|Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
)
_DATE_NAMED_RE = re.compile(
rf"\b({_MONTH_NAMES})\s+(\d{{1,2}})(?:,?\s+(\d{{4}}))?\b"
)
_DATE_ISO_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
# Basis points: "25 basis points", "50bps", "25 bps"
_BASIS_POINTS_RE = re.compile(
r"[+-]?\d[\d,]*\.?\d*\s*(?:basis\s+points?|bps)\b", re.IGNORECASE
)
# Percentage: 4%, -2.5%, +1.2 percent
_PERCENTAGE_RE = re.compile(
r"[+-]?\d[\d,]*\.?\d*\s*(?:%|percent(?:age)?(?:\s+points?)?\b)", re.IGNORECASE
)
# EPS: "$1.52 per share", "earnings per share of $1.52"
_EPS_PER_SHARE_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s+per\s+share\b", re.IGNORECASE
)
_EPS_PREFIX_RE = re.compile(
r"\b(?:EPS|earnings\s+per\s+share)\s+(?:of\s+)?\$\s*\d[\d,]*\.?\d*", re.IGNORECASE
)
# Revenue: "$94.9 billion in revenue", "revenue of $94.9 billion"
_REVENUE_AMOUNT_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|bn|mn)\s+(?:in\s+)?revenue\b",
re.IGNORECASE,
)
_REVENUE_PREFIX_RE = re.compile(
r"\brevenue\s+(?:of|was|reached|hit|grew\s+to|increased\s+to|totaled)\s+\$\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|bn|mn)?",
re.IGNORECASE,
)
# Range: "$10-$12", "$10 to $12", "$1.50-$2.00"
_RANGE_RE = re.compile(
r"\$\s*\d[\d,]*\.?\d*\s*(?:-|to||—)\s*\$?\s*\d[\d,]*\.?\d*",
re.IGNORECASE,
)
# Money with multiplier: "$94.9 billion", "$1.2 million", "€5 billion"
_MONEY_MULT_RE = re.compile(
r"[€£¥$]\s*\d[\d,]*\.?\d*\s*(?:trillion|billion|million|thousand|bn|mn|tn|[kmbt])\b",
re.IGNORECASE,
)
# Simple currency: $123.45, €99, £1,234.56
# Note: requires digits after decimal point to avoid matching trailing periods
_CURRENCY_RE = re.compile(
r"[€£¥$]\s*\d[\d,]*(?:\.\d+)?"
)
# Currency codes: USD, EUR, GBP, JPY
_CURRENCY_SYMBOLS = {"$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY"}
def _detect_currency_unit(text: str) -> str:
"""Detect currency unit from symbol in text."""
for symbol, code in _CURRENCY_SYMBOLS.items():
if symbol in text:
return code
return "USD"
class FinancialParser:
"""Deterministic regex-based financial entity parser.
Detects financial entities in text and returns ParsedCandidate instances
with exact character offsets, literal text, and normalized values.
"""
def parse(self, text: str) -> list[ParsedCandidate]:
"""Parse text for financial entities.
Returns a list of ParsedCandidate sorted by start_char offset.
Overlapping matches are resolved by priority (more specific wins).
"""
if not text or not text.strip():
return []
candidates: list[ParsedCandidate] = []
# Order matters: more specific patterns first to claim offsets
candidates.extend(self._parse_fiscal_periods(text))
candidates.extend(self._parse_dates(text))
candidates.extend(self._parse_basis_points(text))
candidates.extend(self._parse_eps(text))
candidates.extend(self._parse_revenue(text))
candidates.extend(self._parse_ranges(text))
candidates.extend(self._parse_percentages(text))
candidates.extend(self._parse_money_with_multiplier(text))
candidates.extend(self._parse_tickers(text))
candidates.extend(self._parse_currency(text))
# Resolve overlaps: keep higher-priority (earlier in list) matches
candidates = self._resolve_overlaps(candidates)
# Sort by position
candidates.sort(key=lambda c: (c.start_char, -c.end_char))
return candidates
def _resolve_overlaps(self, candidates: list[ParsedCandidate]) -> list[ParsedCandidate]:
"""Remove overlapping candidates, keeping earlier ones (higher priority)."""
if not candidates:
return []
# Sort by start position for greedy non-overlap resolution
sorted_candidates = sorted(candidates, key=lambda c: (c.start_char, -c.end_char))
result: list[ParsedCandidate] = []
claimed: list[tuple[int, int]] = []
for cand in sorted_candidates:
overlaps = False
for start, end in claimed:
# Check if this candidate overlaps with any claimed range
if cand.start_char < end and cand.end_char > start:
overlaps = True
break
if not overlaps:
result.append(cand)
claimed.append((cand.start_char, cand.end_char))
return result
def _parse_tickers(self, text: str) -> list[ParsedCandidate]:
"""Parse ticker symbols: $AAPL style."""
results: list[ParsedCandidate] = []
# Dollar-prefixed tickers: $AAPL
for match in _TICKER_DOLLAR_RE.finditer(text):
ticker = match.group(1)
if ticker not in _TICKER_STOPWORDS:
results.append(ParsedCandidate(
candidate_type=CandidateType.TICKER,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_fiscal_periods(self, text: str) -> list[ParsedCandidate]:
"""Parse fiscal periods: Q1 2024, FY2025, H1 2024."""
results: list[ParsedCandidate] = []
for match in _FISCAL_PERIOD_RE.finditer(text):
period_prefix = match.group(1)
year_str = match.group(2)
year = int(year_str)
if len(year_str) == 2:
year = 2000 + year if year < 80 else 1900 + year
if period_prefix.startswith("Q"):
period_type = "quarter"
period_value = period_prefix
elif period_prefix.startswith("H"):
period_type = "half"
period_value = period_prefix
else: # FY
period_type = "fiscal_year"
period_value = "FY"
results.append(ParsedCandidate(
candidate_type=CandidateType.FISCAL_PERIOD,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
period=PeriodAnnotation(
period_type=period_type,
period_value=period_value,
year=year,
),
))
return results
def _parse_dates(self, text: str) -> list[ParsedCandidate]:
"""Parse dates: January 15, 2024 / 2024-01-15."""
results: list[ParsedCandidate] = []
for match in _DATE_NAMED_RE.finditer(text):
results.append(ParsedCandidate(
candidate_type=CandidateType.DATE,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
for match in _DATE_ISO_RE.finditer(text):
results.append(ParsedCandidate(
candidate_type=CandidateType.DATE,
literal_value=match.group(0),
normalized_value=None,
unit=None,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_basis_points(self, text: str) -> list[ParsedCandidate]:
"""Parse basis points: 25 basis points, 50bps."""
results: list[ParsedCandidate] = []
for match in _BASIS_POINTS_RE.finditer(text):
literal = match.group(0)
normalized = normalize_basis_points(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.BASIS_POINTS,
literal_value=literal,
normalized_value=normalized,
unit="bps",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_percentages(self, text: str) -> list[ParsedCandidate]:
"""Parse percentages: 4%, -2.5%, +1.2 percent."""
results: list[ParsedCandidate] = []
for match in _PERCENTAGE_RE.finditer(text):
literal = match.group(0)
normalized = normalize_percentage(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.PERCENTAGE,
literal_value=literal,
normalized_value=normalized,
unit="%",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_eps(self, text: str) -> list[ParsedCandidate]:
"""Parse EPS values: $1.52 per share, EPS of $1.52."""
results: list[ParsedCandidate] = []
for match in _EPS_PER_SHARE_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.EPS,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
for match in _EPS_PREFIX_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.EPS,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_revenue(self, text: str) -> list[ParsedCandidate]:
"""Parse revenue figures: $94.9 billion in revenue, revenue of $50 billion."""
results: list[ParsedCandidate] = []
for match in _REVENUE_AMOUNT_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.REVENUE,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
for match in _REVENUE_PREFIX_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.REVENUE,
literal_value=literal,
normalized_value=normalized,
unit="USD",
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_ranges(self, text: str) -> list[ParsedCandidate]:
"""Parse ranges: $10-$12, $1.50 to $2.00."""
results: list[ParsedCandidate] = []
for match in _RANGE_RE.finditer(text):
literal = match.group(0)
low, high = normalize_range(literal)
# Store midpoint as normalized value
normalized = None
if low is not None and high is not None:
normalized = (low + high) / 2.0
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.RANGE,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_money_with_multiplier(self, text: str) -> list[ParsedCandidate]:
"""Parse money with multiplier: $94.9 billion, €5 million."""
results: list[ParsedCandidate] = []
for match in _MONEY_MULT_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.MONEY,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results
def _parse_currency(self, text: str) -> list[ParsedCandidate]:
"""Parse simple currency: $123.45, €99, £1,234.56."""
results: list[ParsedCandidate] = []
for match in _CURRENCY_RE.finditer(text):
literal = match.group(0)
normalized = normalize_money(literal)
unit = _detect_currency_unit(literal)
results.append(ParsedCandidate(
candidate_type=CandidateType.CURRENCY,
literal_value=literal,
normalized_value=normalized,
unit=unit,
start_char=match.start(),
end_char=match.end(),
))
return results