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.
584 lines
24 KiB
Python
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
|