Skip to content

Commit 589600f

Browse files
authored
Merge pull request #117 from Open-Finance-Lab/chore/news-signals-v2-cutover
chore(news): complete signals v2 cutover
2 parents dee5b25 + 7204a7c commit 589600f

15 files changed

Lines changed: 208 additions & 102 deletions

dashboard/backend/integrations/news_sentiment.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,13 @@ def _story_fields(sig: dict) -> dict:
236236
def _project_entry(sig: dict, reference_ts: float) -> dict:
237237
return {
238238
"sentiment": sig["sentiment"],
239-
# v2 sentiment_score with transitional v1 fallback — PR-2 of the
240-
# FinSearch score-field disambiguation deletes the fallback.
241-
"score": sig["sentiment_score"] if "sentiment_score" in sig else sig["score"],
239+
# Reads the wire's v2 key; emits the INTERNAL key `score`, which
240+
# api/v2/models.py's NewsSentimentEntry validates. The two vocabularies
241+
# are decoupled by design — this is not a half-finished rename.
242+
# No v1 fallback: signals v2 is a hard rename and FinSearch normalizes
243+
# at its boundary, so `score` cannot reach us. A KeyError here means the
244+
# producer contract broke and should be seen, not smoothed over.
245+
"score": sig["sentiment_score"],
242246
**_story_fields(sig),
243247
"n_articles": sig["n_articles"],
244248
"age_hours": max(0.0, (reference_ts - float(sig["published"])) / 3600.0),

dashboard/backend/tests/fixtures/items-wire-fixture.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"schema_version": 1,
2+
"schema_version": 2,
33
"batch": "items-fixture.jsonl",
44
"count": 6,
55
"items": [
@@ -11,7 +11,7 @@
1111
"published": 1783339200.0,
1212
"description": "Nvidia said datacenter bookings set a quarterly record.",
1313
"tickers": ["NVDA"],
14-
"score": 0.7
14+
"editorial_score": 0.7
1515
},
1616
{
1717
"guid": "fix-msft-1",
@@ -21,7 +21,7 @@
2121
"published": 1783335600.0,
2222
"description": "Azure growth guidance was revised upward.",
2323
"tickers": ["MSFT"],
24-
"score": 0.5
24+
"editorial_score": 0.5
2525
},
2626
{
2727
"guid": "fix-msft-2",
@@ -31,7 +31,7 @@
3131
"published": 1783334000.0,
3232
"description": "Second outlet on the same Azure guidance raise.",
3333
"tickers": ["MSFT"],
34-
"score": 0.45
34+
"editorial_score": 0.45
3535
},
3636
{
3737
"guid": "fix-aapl-1",
@@ -41,7 +41,7 @@
4141
"published": 1783332000.0,
4242
"description": "Store opening; no financial subject matter.",
4343
"tickers": ["AAPL"],
44-
"score": 0.05
44+
"editorial_score": 0.05
4545
},
4646
{
4747
"guid": "fix-googl-1",
@@ -51,7 +51,7 @@
5151
"published": 1783330000.0,
5252
"description": "Personnel move; no financial subject matter.",
5353
"tickers": ["GOOGL"],
54-
"score": 0.03
54+
"editorial_score": 0.03
5555
},
5656
{
5757
"guid": "fix-mkt-1",
@@ -61,7 +61,7 @@
6161
"published": 1783328400.0,
6262
"description": "General-market story: carries no ticker at all.",
6363
"tickers": [],
64-
"score": 0.1
64+
"editorial_score": 0.1
6565
}
6666
]
6767
}

