From fd416328009e41629c094cd2610439991a1f45d7 Mon Sep 17 00:00:00 2001 From: PP1 <74917296+pengpengyi92@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:02:30 +0800 Subject: [PATCH 1/2] fix(preprocess): bound shared exchange ticker lists --- quantmind/preprocess/news.py | 34 +++++++++++++++++++++-- tests/preprocess/test_news.py | 52 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/quantmind/preprocess/news.py b/quantmind/preprocess/news.py index 12e7706..428268f 100644 --- a/quantmind/preprocess/news.py +++ b/quantmind/preprocess/news.py @@ -56,6 +56,9 @@ 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)``. 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 +429,32 @@ def extract_exchange_ticker_hints(text: str) -> tuple[NewsTickerHint, ...]: raw=match.group(0).strip(), ) ) + matched_text = match.group(0) + if not matched_text.lstrip().startswith( + "(" + ) or matched_text.rstrip().endswith(")"): + 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=continuation.group(0).strip(), + ) + ) + position = continuation.end() return tuple(hints) diff --git a/tests/preprocess/test_news.py b/tests/preprocess/test_news.py index 7a0b634..d8745f0 100644 --- a/tests/preprocess/test_news.py +++ b/tests/preprocess/test_news.py @@ -117,6 +117,58 @@ def test_exchange_ticker_hints_ignore_markdown_decoration(self): expected, ) + def test_exchange_ticker_hints_capture_shared_prefix_comma_list(self): + hints = extract_exchange_ticker_hints( + "Example issuer (NYSE: EVEX, EVEXW) announced results." + ) + + self.assertEqual( + tuple((hint.symbol, hint.exchange, hint.raw) for hint in hints), + ( + ("EVEX", "NYSE", "(NYSE: EVEX"), + ("EVEXW", "NYSE", ", EVEXW"), + ), + ) + + def test_exchange_ticker_hints_stop_at_shared_prefix_boundaries(self): + cases = ( + ( + "balanced mention followed by prose", + "Shares of (NYSE: IBM), and (NASDAQ: AAPL) rose today.", + ( + ("IBM", "NYSE", "(NYSE: IBM)"), + ("AAPL", "NASDAQ", "(NASDAQ: AAPL)"), + ), + ), + ("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( From 44baba1f53f4d20bb52d5f59f268234b1627207d Mon Sep 17 00:00:00 2001 From: PP1 <74917296+pengpengyi92@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:10:00 +0800 Subject: [PATCH 2/2] fix(preprocess): preserve shared-list provenance --- quantmind/preprocess/news.py | 61 +++++++++++++++++++---------------- tests/preprocess/test_news.py | 53 ++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/quantmind/preprocess/news.py b/quantmind/preprocess/news.py index 428268f..5fa1abb 100644 --- a/quantmind/preprocess/news.py +++ b/quantmind/preprocess/news.py @@ -56,9 +56,11 @@ r"(?(?:\s*,\s*[A-Z][A-Z0-9.-]{0,9})+)" + r"(?=\s*(?:\)|;\s*[A-Z][A-Z0-9 .-]*\s*:))", ) +_SHARED_EXCHANGE_SYMBOL_RE = re.compile(r",\s*([A-Z][A-Z0-9.-]{0,9})") _EMAIL_PROTECTION_LINK_RE = re.compile( r"\[\[email protected]\]\(/cdn-cgi/l/email-protection#[^)]+\)" ) @@ -418,31 +420,37 @@ def extract_exchange_ticker_hints(text: str) -> tuple[NewsTickerHint, ...]: raw_exchange = " ".join(match.group(1).upper().split()) exchange = _EXCHANGE_NAMES.get(raw_exchange, raw_exchange) symbol = match.group(2).upper() - key = (symbol, exchange) - if key in seen: - continue - seen.add(key) - hints.append( - NewsTickerHint( - symbol=symbol, - exchange=exchange, - raw=match.group(0).strip(), - ) - ) + continuation_matches: list[re.Match[str]] = [] + shared_group_raw: str | None = None matched_text = match.group(0) - if not matched_text.lstrip().startswith( + if matched_text.lstrip().startswith( "(" - ) or matched_text.rstrip().endswith(")"): - 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 - ): + ) and not matched_text.rstrip().endswith(")"): + closing_parenthesis = scan_text.find(")", match.end()) + if closing_parenthesis != -1: + suffix = scan_text[match.end() : closing_parenthesis + 1] + shared_group = _SHARED_EXCHANGE_GROUP_RE.match(suffix) + if shared_group: + continuation_matches = list( + _SHARED_EXCHANGE_SYMBOL_RE.finditer( + shared_group.group("members") + ) + ) + shared_group_raw = scan_text[ + match.start() : closing_parenthesis + 1 + ].strip() + + key = (symbol, exchange) + if key not in seen: + seen.add(key) + hints.append( + NewsTickerHint( + symbol=symbol, + exchange=exchange, + raw=shared_group_raw or matched_text.strip(), + ) + ) + for continuation in continuation_matches: symbol = continuation.group(1).upper() key = (symbol, exchange) if key not in seen: @@ -451,10 +459,9 @@ def extract_exchange_ticker_hints(text: str) -> tuple[NewsTickerHint, ...]: NewsTickerHint( symbol=symbol, exchange=exchange, - raw=continuation.group(0).strip(), + raw=shared_group_raw, ) ) - position = continuation.end() return tuple(hints) diff --git a/tests/preprocess/test_news.py b/tests/preprocess/test_news.py index d8745f0..576ef11 100644 --- a/tests/preprocess/test_news.py +++ b/tests/preprocess/test_news.py @@ -125,8 +125,42 @@ def test_exchange_ticker_hints_capture_shared_prefix_comma_list(self): self.assertEqual( tuple((hint.symbol, hint.exchange, hint.raw) for hint in hints), ( - ("EVEX", "NYSE", "(NYSE: EVEX"), - ("EVEXW", "NYSE", ", EVEXW"), + ("EVEX", "NYSE", "(NYSE: EVEX, EVEXW)"), + ("EVEXW", "NYSE", "(NYSE: EVEX, EVEXW)"), + ), + ) + + def test_exchange_ticker_hints_stop_shared_list_at_semicolon(self): + hints = extract_exchange_ticker_hints( + "Example issuer (NYSE: EVEX, EVEXW; B3: EVEB31) announced results." + ) + + self.assertEqual( + tuple((hint.symbol, hint.exchange, hint.raw) for hint in hints), + ( + ( + "EVEX", + "NYSE", + "(NYSE: EVEX, EVEXW; B3: EVEB31)", + ), + ( + "EVEXW", + "NYSE", + "(NYSE: EVEX, EVEXW; B3: EVEB31)", + ), + ), + ) + + def test_exchange_ticker_hints_capture_new_shared_list_members(self): + hints = extract_exchange_ticker_hints( + "Prior (NYSE: EVEX). Offering (NYSE: EVEX, EVEXW)." + ) + + self.assertEqual( + tuple((hint.symbol, hint.exchange, hint.raw) for hint in hints), + ( + ("EVEX", "NYSE", "(NYSE: EVEX)"), + ("EVEXW", "NYSE", "(NYSE: EVEX, EVEXW)"), ), ) @@ -156,6 +190,21 @@ def test_exchange_ticker_hints_stop_at_shared_prefix_boundaries(self): "(NYSE: ASR; BMV: ASUR)", (("ASR", "NYSE", "(NYSE: ASR"),), ), + ( + "ordinary prose inside parentheses", + "(NYSE: IBM, and revenue increased)", + (("IBM", "NYSE", "(NYSE: IBM"),), + ), + ( + "uppercase token before ordinary prose", + "(NYSE: IBM, ABC and revenue increased)", + (("IBM", "NYSE", "(NYSE: IBM"),), + ), + ( + "uppercase token before non-exchange semicolon", + "(NYSE: IBM, ABC; revenue increased)", + (("IBM", "NYSE", "(NYSE: IBM"),), + ), ) for name, text, expected in cases: