Files
stonks-oracle/tests/intelligence_pipeline_v3/parsing/test_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

584 lines
24 KiB
Python

"""Unit tests and property tests for the deterministic financial parser.
Tests cover:
- 23.1: Parse tickers, currencies, money, percentages, basis points, ranges, EPS, revenue, dates, fiscal periods
- 23.2: Store literal and normalized representations
- 23.3: Link each candidate to exact offsets
- 23.4: Property tests for numeric formatting and unit conversions
"""
from __future__ import annotations
import math
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from services.intelligence_pipeline_v3.parsing import (
CandidateType,
FinancialParser,
normalize_value,
)
from services.intelligence_pipeline_v3.parsing.normalizer import (
normalize_basis_points,
normalize_money,
normalize_percentage,
normalize_range,
)
@pytest.fixture
def parser() -> FinancialParser:
return FinancialParser()
# ---------------------------------------------------------------------------
# 23.1 — Parse tickers, currencies, money, percentages, basis points,
# ranges, EPS, revenue, dates, and fiscal periods
# ---------------------------------------------------------------------------
class TestTickerParsing:
"""Test ticker symbol detection."""
def test_dollar_ticker(self, parser: FinancialParser) -> None:
"""Detect $AAPL style tickers."""
results = parser.parse("Shares of $AAPL rose 3% today.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
assert len(tickers) == 1
assert tickers[0].literal_value == "$AAPL"
def test_multiple_tickers(self, parser: FinancialParser) -> None:
"""Detect multiple tickers in one text."""
results = parser.parse("$AAPL and $MSFT both reported earnings.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
assert len(tickers) == 2
literals = {t.literal_value for t in tickers}
assert "$AAPL" in literals
assert "$MSFT" in literals
def test_ticker_not_confused_with_currency(self, parser: FinancialParser) -> None:
"""$AAPL (letters) is a ticker, not currency."""
results = parser.parse("$AAPL hit $200.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert any(t.literal_value == "$AAPL" for t in tickers)
assert any(c.literal_value == "$200" for c in currencies)
class TestCurrencyParsing:
"""Test simple currency detection."""
def test_usd_amount(self, parser: FinancialParser) -> None:
results = parser.parse("The stock traded at $123.45 today.")
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert len(currencies) >= 1
assert any(c.literal_value == "$123.45" for c in currencies)
def test_euro_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Trading at €99 in Frankfurt.")
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert len(currencies) == 1
assert currencies[0].literal_value == "€99"
assert currencies[0].unit == "EUR"
def test_gbp_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Shares are £1,234.56 in London.")
currencies = [r for r in results if r.candidate_type == CandidateType.CURRENCY]
assert len(currencies) == 1
assert currencies[0].normalized_value == 1234.56
assert currencies[0].unit == "GBP"
class TestMoneyParsing:
"""Test money amounts with multipliers."""
def test_billion_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Apple reported $94.9 billion in revenue.")
# Should get a revenue match (more specific than money)
revenue = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(revenue) >= 1
def test_million_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Operating costs were $45 million last quarter.")
money = [r for r in results if r.candidate_type == CandidateType.MONEY]
assert len(money) >= 1
assert any(m.normalized_value == 45_000_000.0 for m in money)
class TestPercentageParsing:
"""Test percentage detection."""
def test_simple_percentage(self, parser: FinancialParser) -> None:
results = parser.parse("The stock rose 4% today.")
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].normalized_value == 4.0
assert pcts[0].unit == "%"
def test_negative_percentage(self, parser: FinancialParser) -> None:
results = parser.parse("Revenue declined -2.5% year-over-year.")
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].normalized_value == -2.5
def test_percent_word(self, parser: FinancialParser) -> None:
results = parser.parse("Margins expanded 1.2 percent this quarter.")
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].normalized_value == 1.2
class TestBasisPointsParsing:
"""Test basis points detection."""
def test_basis_points_full(self, parser: FinancialParser) -> None:
results = parser.parse("The Fed raised rates by 25 basis points.")
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert bps[0].normalized_value == pytest.approx(0.25)
assert bps[0].unit == "bps"
def test_bps_abbreviation(self, parser: FinancialParser) -> None:
results = parser.parse("Spreads widened 50bps today.")
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert bps[0].normalized_value == pytest.approx(0.50)
def test_bps_with_space(self, parser: FinancialParser) -> None:
results = parser.parse("Credit spreads tightened 100 bps.")
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert bps[0].normalized_value == pytest.approx(1.0)
class TestRangeParsing:
"""Test range detection."""
def test_dollar_range_dash(self, parser: FinancialParser) -> None:
results = parser.parse("Guidance was $10-$12 per share.")
ranges = [r for r in results if r.candidate_type == CandidateType.RANGE]
assert len(ranges) == 1
assert ranges[0].normalized_value == pytest.approx(11.0)
def test_dollar_range_to(self, parser: FinancialParser) -> None:
results = parser.parse("Expected range $1.50 to $2.00.")
ranges = [r for r in results if r.candidate_type == CandidateType.RANGE]
assert len(ranges) == 1
assert ranges[0].normalized_value == pytest.approx(1.75)
class TestEPSParsing:
"""Test EPS detection."""
def test_eps_per_share(self, parser: FinancialParser) -> None:
results = parser.parse("The company earned $1.52 per share.")
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
assert len(eps) == 1
assert eps[0].normalized_value == pytest.approx(1.52)
assert eps[0].unit == "USD"
def test_eps_prefix(self, parser: FinancialParser) -> None:
results = parser.parse("EPS of $2.18 beat expectations.")
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
assert len(eps) == 1
assert eps[0].normalized_value == pytest.approx(2.18)
class TestRevenueParsing:
"""Test revenue figure detection."""
def test_revenue_amount(self, parser: FinancialParser) -> None:
results = parser.parse("Apple reported $94.9 billion in revenue.")
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(rev) == 1
assert rev[0].normalized_value == pytest.approx(94_900_000_000.0)
assert rev[0].unit == "USD"
def test_revenue_prefix(self, parser: FinancialParser) -> None:
results = parser.parse("Revenue reached $50 billion this year.")
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(rev) == 1
assert rev[0].normalized_value == pytest.approx(50_000_000_000.0)
class TestDateParsing:
"""Test date detection."""
def test_named_month_date(self, parser: FinancialParser) -> None:
results = parser.parse("The report was filed on January 15, 2024.")
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
assert len(dates) == 1
assert "January 15" in dates[0].literal_value
def test_abbreviated_month(self, parser: FinancialParser) -> None:
results = parser.parse("Earnings released on Oct 28, 2024.")
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
assert len(dates) == 1
assert "Oct 28" in dates[0].literal_value
def test_iso_date(self, parser: FinancialParser) -> None:
results = parser.parse("Published on 2024-01-15.")
dates = [r for r in results if r.candidate_type == CandidateType.DATE]
assert len(dates) == 1
assert dates[0].literal_value == "2024-01-15"
class TestFiscalPeriodParsing:
"""Test fiscal period detection."""
def test_quarter_with_year(self, parser: FinancialParser) -> None:
results = parser.parse("Results for Q1 2024 were strong.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.period_type == "quarter"
assert periods[0].period.period_value == "Q1"
assert periods[0].period.year == 2024
def test_fiscal_year(self, parser: FinancialParser) -> None:
results = parser.parse("FY2025 outlook is positive.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.period_type == "fiscal_year"
assert periods[0].period.year == 2025
def test_half_year(self, parser: FinancialParser) -> None:
results = parser.parse("H1 2024 revenue grew 15%.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.period_type == "half"
assert periods[0].period.period_value == "H1"
def test_short_year(self, parser: FinancialParser) -> None:
results = parser.parse("Q4'24 results beat estimates.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].period is not None
assert periods[0].period.year == 2024
# ---------------------------------------------------------------------------
# 23.2 — Store literal and normalized representations
# ---------------------------------------------------------------------------
class TestLiteralAndNormalized:
"""Test that both literal text and normalized numeric values are stored."""
def test_money_stores_both(self, parser: FinancialParser) -> None:
text = "$94.9 billion in revenue"
results = parser.parse(text)
rev = [r for r in results if r.candidate_type == CandidateType.REVENUE]
assert len(rev) == 1
# Literal preserved exactly
assert rev[0].literal_value == "$94.9 billion in revenue"
# Normalized to numeric
assert rev[0].normalized_value == pytest.approx(94_900_000_000.0)
def test_percentage_stores_both(self, parser: FinancialParser) -> None:
text = "Growth was 4% this quarter."
results = parser.parse(text)
pcts = [r for r in results if r.candidate_type == CandidateType.PERCENTAGE]
assert len(pcts) == 1
assert pcts[0].literal_value == "4%"
assert pcts[0].normalized_value == 4.0
def test_basis_points_stores_both(self, parser: FinancialParser) -> None:
text = "Rates increased 25 basis points."
results = parser.parse(text)
bps = [r for r in results if r.candidate_type == CandidateType.BASIS_POINTS]
assert len(bps) == 1
assert "25 basis points" in bps[0].literal_value
assert bps[0].normalized_value == pytest.approx(0.25)
def test_eps_stores_both(self, parser: FinancialParser) -> None:
text = "EPS was $1.52 per share."
results = parser.parse(text)
eps = [r for r in results if r.candidate_type == CandidateType.EPS]
assert len(eps) == 1
assert "$1.52 per share" in eps[0].literal_value
assert eps[0].normalized_value == pytest.approx(1.52)
def test_ticker_has_no_normalized_value(self, parser: FinancialParser) -> None:
"""Tickers don't have numeric values."""
results = parser.parse("$AAPL is up today.")
tickers = [r for r in results if r.candidate_type == CandidateType.TICKER]
assert len(tickers) == 1
assert tickers[0].normalized_value is None
def test_date_has_no_normalized_value(self, parser: FinancialParser) -> None:
"""Dates don't have numeric values (they have period annotations)."""
results = parser.parse("Q1 2024 results are strong.")
periods = [r for r in results if r.candidate_type == CandidateType.FISCAL_PERIOD]
assert len(periods) == 1
assert periods[0].normalized_value is None
assert periods[0].period is not None
# ---------------------------------------------------------------------------
# 23.3 — Link each candidate to exact offsets
# ---------------------------------------------------------------------------
class TestExactOffsets:
"""Test that each candidate has correct start_char and end_char."""
def test_offset_matches_source(self, parser: FinancialParser) -> None:
"""literal_value must equal text[start_char:end_char]."""
text = "Apple earned $1.52 per share in Q1 2024."
results = parser.parse(text)
for candidate in results:
assert text[candidate.start_char:candidate.end_char] == candidate.literal_value, (
f"Offset mismatch for {candidate.candidate_type}: "
f"expected '{candidate.literal_value}' but got "
f"'{text[candidate.start_char:candidate.end_char]}'"
)
def test_offsets_for_multiple_candidates(self, parser: FinancialParser) -> None:
"""Multiple candidates all have valid offsets."""
text = "$AAPL reported $94.9 billion in revenue, up 15% year-over-year."
results = parser.parse(text)
assert len(results) >= 3 # ticker, revenue, percentage
for candidate in results:
assert text[candidate.start_char:candidate.end_char] == candidate.literal_value
def test_offsets_no_overlap(self, parser: FinancialParser) -> None:
"""Candidates should not have overlapping offsets."""
text = "$AAPL rose 3% after reporting $94.9 billion in revenue and EPS of $2.18."
results = parser.parse(text)
for i in range(len(results)):
for j in range(i + 1, len(results)):
a = results[i]
b = results[j]
# No overlap: one must end before the other starts
assert a.end_char <= b.start_char or b.end_char <= a.start_char, (
f"Overlap between {a.candidate_type}[{a.start_char}:{a.end_char}] "
f"and {b.candidate_type}[{b.start_char}:{b.end_char}]"
)
def test_offsets_within_bounds(self, parser: FinancialParser) -> None:
"""All offsets must be within the text bounds."""
text = "The stock price was $45.67 in Q3 2024."
results = parser.parse(text)
for candidate in results:
assert candidate.start_char >= 0
assert candidate.end_char <= len(text)
assert candidate.start_char < candidate.end_char
def test_empty_text_no_candidates(self, parser: FinancialParser) -> None:
"""Empty text produces no candidates."""
assert parser.parse("") == []
assert parser.parse(" \n\t ") == []
# ---------------------------------------------------------------------------
# 23.4 — Property tests for numeric formatting and unit conversions
# ---------------------------------------------------------------------------
class TestPropertyBasedFinancialParser:
"""Property-based tests for financial parsing.
**Validates: Requirements 5.1, 5.7, 5.8**
"""
@given(
amount=st.floats(min_value=0.01, max_value=999.99, allow_nan=False, allow_infinity=False),
)
@settings(max_examples=100)
def test_currency_strings_parse_to_expected_value(self, amount: float) -> None:
"""Property: Generated currency strings parse to their expected value.
**Validates: Requirements 5.1, 5.7**
"""
# Round to 2 decimal places for realistic currency
amount = round(amount, 2)
text = f"The price was ${amount:.2f} per unit."
parser = FinancialParser()
results = parser.parse(text)
# Should find at least one currency or EPS match
numeric_candidates = [
r for r in results
if r.candidate_type in (CandidateType.CURRENCY, CandidateType.EPS)
and r.normalized_value is not None
]
assert len(numeric_candidates) >= 1
assert any(
abs(c.normalized_value - amount) < 0.01
for c in numeric_candidates
), f"No candidate matched expected value {amount}"
@given(
pct=st.floats(min_value=-99.9, max_value=99.9, allow_nan=False, allow_infinity=False),
)
@settings(max_examples=100)
def test_percentage_strings_parse_to_expected_value(self, pct: float) -> None:
"""Property: Generated percentage strings parse to their expected value.
**Validates: Requirements 5.1, 5.7**
"""
pct = round(pct, 1)
if pct == 0.0:
pct = 1.0 # Avoid edge case with sign
sign = "+" if pct > 0 else ""
text = f"Revenue changed {sign}{pct}% this quarter."
parser = FinancialParser()
results = parser.parse(text)
pct_candidates = [
r for r in results
if r.candidate_type == CandidateType.PERCENTAGE
and r.normalized_value is not None
]
assert len(pct_candidates) >= 1
assert any(
abs(c.normalized_value - pct) < 0.1
for c in pct_candidates
), f"No candidate matched expected percentage {pct}"
@given(
bps=st.integers(min_value=1, max_value=500),
)
@settings(max_examples=100)
def test_basis_points_normalize_to_percentage(self, bps: int) -> None:
"""Property: N basis points always normalizes to N/100 percentage points.
**Validates: Requirements 5.7, 5.8**
"""
text = f"Rates moved {bps} basis points today."
parser = FinancialParser()
results = parser.parse(text)
bps_candidates = [
r for r in results
if r.candidate_type == CandidateType.BASIS_POINTS
and r.normalized_value is not None
]
assert len(bps_candidates) >= 1
expected = bps / 100.0
assert any(
abs(c.normalized_value - expected) < 0.001
for c in bps_candidates
), f"Expected {expected} but got {[c.normalized_value for c in bps_candidates]}"
@given(
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters="$€£¥%.,+-/0123456789",
)),
)
@settings(max_examples=100)
def test_normalized_values_are_always_finite(self, text: str) -> None:
"""Property: Normalized values are always finite floats (no inf, no NaN).
**Validates: Requirements 5.7, 5.8**
"""
parser = FinancialParser()
results = parser.parse(text)
for candidate in results:
if candidate.normalized_value is not None:
assert math.isfinite(candidate.normalized_value), (
f"Non-finite value {candidate.normalized_value} for "
f"{candidate.candidate_type}: '{candidate.literal_value}'"
)
@given(
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters="$€£¥%.,+-/0123456789 \n",
)),
)
@settings(max_examples=100)
def test_offsets_always_map_to_literal(self, text: str) -> None:
"""Property: For any text, candidate offsets always map to the literal value.
**Validates: Requirements 5.1, 5.8**
"""
parser = FinancialParser()
results = parser.parse(text)
for candidate in results:
extracted = text[candidate.start_char:candidate.end_char]
assert extracted == candidate.literal_value, (
f"Offset mismatch: [{candidate.start_char}:{candidate.end_char}] = "
f"'{extracted}' != literal '{candidate.literal_value}'"
)
@given(
text=st.text(min_size=1, max_size=5000, alphabet=st.characters(
categories=("L", "N", "P", "Z", "S"),
include_characters="$€£¥%.,+-/0123456789 \n",
)),
)
@settings(max_examples=100)
def test_no_overlapping_candidates(self, text: str) -> None:
"""Property: No two candidates have overlapping offsets.
**Validates: Requirements 5.1**
"""
parser = FinancialParser()
results = parser.parse(text)
for i in range(len(results)):
for j in range(i + 1, len(results)):
a = results[i]
b = results[j]
assert a.end_char <= b.start_char or b.end_char <= a.start_char, (
f"Overlap: {a.candidate_type}[{a.start_char}:{a.end_char}] "
f"vs {b.candidate_type}[{b.start_char}:{b.end_char}]"
)
# ---------------------------------------------------------------------------
# Normalizer unit tests
# ---------------------------------------------------------------------------
class TestNormalizerFunctions:
"""Test normalizer helper functions directly."""
def test_normalize_money_billion(self) -> None:
assert normalize_money("$94.9 billion") == pytest.approx(94_900_000_000.0)
def test_normalize_money_million(self) -> None:
assert normalize_money("$45 million") == pytest.approx(45_000_000.0)
def test_normalize_money_per_share(self) -> None:
assert normalize_money("$1.52 per share") == pytest.approx(1.52)
def test_normalize_money_simple(self) -> None:
assert normalize_money("$123.45") == pytest.approx(123.45)
def test_normalize_money_with_commas(self) -> None:
assert normalize_money("$1,234.56") == pytest.approx(1234.56)
def test_normalize_percentage(self) -> None:
assert normalize_percentage("4%") == pytest.approx(4.0)
assert normalize_percentage("-2.5%") == pytest.approx(-2.5)
assert normalize_percentage("+1.2 percent") == pytest.approx(1.2)
def test_normalize_basis_points(self) -> None:
assert normalize_basis_points("25 basis points") == pytest.approx(0.25)
assert normalize_basis_points("50bps") == pytest.approx(0.50)
assert normalize_basis_points("100 bps") == pytest.approx(1.0)
def test_normalize_range(self) -> None:
low, high = normalize_range("$10-$12")
assert low == pytest.approx(10.0)
assert high == pytest.approx(12.0)
def test_normalize_range_to(self) -> None:
low, high = normalize_range("$1.50 to $2.00")
assert low == pytest.approx(1.50)
assert high == pytest.approx(2.00)
def test_normalize_value_dispatches(self) -> None:
assert normalize_value("money", "$94.9 billion") == pytest.approx(94_900_000_000.0)
assert normalize_value("percentage", "4%") == pytest.approx(4.0)
assert normalize_value("basis_points", "25 basis points") == pytest.approx(0.25)
assert normalize_value("eps", "$1.52 per share") == pytest.approx(1.52)
assert normalize_value("ticker", "$AAPL") is None