Skip to content

Commit 1fad6fb

Browse files
authored
feat(heartbeat): codify Dow-30 watchlist + parity test; reconcile aliases (#341)
* feat(heartbeat): codify DOW_30 watchlist (default now Dow-30 ∪ extras, 34) + parity test; reconcile TICKER_ALIASES Retarget the word-boundary alias fixture from removed INTC to surviving CSCO/V aliases. * docs(heartbeat): watchlist is code-defined (Dow-30 ∪ extras); env override optional * chore(heartbeat): bump news_signals VERSION for the watchlist reconcile Regenerate the golden fixture (embeds generator VERSION) via tests/fixtures/make_signals_fixture.py per the fixture-parity test's documented flow. * docs(heartbeat): note aliases must track the list when editing tickers
1 parent cb57835 commit 1fad6fb

7 files changed

Lines changed: 122 additions & 23 deletions

File tree

Heartbeat/.env.heartbeat.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ HEARTBEAT_MODEL=gpt-4o-mini
1313

1414
# Pipeline
1515
HEARTBEAT_DRY_RUN=1
16-
HEARTBEAT_WATCHLIST=AAPL MSFT NVDA GOOGL AMZN META TSLA BRK-B JPM BTC-USD
16+
# Optional override. The code default is Dow-30 ∪ extras (34 tickers), defined
17+
# as DOW_30/WATCHLIST_EXTRAS in news_signals.py + news_heartbeat.py. Leave unset
18+
# to track the code default; set only to hot-patch the universe without a deploy.
19+
# HEARTBEAT_WATCHLIST=AAPL AMGN AMZN AXP BA BRK-B BTC-USD CAT CRM CSCO CVX DIS GOOGL GS HD HON IBM JNJ JPM KO MCD META MMM MRK MSFT NKE NVDA PG SHW TRV TSLA UNH V WMT
1720
HEARTBEAT_WINDOW_HOURS=24
1821
HEARTBEAT_HOME=/home/deploy/fingpt/heartbeat
1922

Heartbeat/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,13 @@ Deploy (droplet, systemd --user, mirrors the heartbeat):
105105
ssh finsearch-deploy 'systemctl --user daemon-reload &&
106106
systemctl --user enable --now finsearch-signals.timer finsearch-signals-canary.timer'
107107

108-
Universe change (spec D2) — set on the droplet in
109-
`~/fingpt/envs/.env.heartbeat` (35 tickers: heartbeat default ∪ DJIA-30;
110-
ATL's bogus `AMEX` slot is deliberately excluded — it can never have data):
111-
112-
HEARTBEAT_WATCHLIST=AAPL AMZN AXP BA BRK-B BTC-USD CAT CSCO CVX DIS GOOGL GS HD IBM INTC JNJ JPM KO MA MCD META MMM MRK MSFT NKE NVDA PFE PG TRV TSLA UNH V WBA WMT XOM
108+
Universe (spec 2026-07-10) — the watchlist is defined **in code** as
109+
`DOW_30 ∪ WATCHLIST_EXTRAS` (34 tickers) in `news_signals.py` and
110+
`news_heartbeat.py` (parity-tested in `tests/test_watchlist.py`). The droplet
111+
does **not** set `HEARTBEAT_WATCHLIST`; the code default drives it, so changing
112+
the universe is a one-line edit + a normal deploy. `HEARTBEAT_WATCHLIST` remains
113+
an optional emergency override (space-separated) if you must hot-patch without a
114+
deploy.
113115

114116
Staging checklist (run once at deploy time, spec §9):
115117
1. Drop two items files back-to-back; `systemctl --user start finsearch-signals.service`;

Heartbeat/news_heartbeat.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,20 @@
4141
}
4242
TICKER_FEED = ("https://feeds.finance.yahoo.com/rss/2.0/headline"
4343
"?s={ticker}&region=US&lang=en-US")
44-
DEFAULT_WATCHLIST = "AAPL MSFT NVDA GOOGL AMZN META TSLA BRK-B JPM BTC-USD"
44+
# Dow Jones Industrial Average constituents.
45+
# Source: S&P Dow Jones Indices; effective 2026-06-29 (GOOGL replaced VZ).
46+
# Reconcile against the official index — never a hand-maintained copy — when
47+
# the composition changes. Parity-tested against news_heartbeat.py
48+
# (Heartbeat/tests/test_watchlist.py). To add/remove a ticker, edit this
49+
# list and its TICKER_ALIASES entry (news_signals.py only; test-enforced).
50+
DOW_30 = [
51+
"AAPL", "AMGN", "AMZN", "AXP", "BA", "CAT", "CRM", "CSCO", "CVX", "DIS",
52+
"GOOGL", "GS", "HD", "HON", "IBM", "JNJ", "JPM", "KO", "MCD", "MMM",
53+
"MRK", "MSFT", "NKE", "NVDA", "PG", "SHW", "TRV", "UNH", "V", "WMT",
54+
]
55+
# Non-Dow tickers FinSearch also tracks for its own community digests.
56+
WATCHLIST_EXTRAS = ["META", "TSLA", "BRK-B", "BTC-USD"]
57+
DEFAULT_WATCHLIST = " ".join(sorted(set(DOW_30) | set(WATCHLIST_EXTRAS)))
4558
DISCLAIMER = ("Summaries by Agentic FinSearch · Sources: Yahoo Finance & linked "
4659
"publishers · Not financial advice")
4760

Heartbeat/news_signals.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,24 @@
2424
from datetime import datetime, timezone
2525
from pathlib import Path
2626

27-
VERSION = "2026-07-06.1"
27+
VERSION = "2026-07-10.1"
2828
SCHEMA_VERSION = 1
2929
PROMPT_VERSION = 1
3030

31-
DEFAULT_WATCHLIST = "AAPL MSFT NVDA GOOGL AMZN META TSLA BRK-B JPM BTC-USD"
31+
# Dow Jones Industrial Average constituents.
32+
# Source: S&P Dow Jones Indices; effective 2026-06-29 (GOOGL replaced VZ).
33+
# Reconcile against the official index — never a hand-maintained copy — when
34+
# the composition changes. Parity-tested against news_heartbeat.py
35+
# (Heartbeat/tests/test_watchlist.py). To add/remove a ticker, edit this
36+
# list and its TICKER_ALIASES entry (news_signals.py only; test-enforced).
37+
DOW_30 = [
38+
"AAPL", "AMGN", "AMZN", "AXP", "BA", "CAT", "CRM", "CSCO", "CVX", "DIS",
39+
"GOOGL", "GS", "HD", "HON", "IBM", "JNJ", "JPM", "KO", "MCD", "MMM",
40+
"MRK", "MSFT", "NKE", "NVDA", "PG", "SHW", "TRV", "UNH", "V", "WMT",
41+
]
42+
# Non-Dow tickers FinSearch also tracks for its own community digests.
43+
WATCHLIST_EXTRAS = ["META", "TSLA", "BRK-B", "BTC-USD"]
44+
DEFAULT_WATCHLIST = " ".join(sorted(set(DOW_30) | set(WATCHLIST_EXTRAS)))
3245
REQUIRED_FIELDS = ("guid", "title", "link", "source", "published", "score")
3346
FIELD_CAPS = {"title": 500, "description": 5000, "link": 2000, "source": 200,
3447
"guid": 200}
@@ -196,21 +209,21 @@ def validation_gate(path, max_file_mb):
196209
# Lowercase name tokens per ticker (union watchlist, spec D2). Symbols shorter
197210
# than SYMBOL_MATCH_MIN_LEN rely on aliases alone. >>> OWNER-TUNED.
198211
TICKER_ALIASES = {
199-
"AAPL": ("apple",), "AMZN": ("amazon",),
212+
"AAPL": ("apple",), "AMGN": ("amgen",), "AMZN": ("amazon",),
200213
"AXP": ("american express", "amex"), "BA": ("boeing",),
201214
"BRK-B": ("berkshire",), "BTC-USD": ("bitcoin",),
202-
"CAT": ("caterpillar",), "CSCO": ("cisco",), "CVX": ("chevron",),
203-
"DIS": ("disney",), "GOOGL": ("google", "alphabet"),
204-
"GS": ("goldman",), "HD": ("home depot",), "IBM": ("ibm",),
205-
"INTC": ("intel",), "JNJ": ("johnson & johnson",),
215+
"CAT": ("caterpillar",), "CRM": ("salesforce",), "CSCO": ("cisco",),
216+
"CVX": ("chevron",), "DIS": ("disney",), "GOOGL": ("google", "alphabet"),
217+
"GS": ("goldman",), "HD": ("home depot",), "HON": ("honeywell",),
218+
"IBM": ("ibm",), "JNJ": ("johnson & johnson",),
206219
"JPM": ("jpmorgan", "jp morgan"), "KO": ("coca-cola",),
207-
"MA": ("mastercard",), "MCD": ("mcdonald",),
220+
"MCD": ("mcdonald",),
208221
"META": ("meta platforms", "facebook", "instagram"), "MMM": ("3m",),
209222
"MRK": ("merck",), "MSFT": ("microsoft",), "NKE": ("nike",),
210-
"NVDA": ("nvidia",), "PFE": ("pfizer",), "PG": ("procter",),
223+
"NVDA": ("nvidia",), "PG": ("procter",),
224+
"SHW": ("sherwin-williams", "sherwin williams"),
211225
"TRV": ("travelers",), "TSLA": ("tesla",), "UNH": ("unitedhealth",),
212-
"V": ("visa",), "WBA": ("walgreens",), "WMT": ("walmart",),
213-
"XOM": ("exxon",),
226+
"V": ("visa",), "WMT": ("walmart",),
214227
}
215228
SYMBOL_MATCH_MIN_LEN = 3
216229

Heartbeat/tests/fixtures/signals-fixture.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"schema_version": 1,
33
"profile": "default",
44
"generated_at": "2026-07-06T15:00:00+00:00",
5-
"generator": "news_signals.py/2026-07-06.1",
5+
"generator": "news_signals.py/2026-07-10.1",
66
"model": "gpt-4o-mini",
77
"prompt_version": 1,
88
"source_items": "items-fixture.jsonl",

Heartbeat/tests/test_news_signals.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,17 +285,15 @@ def test_hyphenated_symbols_match(self):
285285

286286
def test_alias_match_is_word_bounded_not_substring(self):
287287
# A naive `alias in lowered` substring check false-matches company
288-
# aliases embedded inside unrelated words. All three are real words
288+
# aliases embedded inside unrelated words. Both are real words
289289
# that show up routinely in financial headlines.
290-
self.assertFalse(ns.is_subject(
291-
"This Magnificent Artificial Intelligence Stock Rallies", "INTC"))
292290
self.assertFalse(ns.is_subject(
293291
"San Francisco Fed officials weigh in on rate path", "CSCO"))
294292
self.assertFalse(ns.is_subject(
295293
"Analysts say the merger looks advisable for shareholders", "V"))
296294
# genuine mentions must still pass once word-bounded
297-
self.assertTrue(ns.is_subject("Intel unveils new AI chip", "INTC"))
298295
self.assertTrue(ns.is_subject("Cisco beats on earnings", "CSCO"))
296+
self.assertTrue(ns.is_subject("Visa fees draw new scrutiny", "V"))
299297

300298
def test_alias_3m_does_not_match_bare_dollar_or_share_figures(self):
301299
# "3m" is the only way to catch prose that names the company as "3M"

Heartbeat/tests/test_watchlist.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Watchlist single-source-of-truth + cross-file parity (spec 2026-07-10).
2+
3+
DOW_30 is duplicated in news_signals.py and news_heartbeat.py by design — the
4+
heartbeat is stdlib-only, single-file per script, so there is no shared module.
5+
This test is the anti-drift guard, same discipline as the schema-parity test.
6+
"""
7+
import sys
8+
import unittest
9+
from pathlib import Path
10+
11+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12+
13+
import news_signals as ns
14+
import news_heartbeat as nh
15+
16+
# Current Dow-30, verified 2026-07-10 (S&P DJI, effective 2026-06-29).
17+
EXPECTED_DOW_30 = {
18+
"AAPL", "AMGN", "AMZN", "AXP", "BA", "CAT", "CRM", "CSCO", "CVX", "DIS",
19+
"GOOGL", "GS", "HD", "HON", "IBM", "JNJ", "JPM", "KO", "MCD", "MMM",
20+
"MRK", "MSFT", "NKE", "NVDA", "PG", "SHW", "TRV", "UNH", "V", "WMT",
21+
}
22+
# Stale / erroneous tickers that must never reappear (the pre-fix bug set,
23+
# incl. DOW — the ticket wrongly wanted it, but it left the index in 2024).
24+
FORBIDDEN = {"AMEX", "DOW", "INTC", "MA", "PFE", "WBA", "XOM", "VZ"}
25+
26+
27+
class TestWatchlistParity(unittest.TestCase):
28+
def test_dow30_matches_across_both_scripts(self):
29+
self.assertEqual(ns.DOW_30, nh.DOW_30)
30+
31+
def test_extras_match_across_both_scripts(self):
32+
self.assertEqual(ns.WATCHLIST_EXTRAS, nh.WATCHLIST_EXTRAS)
33+
34+
def test_default_watchlist_matches_across_both_scripts(self):
35+
self.assertEqual(ns.DEFAULT_WATCHLIST, nh.DEFAULT_WATCHLIST)
36+
37+
def test_dow30_is_exactly_the_current_index(self):
38+
self.assertEqual(set(ns.DOW_30), EXPECTED_DOW_30)
39+
self.assertEqual(len(ns.DOW_30), 30)
40+
self.assertEqual(len(set(ns.DOW_30)), 30) # unique
41+
self.assertTrue(all(t == t.upper() for t in ns.DOW_30))
42+
43+
def test_no_stale_or_erroneous_tickers(self):
44+
self.assertEqual(set(ns.DOW_30) & FORBIDDEN, set())
45+
self.assertEqual(set(ns.WATCHLIST_EXTRAS) & FORBIDDEN, set())
46+
47+
def test_extras_are_disjoint_from_dow30(self):
48+
self.assertEqual(set(ns.WATCHLIST_EXTRAS) & set(ns.DOW_30), set())
49+
50+
def test_default_watchlist_is_sorted_union_of_34(self):
51+
expected = " ".join(sorted(set(ns.DOW_30) | set(ns.WATCHLIST_EXTRAS)))
52+
self.assertEqual(ns.DEFAULT_WATCHLIST, expected)
53+
self.assertEqual(len(ns.DEFAULT_WATCHLIST.split()), 34)
54+
55+
def test_reconcile_additions_present(self):
56+
for t in ("AMGN", "CRM", "HON", "SHW"):
57+
self.assertIn(t, ns.DOW_30, t)
58+
59+
def test_ticker_aliases_cover_exactly_the_watchlist(self):
60+
# news_signals-only: the subject-relevance gate (spec D8) matches
61+
# name-based headlines through TICKER_ALIASES, so a ticker in the
62+
# watchlist but not the alias table is only half-added (and
63+
# warn_alias_gaps WARNs every sweep). Set EQUALITY also proves the
64+
# stale INTC/MA/PFE/WBA/XOM aliases are gone.
65+
union = set(ns.DOW_30) | set(ns.WATCHLIST_EXTRAS)
66+
self.assertEqual(set(ns.TICKER_ALIASES), union)
67+
68+
69+
if __name__ == "__main__":
70+
unittest.main()

0 commit comments

Comments
 (0)