Skip to content

Commit 97bbbbd

Browse files
Zeeechennclaude
andcommitted
feat(sentiment): harden news layer -- market-flow filter, evidence floor, event-override off by default
Follow-up to the 2026-06-15 M27 sentiment IC diagnosis. The event_taxonomy 0.65 override was measured net-negative for IC across every variant tested (event delta -0.0027 / -0.0146 / -0.0280), and noisy non-company headlines drove individual-stock whipsaws (兆易 603986: 2 sector-IPO headlines, 0 company news -> -71.3 sentiment). Changes: - event_taxonomy: market/fund-flow/list headlines are filtered before classification so they cannot misfire as hard corporate events (for example, "下滑" inside "市场下滑"). Adds is_market_flow / is_company_specific / company_specific_titles helpers. - event_score: the 0.65 override blend is now gated by settings.sentiment_event_override_enabled (default OFF). Matched event types are still reported for observability; raw sentiment is kept. Re-enableable for a clean single-provider OOS re-test. - sentiment.analyze_news: evidence floor returns neutral 0 and skips the LLM when a window has no company-specific headline. Mixed windows now send only company-specific titles into the LLM prompt/cache/event scoring. - postmarket and backtest news-cache paths pass stock name+symbol aliases when available, so cross-company contamination filtering is not production-only. - m27_alpha_diagnostic --event-ab forces the override ON so it keeps measuring the override's counterfactual IC effect independent of the production default. Verification: - PYTHONPATH=. pytest -q tests/test_news_sentiment_pack.py tests/test_m27_alpha_event_universe.py tests/test_m27_m28_integration.py tests/test_tavily_news.py tests/test_sentiment_cache.py tests/test_llm_runtime_provider.py -> 46 passed / 1 skipped - make verify PYTHON=/Users/zeeechenn/mingcang/.venv/bin/python -> ruff clean, mypy clean, backend pytest 1224 passed / 6 skipped; local frontend step not runnable in this linked worktree because frontend node_modules is absent - GitHub Actions on PR #20 after force-push -> backend lint/typecheck, backend tests, frontend test/build, and security/dependency audit all passed for both push and pull_request runs This is a defensive change (removes a measured drag + adds robustness); it does not manufacture new sentiment alpha. Production adoption for live test2 signals should wait for a clean single-provider OOS confirmation (epoch reset). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 83a6c84 commit 97bbbbd

10 files changed

Lines changed: 361 additions & 22 deletions

backend/analysis/event_taxonomy.py

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,71 @@ class EventType:
2727
)
2828
EVENT_TAXONOMY = EVENT_TYPES
2929

30+
# Weight of the hard-event polarity in the event_override blend (rest goes to raw
31+
# LLM sentiment). Module constant so it can be tuned/measured via m27 --event-ab.
32+
EVENT_OVERRIDE_WEIGHT = 0.65
33+
34+
# Market/flow/list-roundup headlines that are NOT company-specific corporate events.
35+
# These must never trigger a strong event_override; they were empirically dragging
36+
# sentiment IC down (M27 diagnosis 2026-06-15: event delta -0.0146). Detected first
37+
# and excluded from hard-event classification so an incidental polarity keyword
38+
# (e.g. "下滑" inside "市场下滑") cannot misfire as an earnings_warning.
39+
MARKET_FLOW_KEYWORDS: tuple[str, ...] = (
40+
"资金流", "主力净", "净流入", "净流出", "大宗交易", "杠杆资金", "两融",
41+
"千股千评", "涨幅榜", "跌幅榜", "龙虎榜", "北向资金", "南向资金", "成交额",
42+
"特大单", "主力动向", "基金浮亏", "基金浮盈", "板块异动", "概念涨", "概念跌",
43+
)
44+
# Roundup / list headlines tagged loosely to many symbols ("XX股一览", "7只个股…").
45+
_LIST_HEADLINE_KEYWORDS: tuple[str, ...] = (
46+
"一览", "名单", "股大", "只个股", "多股", "股获", "股名单",
47+
)
48+
49+
50+
def is_market_flow(title: str) -> bool:
51+
"""True if a headline is market/fund-flow/list noise, not a company-specific event."""
52+
text = str(title)
53+
if any(k in text for k in MARKET_FLOW_KEYWORDS):
54+
return True
55+
return any(k in text for k in _LIST_HEADLINE_KEYWORDS)
56+
57+
58+
def is_company_specific(
59+
title: str, company_aliases: list[str] | tuple[str, ...] | None = None
60+
) -> bool:
61+
"""True if a headline plausibly carries company-specific signal.
62+
63+
Always rejects market/flow/list noise. When ``company_aliases`` (e.g. the stock
64+
name + code) are given, the headline must also mention one of them — this is what
65+
catches cross-company contamination (a 长鑫-IPO headline loosely tagged to 兆易).
66+
"""
67+
text = str(title)
68+
if is_market_flow(text):
69+
return False
70+
aliases = [str(a) for a in (company_aliases or []) if a]
71+
if aliases and not any(a in text for a in aliases):
72+
return False
73+
return True
74+
75+
76+
def company_specific_titles(
77+
titles: list[str] | tuple[str, ...],
78+
company_aliases: list[str] | tuple[str, ...] | None = None,
79+
) -> list[str]:
80+
"""Keep only headlines that are company-specific (see ``is_company_specific``)."""
81+
return [str(t) for t in titles if is_company_specific(t, company_aliases)]
82+
3083

