diff --git a/contexts/design/flow/news.md b/contexts/design/flow/news.md index 98766d5..fe6b852 100644 --- a/contexts/design/flow/news.md +++ b/contexts/design/flow/news.md @@ -230,10 +230,17 @@ three limited checks: 3. fetch up to 25 unique articles and find the first supported exchange-coded symbol in every group that has one (100% recall). +Ticker-hint extraction supports one exchange-qualified symbol and parenthesized +comma-separated symbol lists that share a supported exchange prefix. A shared +group ends at a semicolon, conjunction, or closing parenthesis. Additional list +members record a reconstructed `EXCHANGE: SYMBOL` raw value; unsupported +exchanges remain outside the whitelist policy. + The ticker check uses a separate comparison regex rather than the production extractor. It accepts plain, Markdown-link, and emphasis-wrapped symbols. It -ignores later symbols in a multi-symbol list and exchanges that the extractor -does not support. The script prints PASS/FAIL plus short listing, article, +recognizes comma-separated symbols that share a supported exchange prefix and +ignores exchanges that the extractor does not support. The script prints +PASS/FAIL plus short listing, article, failure, and recall summaries. It fails when RSS or listing data is invalid, no sampled article parses, or supported symbols fall below 100% recall. A parsed sample with no supported symbols reports `SKIP` and still passes. diff --git a/examples/preprocess/02_news_ticker_lists.py b/examples/preprocess/02_news_ticker_lists.py new file mode 100644 index 0000000..dfd7ddd --- /dev/null +++ b/examples/preprocess/02_news_ticker_lists.py @@ -0,0 +1,8 @@ +"""Extract ticker hints from a shared exchange-prefix list.""" + +from quantmind.preprocess import extract_exchange_ticker_hints + +text = "Example issuer (NYSE: EVEX, EVEXW; B3: EVEB31) announced results." + +for hint in extract_exchange_ticker_hints(text): + print(hint) diff --git a/quantmind/preprocess/news.py b/quantmind/preprocess/news.py index 12e7706..314c999 100644 --- a/quantmind/preprocess/news.py +++ b/quantmind/preprocess/news.py @@ -56,6 +56,10 @@ r"(? str: def extract_exchange_ticker_hints(text: str) -> tuple[NewsTickerHint, ...]: """Extract exchange-qualified ticker mentions from PR-style text. - Examples matched include ``(NASDAQ: NVDA)`` and ``NYSE: IBM``. The result is - only a hint; downstream instrument resolution should still validate it. + Examples matched include ``(NASDAQ: NVDA)``, ``NYSE: IBM``, and a + parenthesized comma list such as ``(NYSE: EVEX, EVEXW)``. A shared exchange + applies only to comma-separated symbols before the closing parenthesis; + semicolons and conjunctions end the group. Additional members use a + reconstructed ``EXCHANGE: SYMBOL`` raw value. 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. """ @@ -425,6 +433,29 @@ def extract_exchange_ticker_hints(text: str) -> tuple[NewsTickerHint, ...]: raw=match.group(0).strip(), ) ) + if not match.group(0).lstrip().startswith("("): + continue + + closing_parenthesis = scan_text.find(")", match.end()) + if closing_parenthesis == -1: + continue + suffix = scan_text[match.end() : closing_parenthesis] + position = 0 + while continuation := _SHARED_EXCHANGE_SYMBOL_RE.match( + suffix, position + ): + symbol = continuation.group(1).upper() + key = (symbol, exchange) + if key not in seen: + seen.add(key) + hints.append( + NewsTickerHint( + symbol=symbol, + exchange=exchange, + raw=f"{exchange}: {symbol}", + ) + ) + position = continuation.end() return tuple(hints) diff --git a/tests/preprocess/test_news.py b/tests/preprocess/test_news.py index 7a0b634..4f380ee 100644 --- a/tests/preprocess/test_news.py +++ b/tests/preprocess/test_news.py @@ -117,6 +117,45 @@ def test_exchange_ticker_hints_ignore_markdown_decoration(self): expected, ) + def test_exchange_ticker_hints_capture_shared_prefix_comma_lists(self): + cases = ( + ( + "supported shared prefix", + "(NYSE: EVEX, EVEXW; B3: EVEB31)", + ( + ("EVEX", "NYSE", "(NYSE: EVEX"), + ("EVEXW", "NYSE", "NYSE: EVEXW"), + ), + ), + ("unsupported exchange", "(OTCID: QVCAQ, QVCGQ, QVCPQ)", ()), + ( + "conjunction boundary", + "(NYSE: TME and HKEX: 1698)", + (("TME", "NYSE", "(NYSE: TME"),), + ), + ( + "semicolon boundary TSXV", + "(NASDAQ: VMAR; TSXV: VMAR)", + (("VMAR", "NASDAQ", "(NASDAQ: VMAR"),), + ), + ( + "semicolon boundary BMV", + "(NYSE: ASR; BMV: ASUR)", + (("ASR", "NYSE", "(NYSE: ASR"),), + ), + ) + + for name, text, expected in cases: + with self.subTest(name=name): + hints = extract_exchange_ticker_hints(text) + + self.assertEqual( + tuple( + (hint.symbol, hint.exchange, hint.raw) for hint in hints + ), + expected, + ) + def test_build_sec_news_identity(self): self.assertEqual( build_sec_news_identity(