diff --git a/dashboard/backend/integrations/news_sentiment.py b/dashboard/backend/integrations/news_sentiment.py index b2d9a9b..a106d58 100644 --- a/dashboard/backend/integrations/news_sentiment.py +++ b/dashboard/backend/integrations/news_sentiment.py @@ -236,7 +236,9 @@ def _story_fields(sig: dict) -> dict: def _project_entry(sig: dict, reference_ts: float) -> dict: return { "sentiment": sig["sentiment"], - "score": sig["score"], + # v2 sentiment_score with transitional v1 fallback — PR-2 of the + # FinSearch score-field disambiguation deletes the fallback. + "score": sig["sentiment_score"] if "sentiment_score" in sig else sig["score"], **_story_fields(sig), "n_articles": sig["n_articles"], "age_hours": max(0.0, (reference_ts - float(sig["published"])) / 3600.0), diff --git a/dashboard/backend/tests/test_news_sentiment_fixture.py b/dashboard/backend/tests/test_news_sentiment_fixture.py index bec510a..f3dbbd9 100644 --- a/dashboard/backend/tests/test_news_sentiment_fixture.py +++ b/dashboard/backend/tests/test_news_sentiment_fixture.py @@ -102,7 +102,7 @@ def test_items_fixture_is_the_batch_the_signals_fixture_came_from(): def test_fixture_matches_contract_essentials(): body = load_signals_fixture() - assert body["schema_version"] == 1 + assert body["schema_version"] in (1, 2) # transitional; PR-2 pins == 2 assert isinstance(body["signals"], dict) and body["signals"] sample = next(iter(body["signals"].values())) for field in ("sentiment", "score", "rationale", "headline", "source", diff --git a/dashboard/backend/tests/test_sentiment_score_fallback.py b/dashboard/backend/tests/test_sentiment_score_fallback.py new file mode 100644 index 0000000..c2dd734 --- /dev/null +++ b/dashboard/backend/tests/test_sentiment_score_fallback.py @@ -0,0 +1,36 @@ +"""PR-1 of the FinSearch score-field disambiguation (see FinSearch spec +2026-07-14-score-field-disambiguation-design.md): _project_entry must read +sentiment_score (signals v2) and fall back to score (v1) until PR-2 deletes +the fallback.""" +import pytest + +from dashboard.backend.integrations.news_sentiment import _project_entry + +BASE = {"sentiment": "bullish", "rationale": "r", "headline": "h", + "source": "Reuters", "url": "https://example.com/a", + "published": 1783330000.0, "guid": "g1", "n_articles": 2} + + +def test_project_entry_reads_sentiment_score_v2(): + entry = _project_entry({**BASE, "sentiment_score": 0.5}, + reference_ts=1783333600.0) + assert entry["score"] == 0.5 + + +def test_project_entry_falls_back_to_v1_score(): + entry = _project_entry({**BASE, "score": -0.3}, + reference_ts=1783333600.0) + assert entry["score"] == -0.3 + + +def test_project_entry_prefers_sentiment_score_when_both_present(): + entry = _project_entry({**BASE, "sentiment_score": 0.5, "score": -0.9}, + reference_ts=1783333600.0) + assert entry["score"] == 0.5 + + +def test_project_entry_raises_when_both_score_keys_absent(): + # Fail loud like every other required field: a payload with neither key is + # broken upstream, and score=None would flow silently into the panel. + with pytest.raises(KeyError): + _project_entry(dict(BASE), reference_ts=1783333600.0) diff --git a/docs/integrations/finsearch-news-sentiment.md b/docs/integrations/finsearch-news-sentiment.md index 9a834ee..4b4b398 100644 --- a/docs/integrations/finsearch-news-sentiment.md +++ b/docs/integrations/finsearch-news-sentiment.md @@ -36,6 +36,11 @@ No ATL-side contract change is needed — the `/api/v2` refactor shipped the who | `age_hours` | float | ≥ 0.0 | | `n_articles` | int | ≥ 0 | | `rationale` | str \| None | optional — see design note | + +> **Transitional:** until FinSearch's schema-v2 deploy lands, the producer +> still sends `score` (v1); the adapter reads `sentiment_score` with a `score` +> fallback. PR-2 removes the fallback and pins `schema_version == 2`. + - **Universe:** the loader is called with `list(DJIA_30)` (the canonical current Dow-30 constant, `infrastructure/llm/validator.py`, reconciled in #91/#94 — the old `AMEX` typo is gone). Pass that straight through as the `?tickers=` filter. --- @@ -82,7 +87,7 @@ Top level — the fields the **public, bearer-gated** response carries (consume ```json "MSFT": { "sentiment": "bullish", - "score": 0.5, + "sentiment_score": 0.5, "rationale": "Two distinct outlets report upbeat Azure guidance.", "headline": "Microsoft raises Azure guidance after record quarter", "source": "Reuters", @@ -104,7 +109,7 @@ The producer artifact and the consumer type were designed independently and **do | `NewsSentimentEntry` | ← source | transform | |----------------------|----------|-----------| | `sentiment` | `sentiment` | passthrough — the enums are identical (`bullish`/`bearish`/`neutral`) | -| `score` | `score` | passthrough (already −1…1) | +| `score` | `sentiment_score` | passthrough (already −1…1) | | `headline` | `headline` | passthrough | | `source` | `source` | passthrough | | `url` | `url` | passthrough | @@ -191,7 +196,7 @@ def get_news_sentiment(universe, timestamp): for ticker, s in body.get("signals", {}).items(): out[ticker] = { "sentiment": s["sentiment"], - "score": s["score"], + "score": s["sentiment_score"], "headline": s["headline"], "source": s["source"], "url": s["url"],