3184
def classify_events(events: list[str] | tuple[str, ...]) -> list[dict]:
32-
"""Classify event strings into the local A-share taxonomy."""
85+
"""Classify event strings into the local A-share taxonomy.
86+
87+
Market/flow/list noise is skipped up front so it cannot be misclassified as a
88+
hard corporate event via an incidental keyword match.
89+
"""
3390
classified: list[dict] = []
3491
for event in events:
3592
text = str(event)
93+
if is_market_flow(text):
94+
continue
3695
for event_type in EVENT_TYPES:
3796
if any(keyword in text for keyword in event_type.keywords):
3897
classified.append({
@@ -69,32 +128,58 @@ def build_event_extraction_prompt(symbol: str | None, titles: list[str]) -> str:
69128
)
70129

71130

72-
def event_score(sentiment: float, events: list[str] | tuple[str, ...]) -> dict:
73-
"""Return an event-aware score in [-1, 1], falling back to sentiment when no event matches."""
131+
def event_score(
132+
sentiment: float,
133+
events: list[str] | tuple[str, ...],
134+
*,
135+
enable_override: bool | None = None,
136+
) -> dict:
137+
"""Return an event-aware score in [-1, 1].
138+
139+
The event_override blend is gated by ``enable_override`` (defaults to
140+
``settings.sentiment_event_override_enabled``, which is OFF). When disabled, the
141+
raw sentiment is kept (mode ``sentiment_fallback``) but matched event types are
142+
still reported for observability. The override was measured net-negative for IC
143+
(M27 diagnosis 2026-06-15) — keep it off unless a clean OOS re-test earns it back.
144+
"""
74145
clipped_sentiment = max(-1.0, min(1.0, float(sentiment or 0.0)))
75146
classified = classify_events(events)
76-
if not classified:
147+
if enable_override is None:
148+
try:
149+
from backend.config import settings
150+
151+
enable_override = bool(settings.sentiment_event_override_enabled)
152+
except Exception:
153+
enable_override = False
154+
if not classified or not enable_override:
77155
return {
78156
"event_score": clipped_sentiment,
79157
"event_score_mode": "sentiment_fallback",
80-
"event_types": [],
158+
"event_types": classified,
81159
}
82160

83161
polarity_avg = sum(item["polarity"] for item in classified) / len(classified)
84-
score = 0.65 * polarity_avg + 0.35 * clipped_sentiment
162+
score = EVENT_OVERRIDE_WEIGHT * polarity_avg + (1.0 - EVENT_OVERRIDE_WEIGHT) * clipped_sentiment
85163
return {
86164
"event_score": round(max(-1.0, min(1.0, score)), 4),
87165
"event_score_mode": "event_override",
88166
"event_types": classified,
89167
}
90168

91169

92-
def apply_event_score(sentiment_result: dict[str, Any], titles: list[str] | None = None) -> dict[str, Any]:
170+
def apply_event_score(
171+
sentiment_result: dict[str, Any],
172+
titles: list[str] | None = None,
173+
*,
174+
enable_override: bool | None = None,
175+
) -> dict[str, Any]:
93176
"""Add event-aware scoring to an analyze_news-style result."""
94177
out = dict(sentiment_result)
95178
event_texts: list[str] = []
96179
if titles:
97180
event_texts.extend(titles)
98181
event_texts.extend(str(item) for item in out.get("key_events", []) or [])
99-
out.update(event_score(float(out.get("sentiment") or 0.0), event_texts))
182+
out.update(
183+
event_score(float(out.get("sentiment") or 0.0), event_texts, enable_override=enable_override)
184+
)
100185
return out

backend/analysis/sentiment.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections import OrderedDict
55
from datetime import UTC, datetime
66

7-
from backend.analysis.event_taxonomy import apply_event_score
7+
from backend.analysis.event_taxonomy import apply_event_score, company_specific_titles
88
from backend.config import settings
99
from backend.llm import get_provider, has_runtime_llm_provider
1010

@@ -57,6 +57,20 @@
5757
"event_score_mode": "sentiment_fallback",
5858
"event_types": [],
5959
}
60+
# Evidence floor: when a symbol's window has no company-specific headline (only
61+
# sector/fund-flow/list noise), neutralise sentiment instead of scoring noise.
62+
# Prevents the M27 whipsaw (e.g. 兆易 603986: 2 sector-IPO headlines, 0 company
63+
# news → -71.3 sentiment). Skips the LLM call entirely, so it is also cheaper.
64+
_NOISE_ONLY_FALLBACK = {
65+
"sentiment": 0.0,
66+
"summary": "仅板块/资金流新闻,无个股特定信号,情感置中",
67+
"impact": "short",
68+
"key_events": [],
69+
"low_confidence": True,
70+
"event_score": 0.0,
71+
"event_score_mode": "evidence_floor",
72+
"event_types": [],
73+
}
6074

