diff --git a/docs/README.md b/docs/README.md index 757ab4b..8a16f8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,9 +28,10 @@ layer shown in the catalog. |---|---|---|---| | PR Newswire | `NewsWindow(source="pr-newswire", ...)` | `collect_news` | `python scripts/verify_news_e2e.py` | -The PR Newswire gate checks the public RSS feed and a complete preceding -24-hour listing window without fetching article pages. Its GitHub workflow -runs on pull requests, daily, and on manual dispatch. +The PR Newswire gate checks the public RSS feed, a complete preceding 24-hour +listing window, and ticker-hint recall on a bounded sample of up to 25 article +pages. Its GitHub workflow runs on pull requests, daily, and on manual +dispatch. ## Verification diff --git a/docs/design/en/news.md b/docs/design/en/news.md index 269a967..5e12d23 100644 --- a/docs/design/en/news.md +++ b/docs/design/en/news.md @@ -204,17 +204,22 @@ observations, raw-byte retention, retries, partial failures, and completeness. ### Live news E2E `python scripts/verify_news_e2e.py` is the canonical public-network component -check. It performs two bounded checks: +check. It performs three bounded checks: 1. fetch and parse the official PR Newswire RSS feed; 2. discover PR Newswire listing observations for the preceding 24 hours and - prove that discovery crossed the window start. - -The E2E check never fetches article pages. It prints component-level PASS/FAIL -records and a compact observation/page/failure summary, then exits non-zero if -RSS is empty or invalid, listing discovery is empty or incomplete, or a -component raises. This keeps the check lightweight while detecting public -source or parser drift. + prove that discovery crossed the window start; +3. fetch up to 25 unique article observations and require 100% ticker-hint + recall for the first supported exchange-coded symbol in each group. + +The ticker control uses an oracle regex that is independent from the production +extractor and accepts plain, Markdown-link, and emphasis-wrapped symbols. It +intentionally ignores later members of multi-symbol lists and exchanges outside +the production whitelist. The gate prints component-level PASS/FAIL records and +compact discovery, article-sample, failure, and recall summaries. It exits +non-zero if RSS or discovery is invalid, no sampled article parses, or a sample +with supported exchange-coded symbols falls below 100% recall. A parsed sample +with zero supported symbols reports `SKIP` and passes neutrally. GitHub Actions runs this check on every pull request, once daily, and on manual dispatch. The ordinary offline verification workflow remains network-free so diff --git a/examples/preprocess/01_news_pr_wire.py b/examples/preprocess/01_news_pr_wire.py index ea66e33..647276b 100644 --- a/examples/preprocess/01_news_pr_wire.py +++ b/examples/preprocess/01_news_pr_wire.py @@ -5,13 +5,13 @@ from quantmind.preprocess.news import RawNewsDocument, preprocess_news_document raw = RawNewsDocument( - title="NVIDIA Announces Results", + title="Carnival Announces Results", body_text=( - "NVIDIA Corporation (NASDAQ: NVDA) today reported record quarterly " - "revenue and highlighted demand for accelerated computing." + "Carnival Corporation & plc (NYSE: [CCL](#financial-modal)) today " + "reported record quarterly revenue." ), source_type="press_release", - source_url="https://example.com/news/nvidia-results", + source_url="https://example.com/news/carnival-results", publisher="Example Newswire", published_at=datetime(2026, 7, 6, 12, 0, tzinfo=timezone.utc), payload_id="example-wire-123", @@ -21,4 +21,5 @@ print(candidate.identity) print(candidate.content_hash) +print(candidate.body_text) print([hint.symbol for hint in candidate.ticker_hints]) diff --git a/quantmind/preprocess/news.py b/quantmind/preprocess/news.py index ac36346..12e7706 100644 --- a/quantmind/preprocess/news.py +++ b/quantmind/preprocess/news.py @@ -51,6 +51,11 @@ r"(?:\)|\b)", re.IGNORECASE, ) +_MARKDOWN_LINK_RE = re.compile(r"\[([^\]\n]+)\]\([^\)\n]*\)") +_MARKDOWN_EMPHASIS_RE = re.compile( + r"(? tuple[NewsTickerHint, ...]: Examples matched include ``(NASDAQ: NVDA)`` and ``NYSE: IBM``. The result is only a hint; downstream instrument resolution should still validate it. + Markdown link and emphasis decoration is removed from a scan-only copy so + stored news text and its content hash retain their original representation. """ + scan_text = _MARKDOWN_LINK_RE.sub(r"\1", text) + scan_text = _MARKDOWN_EMPHASIS_RE.sub(r"\1", scan_text) hints: list[NewsTickerHint] = [] seen: set[tuple[str, str | None]] = set() - for match in _EXCHANGE_TICKER_RE.finditer(text): + for match in _EXCHANGE_TICKER_RE.finditer(scan_text): raw_exchange = " ".join(match.group(1).upper().split()) exchange = _EXCHANGE_NAMES.get(raw_exchange, raw_exchange) symbol = match.group(2).upper() diff --git a/scripts/verify_news_e2e.py b/scripts/verify_news_e2e.py index 76a27e7..2b06066 100644 --- a/scripts/verify_news_e2e.py +++ b/scripts/verify_news_e2e.py @@ -2,28 +2,53 @@ """Run the bounded live-network checks for public news collection.""" import asyncio +import re from datetime import datetime, timedelta, timezone +from quantmind.preprocess import NewsDocument from quantmind.preprocess.fetch.http import FetchPolicy, HttpFetcher from quantmind.preprocess.fetch.rss import fetch_rss_feed -from quantmind.preprocess.pr_newswire import _discover_pr_newswire +from quantmind.preprocess.pr_newswire import ( + PRNewswireDiscovery, + _collect_observation, + _discover_pr_newswire, +) _PUBLIC_RSS_URL = "https://www.prnewswire.com/rss/news-releases-list.rss" _RSS_TIMEOUT_SECONDS = 60 _DISCOVERY_TIMEOUT_SECONDS = 300 +_ARTICLE_SAMPLE_TIMEOUT_SECONDS = 180 +_ARTICLE_SAMPLE_SIZE = 25 +_LIVE_FETCH_POLICY = FetchPolicy( + max_attempts=3, + backoff_base_seconds=0.5, + backoff_max_seconds=5.0, + jitter_seconds=0.2, + max_concurrency_per_host=2, + min_interval_seconds=0.25, +) + +# This oracle intentionally stays independent from the production extractor. +# It checks the first supported symbol after each explicit exchange prefix. It +# does not infer later list members or exchanges outside the current whitelist. +_CONTROL_EXCHANGE_TICKER_RE = re.compile( + r"(?:\(|\b)" + r"(?:NASDAQ|NYSE(?:\s+American|\s+MKT|\s+Arca)?|" + r"AMEX|OTCQX|OTCQB|OTC|CBOE)" + r"\s*:\s*" + r"(?:" + r"\[(?P[A-Z][A-Z0-9.-]{0,9})\]\([^\)\n]*\)" + r"|\*{1,2}(?P[A-Z][A-Z0-9.-]{0,9})\*{1,2}" + r"|(?P[A-Z][A-Z0-9.-]{0,9})" + r")", + re.IGNORECASE, +) async def _check_rss() -> bool: try: async with HttpFetcher( - policy=FetchPolicy( - max_attempts=3, - backoff_base_seconds=0.5, - backoff_max_seconds=5.0, - jitter_seconds=0.2, - max_concurrency_per_host=1, - min_interval_seconds=0.25, - ), + policy=_LIVE_FETCH_POLICY, timeout=30.0, max_bytes=5_000_000, ) as fetcher: @@ -48,7 +73,10 @@ async def _check_rss() -> bool: return passed -async def _check_discovery(start: datetime, end: datetime) -> bool: +async def _check_discovery( + start: datetime, + end: datetime, +) -> tuple[bool, PRNewswireDiscovery | None]: try: result = await asyncio.wait_for( _discover_pr_newswire(start=start, end=end), @@ -56,7 +84,7 @@ async def _check_discovery(start: datetime, end: datetime) -> bool: ) except Exception as exc: print(f"[FAIL] pr-newswire-discovery: {type(exc).__name__}: {exc}") - return False + return False, None urls = [observation.canonical_url for observation in result.observations] duplicate_count = len(urls) - len(set(urls)) @@ -82,6 +110,90 @@ async def _check_discovery(start: datetime, end: datetime) -> bool: print( f" {failure.stage} {failure.error_type} {failure.source_url}" ) + return passed, result if passed else None + + +def _ticker_hint_control_counts( + documents: tuple[NewsDocument, ...], +) -> tuple[int, int]: + expected_count = 0 + recovered_count = 0 + for document in documents: + expected_symbols = { + ( + match.group("link") + or match.group("emphasis") + or match.group("plain") + ).upper() + for match in _CONTROL_EXCHANGE_TICKER_RE.finditer( + document.cleaned_markdown + ) + } + actual_symbols = {hint.symbol for hint in document.ticker_hints} + expected_count += len(expected_symbols) + recovered_count += len(expected_symbols & actual_symbols) + return expected_count, recovered_count + + +async def _check_ticker_hints(discovery: PRNewswireDiscovery) -> bool: + sample = [] + seen_urls: set[str] = set() + for observation in discovery.observations: + if observation.canonical_url in seen_urls: + continue + sample.append(observation) + seen_urls.add(observation.canonical_url) + if len(sample) == _ARTICLE_SAMPLE_SIZE: + break + + if not sample: + print("[FAIL] pr-newswire-ticker-hints: no article URLs to sample") + return False + + try: + async with HttpFetcher( + policy=_LIVE_FETCH_POLICY, + timeout=30.0, + max_bytes=10_000_000, + ) as fetcher: + outcomes = await asyncio.wait_for( + asyncio.gather( + *( + _collect_observation( + observation, + fetcher=fetcher, + retain_raw_html=False, + ) + for observation in sample + ) + ), + timeout=_ARTICLE_SAMPLE_TIMEOUT_SECONDS, + ) + except Exception as exc: + print(f"[FAIL] pr-newswire-ticker-hints: {type(exc).__name__}: {exc}") + return False + + documents = tuple( + outcome for outcome in outcomes if isinstance(outcome, NewsDocument) + ) + expected_count, recovered_count = _ticker_hint_control_counts(documents) + article_failure_count = len(outcomes) - len(documents) + recall = recovered_count / expected_count if expected_count else 0.0 + if not documents: + passed = False + state = "FAIL" + elif not expected_count: + passed = True + state = "SKIP" + else: + passed = recall == 1.0 + state = "PASS" if passed else "FAIL" + print( + f"[{state}] pr-newswire-ticker-hints: sampled={len(sample)} " + f"parsed={len(documents)} failures={article_failure_count} " + f"expected={expected_count} recovered={recovered_count} " + f"recall={recall:.1%}" + ) return passed @@ -93,8 +205,13 @@ async def main(*, now: datetime | None = None) -> int: print(f"news window: [{start.isoformat()}, {end.isoformat()})") rss_passed = await _check_rss() - discovery_passed = await _check_discovery(start, end) - return 0 if rss_passed and discovery_passed else 1 + discovery_passed, discovery = await _check_discovery(start, end) + if discovery is None: + print("[FAIL] pr-newswire-ticker-hints: discovery unavailable") + ticker_hints_passed = False + else: + ticker_hints_passed = await _check_ticker_hints(discovery) + return 0 if all((rss_passed, discovery_passed, ticker_hints_passed)) else 1 if __name__ == "__main__": diff --git a/tests/preprocess/fixtures/pr_newswire/ticker_markup.md b/tests/preprocess/fixtures/pr_newswire/ticker_markup.md new file mode 100644 index 0000000..aba8403 --- /dev/null +++ b/tests/preprocess/fixtures/pr_newswire/ticker_markup.md @@ -0,0 +1,9 @@ +# Wire ticker markup regression fixture + +Carnival Corporation & plc (NYSE: [CCL](#financial-modal)) today announced an update. + +Insulet Corporation (NASDAQ: + +[PODD](#financial-modal)) today announced an update. + +Example Corporation (NASDAQ: **ABC**) today announced an update. diff --git a/tests/preprocess/test_news.py b/tests/preprocess/test_news.py index 6c04a8b..7a0b634 100644 --- a/tests/preprocess/test_news.py +++ b/tests/preprocess/test_news.py @@ -3,6 +3,7 @@ import hashlib import unittest from datetime import datetime, timedelta, timezone +from pathlib import Path import httpx import respx @@ -23,6 +24,8 @@ ) from quantmind.preprocess.time import parse_news_datetime +_FIXTURES = Path(__file__).parent / "fixtures" / "pr_newswire" + class NewsTimeTests(unittest.TestCase): def test_parse_rfc822_news_datetime(self): @@ -84,6 +87,36 @@ def test_exchange_ticker_hints_are_deduped(self): self.assertEqual(hints[0].exchange, "NASDAQ") self.assertEqual(hints[1].exchange, "NYSE") + def test_exchange_ticker_hints_ignore_markdown_decoration(self): + cases = ( + ( + "link", + "Carnival Corporation & plc " + "(NYSE: [CCL](#financial-modal)) today announced...", + ("CCL", "NYSE", "(NYSE: CCL)"), + ), + ( + "bold emphasis", + "Example Corporation (NASDAQ: **ABC**) today announced...", + ("ABC", "NASDAQ", "(NASDAQ: ABC)"), + ), + ( + "italic emphasis", + "Example Corporation (NASDAQ: *ABC*) today announced...", + ("ABC", "NASDAQ", "(NASDAQ: ABC)"), + ), + ) + + for name, text, expected in cases: + with self.subTest(name=name): + hints = extract_exchange_ticker_hints(text) + + self.assertEqual(len(hints), 1) + self.assertEqual( + (hints[0].symbol, hints[0].exchange, hints[0].raw), + expected, + ) + def test_build_sec_news_identity(self): self.assertEqual( build_sec_news_identity( @@ -137,6 +170,36 @@ def test_preprocess_news_document_rejects_empty_body(self): with self.assertRaises(ValueError): preprocess_news_document(raw) + def test_wire_markup_fixture_meets_ticker_recall_control(self): + body_text = (_FIXTURES / "ticker_markup.md").read_text(encoding="utf-8") + raw = RawNewsDocument( + body_text=body_text, + source_url="https://www.prnewswire.com/news-releases/example.html", + ) + + candidate = preprocess_news_document(raw) + + expected_hints = { + ("ABC", "NASDAQ"), + ("CCL", "NYSE"), + ("PODD", "NASDAQ"), + } + actual_hints = { + (hint.symbol, hint.exchange) for hint in candidate.ticker_hints + } + recall = len(expected_hints & actual_hints) / len(expected_hints) + self.assertEqual(recall, 1.0) + self.assertIn("[CCL](#financial-modal)", candidate.body_text) + self.assertIn( + "(NASDAQ:\n\n[PODD](#financial-modal))", + candidate.body_text, + ) + self.assertIn("**ABC**", candidate.body_text) + self.assertEqual( + candidate.content_hash, + news_content_hash(candidate.body_text), + ) + class NewsFeedItemTests(unittest.IsolatedAsyncioTestCase): async def test_feed_item_to_news_document_uses_inline_content(self): diff --git a/tests/test_verify_news_e2e.py b/tests/test_verify_news_e2e.py index ca5d684..1877313 100644 --- a/tests/test_verify_news_e2e.py +++ b/tests/test_verify_news_e2e.py @@ -5,6 +5,11 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, patch +from quantmind.preprocess import ( + NewsArtifact, + NewsDocument, + NewsTickerHint, +) from scripts import verify_news_e2e _PUBLISHED_AT = datetime(2026, 7, 14, tzinfo=timezone.utc) @@ -35,8 +40,31 @@ def _discovery_result( ) +def _document( + markdown: str, + *symbols: str, +) -> NewsDocument: + artifact = NewsArtifact( + bytes=None, + content_hash="hash", + content_type="text/html", + source_url="https://example.test/release", + resolved_url="https://example.test/release", + status_code=200, + ) + return NewsDocument( + source="pr-newswire", + identity="news:pr-newswire:test", + cleaned_markdown=markdown, + content_hash="hash", + discovery_artifact=artifact, + article_artifact=artifact, + ticker_hints=tuple(NewsTickerHint(symbol=symbol) for symbol in symbols), + ) + + class VerifyNewsE2ETests(unittest.IsolatedAsyncioTestCase): - async def test_main_reports_duplicates_and_passes_both_components(self): + async def test_main_reports_duplicates_and_passes_all_components(self): now = datetime(2026, 7, 14, 12, 30, tzinfo=timezone.utc) feed = SimpleNamespace(items=(_feed_item(), _feed_item(url=None))) discovery = _discovery_result( @@ -58,6 +86,11 @@ async def test_main_reports_duplicates_and_passes_both_components(self): "_discover_pr_newswire", new=AsyncMock(return_value=discovery), ) as discover, + patch.object( + verify_news_e2e, + "_check_ticker_hints", + new=AsyncMock(return_value=True), + ) as ticker_check, redirect_stdout(io.StringIO()) as output, ): exit_code = await verify_news_e2e.main(now=now) @@ -69,6 +102,7 @@ async def test_main_reports_duplicates_and_passes_both_components(self): start=now - timedelta(days=1), end=now, ) + ticker_check.assert_awaited_once_with(discovery) async def test_rss_failure_does_not_skip_discovery(self): discovery = _discovery_result() @@ -83,6 +117,11 @@ async def test_rss_failure_does_not_skip_discovery(self): "_discover_pr_newswire", new=AsyncMock(return_value=discovery), ) as discover, + patch.object( + verify_news_e2e, + "_check_ticker_hints", + new=AsyncMock(return_value=True), + ), redirect_stdout(io.StringIO()) as output, ): exit_code = await verify_news_e2e.main() @@ -134,8 +173,59 @@ async def test_discovery_rejects_invalid_results(self): ), redirect_stdout(io.StringIO()), ): - passed = await verify_news_e2e._check_discovery(start, end) + passed, returned = await verify_news_e2e._check_discovery( + start, end + ) self.assertFalse(passed) + self.assertIsNone(returned) + + def test_ticker_hint_control_counts_markup_independently(self): + documents = ( + _document( + "Carnival (NYSE: [CCL](#financial-modal)) and " + "NVIDIA (NASDAQ: NVDA).", + "CCL", + "NVDA", + ), + _document("Example (NASDAQ: **ABC**)."), + ) + + expected, recovered = verify_news_e2e._ticker_hint_control_counts( + documents + ) + + self.assertEqual(expected, 3) + self.assertEqual(recovered, 2) + + async def test_ticker_hint_check_enforces_full_sample_recall(self): + discovery = _discovery_result() + recovered = _document( + "Carnival (NYSE: [CCL](#financial-modal)).", + "CCL", + ) + missed = _document("Example (NASDAQ: **ABC**).") + no_ticker = _document("Example release with no ticker mention.") + + cases = ( + (recovered, True, "[PASS]"), + (missed, False, "[FAIL]"), + (no_ticker, True, "[SKIP]"), + ) + for document, expected, state in cases: + with self.subTest(expected=expected, state=state): + with ( + patch.object( + verify_news_e2e, + "_collect_observation", + new=AsyncMock(return_value=document), + ), + redirect_stdout(io.StringIO()) as output, + ): + passed = await verify_news_e2e._check_ticker_hints( + discovery + ) + self.assertIs(passed, expected) + self.assertIn(state, output.getvalue()) if __name__ == "__main__":