dashboard/backend/tests/fixtures/signals-fixture.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
2-
"schema_version": 1,
2+
"schema_version": 2,
33
"profile": "default",
44
"generated_at": "2026-07-06T15:00:00+00:00",
5-
"generator": "news_signals.py/2026-07-11.2",
5+
"generator": "news_signals.py/2026-07-14.2",
66
"model": "gpt-4o-mini",
77
"prompt_version": 1,
88
"source_items": "items-fixture.jsonl",
@@ -31,7 +31,7 @@
3131
"signals": {
3232
"MSFT": {
3333
"sentiment": "bullish",
34-
"score": 0.5,
34+
"sentiment_score": 0.5,
3535
"rationale": "Two distinct outlets report upbeat Azure guidance.",
3636
"headline": "Microsoft raises Azure guidance after record quarter",
3737
"source": "Reuters",
@@ -42,7 +42,7 @@
4242
},
4343
"NVDA": {
4444
"sentiment": "bullish",
45-
"score": 0.7,
45+
"sentiment_score": 0.7,
4646
"rationale": "Single story reports record datacenter orders.",
4747
"headline": "Nvidia reports record datacenter orders",
4848
"source": "CNBC",

dashboard/backend/tests/fixtures/signals-v1.schema.json renamed to dashboard/backend/tests/fixtures/signals-v2.schema.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "http://json-schema.org/draft-07/schema#",
3-
"$id": "signals-v1.schema.json",
4-
"title": "FinSearch news signals artifact v1 (spec 2026-07-06 §4.2, amended)",
3+
"$id": "signals-v2.schema.json",
4+
"title": "FinSearch news signals artifact v2 (2026-07-14 score-field disambiguation)",
55
"type": "object",
66
"additionalProperties": false,
77
"required": [
@@ -10,7 +10,7 @@
1010
"status", "status_reason", "news_overview", "diagnostics", "signals"
1111
],
1212
"properties": {
13-
"schema_version": { "const": 1 },
13+
"schema_version": { "const": 2 },
1414
"profile": { "type": "string" },
1515
"generated_at": { "type": "string", "format": "date-time" },
1616
"generator": { "type": "string" },
@@ -55,12 +55,12 @@
5555
"type": "object",
5656
"additionalProperties": false,
5757
"required": [
58-
"sentiment", "score", "rationale", "headline", "source", "url",
58+
"sentiment", "sentiment_score", "rationale", "headline", "source", "url",
5959
"published", "guid", "n_articles"
6060
],
6161
"properties": {
6262
"sentiment": { "enum": ["bullish", "bearish", "neutral"] },
63-
"score": { "type": "number", "minimum": -1, "maximum": 1 },
63+
"sentiment_score": { "type": "number", "minimum": -1, "maximum": 1 },
6464
"rationale": { "type": "string", "maxLength": 280 },
6565
"headline": { "type": "string", "maxLength": 500 },
6666
"source": { "type": "string", "maxLength": 200 },

dashboard/backend/tests/fixtures/signals-wire-fixture.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"schema_version": 1,
2+
"schema_version": 2,
33
"profile": "default",
44
"generated_at": "2026-07-06T15:00:00+00:00",
55
"source_items": "items-fixture.jsonl",
@@ -28,7 +28,7 @@
2828
"signals": {
2929
"MSFT": {
3030
"sentiment": "bullish",
31-
"score": 0.5,
31+
"sentiment_score": 0.5,
3232
"rationale": "Two distinct outlets report upbeat Azure guidance.",
3333
"headline": "Microsoft raises Azure guidance after record quarter",
3434
"source": "Reuters",
@@ -39,7 +39,7 @@
3939
},
4040
"NVDA": {
4141
"sentiment": "bullish",
42-
"score": 0.7,
42+
"sentiment_score": 0.7,
4343
"rationale": "Single story reports record datacenter orders.",
4444
"headline": "Nvidia reports record datacenter orders",
4545
"source": "CNBC",

dashboard/backend/tests/test_news_sentiment_adapter.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ def test_malformed_panel_signal_dropped_not_whole_panel(monkeypatch):
172172
"""One signal missing a required story field is dropped from the feed
173173
(logged), not allowed to collapse the entire panel to unavailable."""
174174
body = load_signals_fixture()
175-
body["signals"]["BADD"] = {"sentiment": "bullish", "score": 0.1} # no headline/url
175+
# Off-spec in exactly one way — no headline/url — so the drop is
176+
# attributable to the missing story field. Keep sentiment_score valid:
177+
# omitting it too would drop the entry for a second, unrelated reason and
178+
# this test would pass without proving what it claims.
179+
body["signals"]["BADD"] = {"sentiment": "bullish", "sentiment_score": 0.1}
176180
monkeypatch.setattr(ns, "_http_get", lambda **kw: _fake_response(body=body))
177181
payload = ns.get_latest_panel_payload(["MSFT", "NVDA", "BADD"])
178182
assert payload["status"] != "unavailable" # panel survived
@@ -329,7 +333,7 @@ def test_304_revalidation_serves_cached_body(monkeypatch):
329333

330334

331335
def _items_body(items, batch="items-test.jsonl"):
332-
return {"schema_version": 1, "items": items, "count": len(items), "batch": batch}
336+
return {"schema_version": 2, "items": items, "count": len(items), "batch": batch}
333337

334338

335339
def test_items_feed_preferred_and_mapped_correctly(monkeypatch):
@@ -580,7 +584,8 @@ def _fetch_items_must_not_be_called(**kw):
580584
def test_feed_from_items_maps_exact_five_keys():
581585
items = [
582586
{"guid": "g1", "headline": "Fed cuts rates", "url": "https://x/1", "source": "Reuters",
583-
"published": 1700000200.0, "description": "d1", "tickers": ["AAPL", "MSFT"], "score": 0.5},
587+
"published": 1700000200.0, "description": "d1", "tickers": ["AAPL", "MSFT"],
588+
"editorial_score": 0.5},
584589
]
585590
feed = ns._feed_from_items(items)
586591
assert feed == [{

dashboard/backend/tests/test_news_sentiment_fixture.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22
from pathlib import Path
33

4+
import jsonschema
5+
46
FIXTURES = Path(__file__).parent / "fixtures"
57

68

@@ -21,7 +23,7 @@ def load_signals_wire_fixture() -> dict:
2123

2224

2325
def load_items_wire_fixture() -> dict:
24-
"""The `GET /api/news/items/` response shape (news-story v1), the single
26+
"""The `GET /api/news/items/` response shape (news-story v2), the single
2527
recorded record of what the adapter's items path parses — see
2628
docs/integrations/finsearch-news-items.md.
2729
@@ -40,19 +42,19 @@ def load_items_wire_fixture() -> dict:
4042
return json.loads((FIXTURES / "items-wire-fixture.json").read_text())
4143

4244

43-
# Every key `GET /api/news/items/` puts on a story, per the news-story v1 table
45+
# Every key `GET /api/news/items/` puts on a story, per the news-story v2 table
4446
# in docs/integrations/finsearch-news-items.md. The first five are the shared
45-
# vocabulary; guid/description/score are the items-only extras.
47+
# vocabulary; guid/description/editorial_score are the items-only extras.
4648
ITEMS_STORY_KEYS = {"headline", "url", "source", "published", "tickers",
47-
"guid", "description", "score"}
49+
"guid", "description", "editorial_score"}
4850

4951

5052
def test_items_wire_fixture_matches_contract_essentials():
5153
"""Pins the items fixture to the documented contract, so a future producer
5254
rename has to change this file — and be seen in review — rather than being
5355
absorbed silently into whichever test dict happened to mention the field."""
5456
body = load_items_wire_fixture()
55-
assert body["schema_version"] == 1
57+
assert body["schema_version"] == 2
5658
assert set(body) == {"schema_version", "items", "count", "batch"}
5759
items = body["items"]
5860
assert isinstance(items, list) and items
@@ -72,10 +74,15 @@ def test_items_wire_fixture_does_not_speak_the_retired_vocabulary():
7274
"""Regression guard on the 2026-07-14 incident: `title`/`link` are the
7375
on-disk RSS-native names and must never reappear on the wire fixture — the
7476
rename happens at AF's boundary, so a consumer that sees them is looking at
75-
a pre-v1 shape."""
77+
a pre-v1 shape. Bare `score` is retired the same way (news-story v2 renamed
78+
it `editorial_score`), but note it is retired for a stricter reason: the
79+
items endpoint has no boundary normalizer, so `editorial_score` sits in the
80+
producer's REQUIRED_FIELDS and a pre-rename batch trips the batch-level
81+
poison pill and 404s rather than being served as v1."""
7682
for item in load_items_wire_fixture()["items"]:
7783
assert "title" not in item
7884
assert "link" not in item
85+
assert "score" not in item
7986

8087

8188
def test_items_fixture_is_the_batch_the_signals_fixture_came_from():
@@ -102,14 +109,54 @@ def test_items_fixture_is_the_batch_the_signals_fixture_came_from():
102109

103110
def test_fixture_matches_contract_essentials():
104111
body = load_signals_fixture()
105-
assert body["schema_version"] in (1, 2) # transitional; PR-2 pins == 2
112+
assert body["schema_version"] == 2
106113
assert isinstance(body["signals"], dict) and body["signals"]
107114
sample = next(iter(body["signals"].values()))
108-
for field in ("sentiment", "score", "rationale", "headline", "source",
109-
"url", "published", "guid", "n_articles"):
115+
for field in ("sentiment", "sentiment_score", "rationale", "headline",
116+
"source", "url", "published", "guid", "n_articles"):
110117
assert field in sample
111118

112119

120+
def test_signals_fixture_validates_against_the_vendored_producer_schema():
121+
"""Both files are copied verbatim from FinSearch (`Heartbeat/schemas/` and
122+
`Heartbeat/tests/fixtures/`), so checking one against the other is what
123+
makes the vendored pair self-policing.
124+
125+
Until now nothing in the suite loaded the schema at all — it was inert
126+
documentation, which is how it sat pinned at v1 while the producer moved to
127+
v2 and no test noticed. v2 sets additionalProperties:false and requires
128+
`sentiment_score`, so this is also the assertion that turns a re-vendored
129+
fixture carrying a stray legacy `score` into a CI failure.
130+
131+
Only the on-disk fixture is validated, never the wire one: the wire shape
132+
deliberately violates this schema (it drops the three _PUBLIC_STRIP
133+
required fields and appends `staleness_hours`), which is precisely the
134+
distinction test_wire_fixture_reflects_public_projection guards."""
135+
schema = json.loads((FIXTURES / "signals-v2.schema.json").read_text())
136+
jsonschema.validate(instance=load_signals_fixture(), schema=schema)
137+
138+
139+
def test_signals_fixtures_do_not_speak_the_retired_score_vocabulary():
140+
"""The `score` -> `sentiment_score` rename is hard, not a dual-write:
141+
signals-v2.schema.json sets additionalProperties:false and requires
142+
sentiment_score, and FinSearch normalizes at its API boundary so `score`
143+
never reaches the wire (whether the artifact is read as latest or via
144+
?as_of). A fixture still carrying `score` would describe a shape the
145+
producer cannot emit — which is precisely the failure this suite had:
146+
v1-pinned fixtures stay green while prod serves v2, so they fail when you
147+
fix them and pass when you are wrong."""
148+
for body in (load_signals_fixture(), load_signals_wire_fixture()):
149+
for sig in body["signals"].values():
150+
assert "score" not in sig
151+
# Deliberately stricter than the vendored schema, whose `number`
152+
# admits ints: news_signals.py builds this via float() + round(),
153+
# so float — not "any number" — is the real producer invariant.
154+
# Don't relax it to (int, float): the schema check above already
155+
# covers "is a number", and only for the on-disk fixture, so this
156+
# is also the wire fixture's only type guard.
157+
assert isinstance(sig["sentiment_score"], float)
158+
159+
113160
def test_wire_fixture_reflects_public_projection():
114161
"""Guards the wire/on-disk distinction the adapter depends on: the wire
115162
shape carries `staleness_hours` and omits the three fields `_PUBLIC_STRIP`

dashboard/backend/tests/test_sentiment_score_fallback.py

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""PR-2 of the FinSearch score-field disambiguation (see FinSearch spec
2+
2026-07-14-score-field-disambiguation-design.md): the transitional v1 `score`
3+
fallback is gone, and reading v1 is now an error rather than a kindness.
4+
5+
Signals v2 is a HARD rename, not a dual-write: signals-v2.schema.json requires
6+
`sentiment_score` and sets additionalProperties:false, and FinSearch renames at
7+
its API boundary so `score` never reaches the wire — whether the artifact is
8+
read as latest or via `?as_of`, and whether the artifact on disk is native v2
9+
or a normalized v1. There is no grace period left to be tolerant of, so a
10+
payload still carrying `score` is not an old-but-valid producer worth
11+
accommodating; it is a broken one. Tolerating it would only paint a plausible
12+
number onto the panel while hiding the breakage.
13+
14+
Note `_project_entry` still EMITS the internal key `score`. That is deliberate,
15+
not a missed rename: api/v2/models.py's NewsSentimentEntry validates the
16+
internal envelope, whose vocabulary is decoupled from the wire's by design.
17+
This module is only about which key we READ.
18+
"""
19+
import pytest
20+
21+
from dashboard.backend.integrations.news_sentiment import _project_entry
22+
23+
BASE = {"sentiment": "bullish", "rationale": "r", "headline": "h",
24+
"source": "Reuters", "url": "https://example.com/a",
25+
"published": 1783330000.0, "guid": "g1", "n_articles": 2}
26+
27+
28+
def test_project_entry_reads_sentiment_score_v2():
29+
entry = _project_entry({**BASE, "sentiment_score": 0.5},
30+
reference_ts=1783333600.0)
31+
assert entry["score"] == 0.5
32+
33+
34+
def test_project_entry_rejects_v1_score_only():
35+
"""The deleted fallback accepted this silently. The wire cannot produce it
36+
any more, so staying quiet here would mean a real producer break renders as
37+
a normal-looking sentiment number instead of a fault."""
38+
with pytest.raises(KeyError):
39+
_project_entry({**BASE, "score": -0.3}, reference_ts=1783333600.0)
40+
41+
42+
def test_project_entry_raises_when_sentiment_score_absent():
43+
"""Fail loud like every other required field: score=None would flow
44+
silently into the panel and into every backtest step."""
45+
with pytest.raises(KeyError):
46+
_project_entry(dict(BASE), reference_ts=1783333600.0)

0 commit comments

Comments
 (0)