6175

6276
def _titles_hash(titles: list[str]) -> str:
@@ -158,28 +172,37 @@ def _persistent_cache_set(key: str, titles_hash: str, symbol: str | None, value:
158172
return
159173

160174

161-
def analyze_news(titles: list[str], symbol: str | None = None) -> dict:
175+
def analyze_news(
176+
titles: list[str],
177+
symbol: str | None = None,
178+
company_aliases: list[str] | None = None,
179+
) -> dict:
162180
"""
163181
输入新闻标题列表,返回情感分析结果。
164182
{sentiment: float, summary: str, impact: str, key_events: list}
165183
相同股票+标题集合优先命中进程内和 SQLite 缓存,避免重复消耗 LLM 配额。
184+
company_aliases(股票名/代码等别名)用于个股相关性过滤与证据地板;不传则只过滤行情/资金流噪声。
166185
"""
167186
if not titles:
168187
return apply_event_score(_FALLBACK, [])
169188
if not has_runtime_llm_provider(settings):
170189
return apply_event_score(_DISABLED_FALLBACK, titles)
190+
# Evidence floor: no company-specific headline (only noise / cross-company) → neutral, skip LLM.
191+
analysis_titles = company_specific_titles(titles, company_aliases)
192+
if not analysis_titles:
193+
return dict(_NOISE_ONLY_FALLBACK)
171194

172-
cache_key, titles_hash = _cache_key(titles, symbol)
195+
cache_key, titles_hash = _cache_key(analysis_titles, symbol)
173196
cached = _cache_get(cache_key)
174197
if cached is not None:
175-
return apply_event_score(cached, titles)
198+
return apply_event_score(cached, analysis_titles)
176199
cached = _persistent_cache_get(cache_key)
177200
if cached is not None:
178201
_cache_set(cache_key, cached)
179-
return apply_event_score(cached, titles)
202+
return apply_event_score(cached, analysis_titles)
180203

181204
context = f"股票代码:{symbol}\n" if symbol else ""
182-
prompt = context + "新闻标题:\n" + "\n".join(f"- {t}" for t in titles[:15])
205+
prompt = context + "新闻标题:\n" + "\n".join(f"- {t}" for t in analysis_titles[:15])
183206

184207
data = get_provider().complete_structured(
185208
prompt=prompt,
@@ -205,11 +228,11 @@ def analyze_news(titles: list[str], symbol: str | None = None) -> dict:
205228
"event_score": 0.0,
206229
"event_score_mode": "sentiment_fallback",
207230
"event_types": [],
208-
}, titles)
231+
}, analysis_titles)
209232

210233
data["sentiment"] = max(-1.0, min(1.0, float(data.get("sentiment", 0))))
211234
data["key_events"] = data.get("key_events", [])[:3]
212-
data = apply_event_score(data, titles)
235+
data = apply_event_score(data, analysis_titles)
213236
_cache_set(cache_key, data)
214237
_persistent_cache_set(cache_key, titles_hash, symbol, data)
215238
return dict(data)

backend/backtest/news_cache.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ def _fetch_titles(symbol: str, date: str, db, lookback_days: int = 3) -> list[st
6969
return [r[0] for r in rows if r[0]]
7070

7171

72+
def _company_aliases_for_symbol(symbol: str, db) -> list[str] | None:
73+
"""Return name+symbol aliases when Stock metadata is available."""
74+
from backend.data.database import Stock
75+
76+
stock = db.query(Stock).filter(Stock.symbol == symbol).first()
77+
if stock is None or not stock.name:
78+
return None
79+
return [stock.name, symbol]
80+
81+
7282
def get_or_backfill(
7383
symbol: str,
7484
date: str,
@@ -105,7 +115,11 @@ def get_or_backfill(
105115
}
106116
else:
107117
from backend.analysis.sentiment import analyze_news
108-
result = analyze_news(titles, symbol=symbol)
118+
result = analyze_news(
119+
titles,
120+
symbol=symbol,
121+
company_aliases=_company_aliases_for_symbol(symbol, db),
122+
)
109123

110124
# 写回缓存(即使是空结果也缓存,避免每次都重查 NewsItem)
111125
cache[key] = result
@@ -144,7 +158,11 @@ def backfill_all(
144158

145159
try:
146160
from backend.analysis.sentiment import analyze_news
147-
result = analyze_news(titles, symbol=s.symbol)
161+
result = analyze_news(
162+
titles,
163+
symbol=s.symbol,
164+
company_aliases=_company_aliases_for_symbol(s.symbol, db),
165+
)
148166
if not result.get("key_events") and result.get("summary") == "解析失败":
149167
stats["llm_fail"] += 1
150168
cache[key] = result

backend/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ class Settings(BaseSettings):
106106
weight_technical: float = 0.6
107107
weight_sentiment: float = 0.4
108108
new_framework_entry_threshold: float = 25.0
109+
# Event-taxonomy override (0.65×事件极性 blend). Default OFF: the M27 sentiment IC
110+
# diagnosis (2026-06-15) measured event delta consistently negative across every
111+
# variant tested (-0.0027 / -0.0146 / -0.0280), i.e. the override only subtracts IC.
112+
# Kept behind a flag so it can be re-enabled for a clean single-provider OOS re-test.
113+
sentiment_event_override_enabled: bool = False
109114

110115
# Signal profile: legacy Qlib framework or current new framework.
111116
paper_trading_profile: str = "auto" # auto / test1_legacy_qlib / new_framework

backend/jobs/postmarket.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ def _postmarket_news_sentiment(stock, db) -> dict:
148148
logger.info("Tavily补充 %s: +%d条 (DB=%d条)",
149149
stock.symbol, len(tavily_titles), len(titles) - len(tavily_titles))
150150

151-
sentiment_result = analyze_news(titles, symbol=stock.symbol)
151+
company_aliases = [a for a in (stock.name, stock.symbol) if a]
152+
sentiment_result = analyze_news(titles, symbol=stock.symbol, company_aliases=company_aliases)
152153
sentiment_result["news_audit"] = [
153154
{
154155
"title": audit.title,

backend/tools/m27_alpha_diagnostic.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,9 @@ def _event_ab_row_frame(
598598
polarity_score = sentiment["polarity_score"]
599599
polarity_source = sentiment["polarity_source"]
600600
polarity_available = polarity_score is not None
601-
adjusted = event_score(polarity_score or 0.0, titles)
601+
# Force the override ON here: this A/B harness exists to measure the override's
602+
# counterfactual IC effect, independent of the production default (which is OFF).
603+
adjusted = event_score(polarity_score or 0.0, titles, enable_override=True)
602604
event_available = polarity_available or adjusted["event_score_mode"] == "event_override"
603605

604606
ab_rows.append({

tests/test_m27_alpha_event_universe.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@ def test_event_taxonomy_scores_events_and_falls_back_to_polarity():
3232
positive = apply_event_score(
3333
{"sentiment": -0.1, "key_events": []},
3434
["公司公告获得重大合同并中标算力项目"],
35+
enable_override=True, # override is opt-in (default OFF per M27 IC diagnosis)
36+
)
37+
neutral = apply_event_score(
38+
{"sentiment": 0.2, "key_events": []}, ["普通经营动态"], enable_override=True
3539
)
36-
neutral = apply_event_score({"sentiment": 0.2, "key_events": []}, ["普通经营动态"])
3740

3841
assert positive["event_score_mode"] == "event_override"
3942
assert positive["event_score"] > 0

tests/test_m27_m28_integration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def test_event_taxonomy_overrides_sentiment_when_event_matches():
3030
result = apply_event_score(
3131
{"sentiment": -0.2, "key_events": ["公司获得重大合同订单"]},
3232
[],
33+
enable_override=True, # override is opt-in (default OFF per M27 IC diagnosis)
3334
)
3435

3536
assert result["event_score_mode"] == "event_override"

0 commit comments

Comments
 (0)