diff --git a/.env.example b/.env.example index 397b04be..21f44ccf 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,9 @@ FINGPT_API_KEY=your_fingpt_api_key_here # Optional override of the FinSearch raw news-items endpoint (defaults to the # live endpoint). Point at a local fixture/staging server for dev. # FINSEARCH_ITEMS_URL=https://agenticfinsearch.org/api/news/items/ +# The Home news panel serves a frozen "Demo news" snapshot while FinSearch's +# feed is paused (unset = frozen). Set to 0 to proxy the live feed. +# NEWS_FEED_FROZEN=0 # OpenRouter multi-provider gateway (Anthropic Messages skin). # Leaderboard entries with "integration": "openrouter" route through this diff --git a/CLAUDE.md b/CLAUDE.md index dbc79389..1c7b22fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,7 @@ python dashboard/scripts/backtest_hourly_agent.py # main hourly agent backte - `MAX_LEGACY_ACTIVE_PER_SESSION` / `MAX_LEGACY_ACTIVE_GLOBAL` (optional, defaults **5** / **50**, 0 disables): concurrency budgets for the legacy `/api/v1/backtest/*` surface. That surface authenticates nothing — `_require_session` accepts any non-empty `X-Session-Id` — and writes **no `protocol_runs` row**, so the per-agent, per-account and global protocol caps are all blind to its runs; before these it was the one unbounded path into the same engine. The per-session budget bounds a looping client (its key is caller-chosen, so it is not a bound against someone who rotates it); the global one is the memory bound that holds regardless, since every live session pins a loaded bar window. Enforced via `start_backtest(enforce_session_cap=True)`, which **only** the legacy route passes — the protocol surfaces reach the same function through `run_service.create_run`, which has already applied its three caps. - `ANALYTICS_DAILY_JOB_INTERVAL_SECONDS` (optional, default **300**, range 5–3600): how often the analytics daily-facts worker (`domain/analytics/daily_job.py`, started from `app.py` beside the run reaper) wakes to ask whether yesterday's `user_daily_facts` row set is due. It runs on **its own thread**, not the 60-second reaper tick — the reaper exists to keep run heartbeats fresh against `RUN_HEARTBEAT_STALE_SECONDS`, and a whole-population batch across three databases on that thread would let a slow analytics night mark live runs as orphaned (design D23). An idle wake costs exactly one store call (the refused day claim on `analytics_projection_jobs`), so the interval buys freshness after midnight, not cost. Junk or out-of-range values log and fall back to 300 rather than raising at import — a bare `int()` at module scope has killed app boot in this repo before. `tests/conftest.py` strips it. The worker also runs the idempotent `user_activity` seed and the eight-week history copy (`domain/analytics/facts_migration.py`) once before its first tick, and owns `rollup_day` and the analytics retention coordinator; the reaper keeps only the two throttled snapshot repairs in `maintenance.py` until PR B deletes them. Design: `docs/superpowers/specs/2026-09-15-admin-layer-redesign-design.md` §6.9, §6.12. - `LEADERBOARD_DAILY_AUTO_DEPLOY` (optional, **strict opt-in — this one spends money**): when truthy, serving the *public, unauthenticated* `GET /api/v1/leaderboard?period=daily` may start a background thread that runs `deploy_model_run` for every competition LLM entry — real billable API calls initiated by an anonymous request. It is therefore off unless explicitly set (`1`/`true`/`yes`/`on`); do **not** restore the old "on whenever `RENDER` is unset" default, which armed it on the Docker image, every self-host and fork, and the test suite (`tests/conftest.py` deliberately strips `RENDER`, and strips this var too). Prod previously relied on the nightly refresh job instead — **but that schedule is paused as of PR #352** (see `LEADERBOARD_DAILY_REFRESH_SECRET` above), so prod currently runs *neither* this flag nor the cron. Nothing refreshes the daily board automatically today, by design: the Live Trading Leaderboard that replaced its tab is a Season 0 preview with no advance engine yet. Do not "restore" automation here to close the gap — arming this flag is the anonymous-billable-request path the strict opt-in exists to prevent. Note the in-progress guard and the per-window state file (`storage/data/leaderboard_daily_refresh.json`) are **per-process**: fine for the current single-instance Render deploy, but a multi-replica deploy would duplicate model deploys. +- `NEWS_FEED_FROZEN` (optional, **unset = frozen** while FinSearch's feed timers are paused, since 2026-10-11): read per request by `api/routers/news.py::news_feed_frozen()`. Frozen, `GET /api/news/signals` never calls FinSearch and serves `dashboard/config/news_demo_snapshot.json` (the last live payload); the loader adds `demo: true` and `staleness_hours: null`, so both Home news consumers (`home-page.js`, `home-news-signals.js`) show "Demo news" and fixed dates instead of a frozen "Updated 0.2h ago" and ever-growing "3w ago". To resume: re-enable the FinSearch timers on the droplet first, then set `NEWS_FEED_FROZEN=0` in the Render dashboard (no deploy). Junk reads as frozen and warns once. A snapshot that fails to load fails closed to `unavailable` and is retried on the next request. The adapter (see *Fail-closed is not fail-visible*) still backs `/api/v2` backtest steps (`?as_of=`) either way. `tests/conftest.py` strips it. - Alpaca paper-trading credentials also live in `credentials/alpaca.json` (gitignored; see `credentials/alpaca.json.example`). ## Architecture diff --git a/dashboard/backend/api/routers/news.py b/dashboard/backend/api/routers/news.py index f70a512f..25d7a620 100644 --- a/dashboard/backend/api/routers/news.py +++ b/dashboard/backend/api/routers/news.py @@ -1,13 +1,99 @@ """Panel proxy for FinSearch news signals — keeps FINGPT_API_KEY server-side.""" +import copy +import json +import logging +import os +import threading + from fastapi import APIRouter from dashboard.backend.cache import (CACHE_KEY_NEWS_SIGNALS, TTL_NEWS, TTL_NEWS_UNAVAILABLE, shared_ttl_cache) from dashboard.backend.infrastructure.llm.validator import DJIA_30 from dashboard.backend.integrations import news_sentiment +from dashboard.backend.paths import CONFIG_DIR + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/news", tags=["news"]) +# FinSearch's news pipeline is paused (2026-10-11), so the panel serves the last +# live payload, frozen, flagged `demo: true` for the "Demo news" line. Proxying +# live would only show a feed going stale with no explanation. +# +# NEWS_FEED_FROZEN is read per request so the feed resumes from the Render +# dashboard with no deploy: restart the FinSearch timers, then set it to `0`. +# Unset means frozen while the pipeline is paused. A value outside the boolean +# vocabulary also reads as frozen — the unfrozen path calls an upstream that may +# be dead, while the frozen path is labelled and local — and warns once. +NEWS_FEED_FROZEN_ENV = "NEWS_FEED_FROZEN" +NEWS_FEED_FROZEN_DEFAULT = True +NEWS_DEMO_SNAPSHOT_PATH = CONFIG_DIR / "news_demo_snapshot.json" + +_TRUTHY = {"1", "true", "yes", "on"} +_FALSEY = {"0", "false", "no", "off"} +_warned_values: set[str] = set() + +# The keys the panel reads; a snapshot without them is not a payload. +_SNAPSHOT_REQUIRED_KEYS = ("status", "feed", "signals") + +_snapshot_lock = threading.Lock() +_snapshot_cache: dict | None = None + + +def news_feed_frozen() -> bool: + raw = (os.getenv(NEWS_FEED_FROZEN_ENV) or "").strip().lower() + if not raw: + return NEWS_FEED_FROZEN_DEFAULT + if raw in _FALSEY: + return False + if raw not in _TRUTHY and raw not in _warned_values: + _warned_values.add(raw) + logger.warning("news: %s=%r is not a boolean; serving the frozen snapshot", + NEWS_FEED_FROZEN_ENV, raw) + return True + + +def _load_demo_snapshot() -> dict | None: + try: + payload = json.loads(NEWS_DEMO_SNAPSHOT_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.error("news: demo snapshot %s unreadable; serving unavailable", + NEWS_DEMO_SNAPSHOT_PATH, exc_info=True) + return None + if not isinstance(payload, dict) or not all(k in payload for k in _SNAPSHOT_REQUIRED_KEYS): + logger.error("news: demo snapshot %s is not a panel payload (needs %s); " + "serving unavailable", NEWS_DEMO_SNAPSHOT_PATH, + ", ".join(_SNAPSHOT_REQUIRED_KEYS)) + return None + # The loader owns both frozen-feed markers so the file cannot disagree with + # them: `demo` drives the "Demo news" line, and a frozen "Updated 0.2h ago" + # would be false. + return {**payload, "demo": True, "staleness_hours": None} + + +def _demo_snapshot() -> dict: + """A private copy of the frozen payload, or UNAVAILABLE if it cannot load. + + Only a successful load is cached: a transient read error must not pin + `unavailable` for the life of the process. The copy is deep because the + payload's `feed`/`signals` are nested and shared across requests. + """ + global _snapshot_cache + if _snapshot_cache is None: + with _snapshot_lock: + if _snapshot_cache is None: + _snapshot_cache = _load_demo_snapshot() + if _snapshot_cache is None: + return dict(news_sentiment.UNAVAILABLE_PAYLOAD) + return copy.deepcopy(_snapshot_cache) + + +def _reset_demo_snapshot_cache() -> None: + global _snapshot_cache + with _snapshot_lock: + _snapshot_cache = None + def _fetch_panel() -> dict: # get_latest_panel_payload already fails closed to UNAVAILABLE_PAYLOAD; the @@ -23,6 +109,8 @@ def _fetch_panel() -> dict: # to `async def` without moving the HTTP call off the loop. @router.get("/signals") def latest_news_signals(): + if news_feed_frozen(): + return _demo_snapshot() # Single-flight lives in the cache primitive: one leader hits the producer, # concurrent callers return the cached value (or a brief UNAVAILABLE) WITHOUT # blocking a worker behind the ~10s upstream call — so a cold-cache burst of diff --git a/dashboard/backend/tests/conftest.py b/dashboard/backend/tests/conftest.py index 048f6328..1c1ca287 100644 --- a/dashboard/backend/tests/conftest.py +++ b/dashboard/backend/tests/conftest.py @@ -206,6 +206,11 @@ # mismatches. base.py reads it at import, which this module precedes. os.environ.pop("LLM_PROVIDER_READ_TIMEOUT_SECONDS", None) +# Decides whether /api/news/signals serves the frozen snapshot or proxies +# FinSearch. The router tests set it explicitly per case; a developer who +# exported it would otherwise see the default-path test assert the wrong mode. +os.environ.pop("NEWS_FEED_FROZEN", None) + @atexit.register def _cleanup_test_db_dir() -> None: diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py index ac9c5b7a..88c1a9b2 100644 --- a/dashboard/backend/tests/test_frontend_fast_boot.py +++ b/dashboard/backend/tests/test_frontend_fast_boot.py @@ -198,7 +198,7 @@ def test_cache_busters_bumped(): assert "js/agent-editor.js?v=32" in APP_HTML assert "styles.css?v=159" in APP_HTML assert "js/leaderboard.js?v=37" in APP_HTML - assert "home-page.js?v=54" in APP_HTML + assert "home-page.js?v=55" in APP_HTML assert "js/credit-format.js?v=1" in APP_HTML assert "js/credits.js?v=8" in APP_HTML assert "js/admin-credits.js?v=9" in APP_HTML diff --git a/dashboard/backend/tests/test_frontend_home_news_demo.py b/dashboard/backend/tests/test_frontend_home_news_demo.py new file mode 100644 index 00000000..ea6afbbd --- /dev/null +++ b/dashboard/backend/tests/test_frontend_home_news_demo.py @@ -0,0 +1,140 @@ +"""The frozen news snapshot (`demo: true`, api/routers/news.py) under node. + +Both consumers of /api/news/signals run their real render code against the +committed snapshot: home-page.js (the Home Market News / Signals tabs) and +home-news-signals.js (the #homeNewsSignals section). Each must label the frozen +feed "Demo news" rather than present it as live, and show fixed dates instead +of relative times that keep growing on a paused feed. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +from dashboard.backend.tests._frontend_source import FRONTEND, _match_brace, fn_body, run_node + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed") + +_HOME_JS = (FRONTEND / "home-page.js").read_text(encoding="utf-8") +_SIGNALS_JS = (FRONTEND / "home-news-signals.js").read_text(encoding="utf-8") +_SNAPSHOT = json.loads( + (Path(__file__).resolve().parents[2] / "config" / "news_demo_snapshot.json").read_text(encoding="utf-8") +) + + +def _home_mock_news() -> str: + start = _HOME_JS.index("const HOME_MOCK_NEWS = {") + return _HOME_JS[start : _match_brace(_HOME_JS, _HOME_JS.index("{", start)) + 1] + ";" + + +_FAKE_DOM = """ +const els = {}; +function el(id) { + if (!els[id]) els[id] = { id, hidden: false, textContent: '', innerHTML: '' }; + return els[id]; +} +const document = { getElementById: el }; +const window = { + formatMarketEventRelativeTime: (ms, now) => 'REL', +}; +""" + + +def _home_harness(payload: dict) -> str: + fns = [fn_body(f"function {name}(", _HOME_JS) for name in ( + "homeEscape", "homeSafeUrl", "homeSentimentClass", "homeRelTime", + "homeFeedTime", "setHomeMarketDemoBadge", "renderHomeMarketNews", + "renderHomeMarketSignals", + )] + return "\n".join([_FAKE_DOM, *fns, _home_mock_news(), f""" +const payload = {json.dumps(payload)}; +renderHomeMarketNews(payload); +const news = {{ status: {{ ...el('homeMarketStatus') }}, list: el('homeModuleNewsList').innerHTML, + badge: el('homeMarketDemoBadge').hidden }}; +renderHomeMarketSignals(payload); +console.log(JSON.stringify({{ news, signals: {{ status: {{ ...el('homeMarketSignalsStatus') }}, + list: el('homeModuleSignalsList').innerHTML }} }})); +"""]) + + +def _live(payload: dict) -> dict: + out = {k: v for k, v in payload.items() if k != "demo"} + out["staleness_hours"] = 1.5 + return out + + +@pytest.fixture +def demo_payload(): + # What the loader serves: the file plus the two markers it owns. + return {**_SNAPSHOT, "demo": True, "staleness_hours": None} + + +def test_home_news_labels_the_snapshot_demo_news(demo_payload): + out = run_node(_home_harness(demo_payload)) + assert out["news"]["status"]["hidden"] is False + assert out["news"]["status"]["textContent"] == "Demo news" + # Real stories, not HOME_MOCK_NEWS: the mock "Demo data" badge stays hidden. + assert out["news"]["badge"] is True + assert _SNAPSHOT["feed"][0]["headline"].split()[0] in out["news"]["list"] + assert out["signals"]["status"]["textContent"] == f"Demo news · {len(_SNAPSHOT['signals'])} signals" + + +def test_home_news_shows_fixed_dates_not_relative_times(demo_payload): + out = run_node(_home_harness(demo_payload)) + assert "REL" not in out["news"]["list"] + assert "Oct " in out["news"]["list"] + + +def test_home_live_feed_keeps_relative_times_and_updated_line(demo_payload): + out = run_node(_home_harness(_live(demo_payload))) + assert out["news"]["status"]["textContent"] == "Updated 1.5h ago" + assert "REL" in out["news"]["list"] + assert out["signals"]["status"]["textContent"] == f"{len(_SNAPSHOT['signals'])} signals" + + +def test_home_signals_read_the_live_sentiment_score(demo_payload): + """Live signals carry `sentiment_score`; reading the mock's `score` rendered + every one as 0.00.""" + sym, sig = next((s, v) for s, v in _SNAPSHOT["signals"].items() if v["sentiment_score"]) + out = run_node(_home_harness(demo_payload)) + assert f"{sym} · score {sig['sentiment_score']:.2f}" in out["signals"]["list"] + + +def test_home_mock_still_reads_its_score_field(): + out = run_node(_home_harness({"status": "unavailable"})) + assert "AAPL · score 0.72" in out["signals"]["list"] + assert out["news"]["badge"] is False # mock data: "Demo data" badge shown + + +def _signals_harness(payload: dict) -> str: + return "\n".join([_FAKE_DOM, """ +function escapeHtml(v) { + return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} +const API_BASE = ''; +let served; +const API = { get: async () => served }; +global.setInterval = () => 1; +""", f"served = {json.dumps(payload)};", _SIGNALS_JS, """ +window.newsSignalsPanel.onShow(); +setTimeout(() => console.log(JSON.stringify({ + updated: el('nnsUpdated').textContent, badge: el('nnsStatusBadge').hidden, + feed: el('nnsFeedList').innerHTML, +})), 0); +"""]) + + +def test_signals_section_labels_the_snapshot_demo_news(demo_payload): + out = run_node(_signals_harness(demo_payload)) + assert out["updated"] == "Demo news" + assert out["badge"] is True + assert "REL" not in out["feed"] and "Oct " in out["feed"] + + +def test_signals_section_live_feed_unchanged(demo_payload): + out = run_node(_signals_harness(_live(demo_payload))) + assert out["updated"] == "Updated 1.5h ago" + assert "REL" in out["feed"] diff --git a/dashboard/backend/tests/test_news_router.py b/dashboard/backend/tests/test_news_router.py index eea068e5..76ad7f8e 100644 --- a/dashboard/backend/tests/test_news_router.py +++ b/dashboard/backend/tests/test_news_router.py @@ -1,11 +1,35 @@ +import json + +import pytest from fastapi.testclient import TestClient from dashboard.backend.app import app from dashboard.backend import cache as cache_mod +from dashboard.backend.api.routers import news as news_router from dashboard.backend.integrations import news_sentiment as ns -def test_news_signals_route_returns_payload(monkeypatch): +@pytest.fixture +def live_feed(monkeypatch): + """The live-proxy path, which the frozen snapshot bypasses while FinSearch is paused.""" + monkeypatch.setenv("NEWS_FEED_FROZEN", "0") + + +@pytest.fixture +def frozen_feed(monkeypatch): + """Explicitly frozen, so these cases do not depend on the code default and + keep passing once the default flips back to live.""" + monkeypatch.setenv("NEWS_FEED_FROZEN", "1") + + +@pytest.fixture +def fresh_snapshot_cache(): + news_router._reset_demo_snapshot_cache() + yield + news_router._reset_demo_snapshot_cache() + + +def test_news_signals_route_returns_payload(monkeypatch, live_feed): cache_mod.shared_ttl_cache.invalidate(cache_mod.CACHE_KEY_NEWS_SIGNALS) fake = {"status": "ok", "status_reason": None, "generated_at": "2026-07-13T11:20:00+00:00", "staleness_hours": 1.2, "news_overview": "calm", "signals": {}, "feed": []} @@ -16,7 +40,7 @@ def test_news_signals_route_returns_payload(monkeypatch): assert resp.json()["news_overview"] == "calm" -def test_news_signals_route_fail_closed(monkeypatch): +def test_news_signals_route_fail_closed(monkeypatch, live_feed): cache_mod.shared_ttl_cache.invalidate(cache_mod.CACHE_KEY_NEWS_SIGNALS) def _boom(tickers): raise RuntimeError("adapter exploded") @@ -27,13 +51,13 @@ def _boom(tickers): assert resp.json()["status"] == "unavailable" -def test_news_signals_route_caches(monkeypatch): +def test_news_signals_route_caches(monkeypatch, live_feed): cache_mod.shared_ttl_cache.invalidate(cache_mod.CACHE_KEY_NEWS_SIGNALS) calls = [] def _once(tickers): calls.append(1) return {"status": "ok", "status_reason": None, "generated_at": None, - "staleness_hours": None, "news_overview": None, "signals": {}, "feed": []} + "staleness_hours": 0.5, "news_overview": "", "signals": {}, "feed": []} monkeypatch.setattr(ns, "get_latest_panel_payload", _once) client = TestClient(app) client.get("/api/news/signals") @@ -41,18 +65,137 @@ def _once(tickers): assert len(calls) == 1 -def test_news_signals_route_negative_caches_failures(monkeypatch): +def test_news_signals_route_negative_caches_failures(monkeypatch, live_feed): """The whole point of the short-TTL negative cache: a second failing request within TTL_NEWS_UNAVAILABLE is served from cache, NOT re-run through the ~10s adapter behind _fetch_lock (the outage-serialization fix). Without the negative - cache this asserts len(calls) == 2 — the regression the fix prevents.""" + cache, every Home poll during an outage would block a worker for the full timeout.""" cache_mod.shared_ttl_cache.invalidate(cache_mod.CACHE_KEY_NEWS_SIGNALS) calls = [] - def _boom(tickers): + def _fail(tickers): calls.append(1) - raise RuntimeError("adapter exploded") - monkeypatch.setattr(ns, "get_latest_panel_payload", _boom) + return dict(ns.UNAVAILABLE_PAYLOAD) + monkeypatch.setattr(ns, "get_latest_panel_payload", _fail) client = TestClient(app) assert client.get("/api/news/signals").json()["status"] == "unavailable" assert client.get("/api/news/signals").json()["status"] == "unavailable" assert len(calls) == 1 # second request served from the negative cache + + +def test_frozen_feed_serves_demo_snapshot_without_calling_finsearch(monkeypatch, frozen_feed): + def _never(tickers): + raise AssertionError("frozen feed must not reach the producer") + monkeypatch.setattr(ns, "get_latest_panel_payload", _never) + body = TestClient(app).get("/api/news/signals").json() + assert body["demo"] is True + assert body["status"] == "ok" + # A frozen "Updated Nh ago" would be false; the panel shows "Demo news" instead. + assert body["staleness_hours"] is None + assert body["feed"] and body["signals"] + + +@pytest.mark.parametrize("raw, frozen", [ + ("1", True), ("true", True), (" ON ", True), ("yes", True), + ("0", False), ("false", False), ("Off", False), ("no", False), +]) +def test_freeze_flag_vocabulary(monkeypatch, raw, frozen): + monkeypatch.setenv("NEWS_FEED_FROZEN", raw) + assert news_router.news_feed_frozen() is frozen + + +def test_freeze_flag_unset_uses_the_code_default(monkeypatch): + monkeypatch.delenv("NEWS_FEED_FROZEN", raising=False) + assert news_router.news_feed_frozen() is news_router.NEWS_FEED_FROZEN_DEFAULT + + +def test_freeze_flag_junk_reads_frozen_and_warns_once(monkeypatch, caplog): + """Junk must not reach a possibly-dead upstream, and must not log per request.""" + monkeypatch.setenv("NEWS_FEED_FROZEN", "frozne") + monkeypatch.setattr(news_router, "_warned_values", set()) + with caplog.at_level("WARNING", logger=news_router.__name__): + results = [news_router.news_feed_frozen() for _ in range(3)] + assert results == [True, True, True] + assert sum("frozne" in r.getMessage() for r in caplog.records) == 1 + + +def test_freeze_flag_is_read_per_request(monkeypatch): + """Resuming from the Render dashboard needs no deploy: the env var is live.""" + calls = [] + def _live(tickers): + calls.append(1) + return {"status": "ok", "status_reason": None, "generated_at": None, + "staleness_hours": 0.1, "news_overview": "", "signals": {}, "feed": []} + monkeypatch.setattr(ns, "get_latest_panel_payload", _live) + cache_mod.shared_ttl_cache.invalidate(cache_mod.CACHE_KEY_NEWS_SIGNALS) + client = TestClient(app) + monkeypatch.setenv("NEWS_FEED_FROZEN", "1") + assert client.get("/api/news/signals").json().get("demo") is True + monkeypatch.setenv("NEWS_FEED_FROZEN", "0") + assert "demo" not in client.get("/api/news/signals").json() + assert calls == [1] + + +def test_demo_snapshot_matches_the_panel_contract(fresh_snapshot_cache): + """The snapshot stands in for get_latest_panel_payload, so it must carry + every key the panel reads, in the shape the live projection produces.""" + body = news_router._demo_snapshot() + for key in ("status", "status_reason", "generated_at", "staleness_hours", + "news_overview", "signals", "feed"): + assert key in body + for item in body["feed"]: + assert {"headline", "url", "source", "published", "ticker"} <= item.keys() + for sig in body["signals"].values(): + assert set(ns._PANEL_SIGNAL_KEYS) <= sig.keys() + + +def test_loader_owns_the_frozen_markers(monkeypatch, tmp_path, fresh_snapshot_cache): + """`demo` and the null staleness come from the loader, not the file, so an + edited or regenerated snapshot cannot drop the label or claim freshness.""" + path = tmp_path / "snap.json" + path.write_text(json.dumps({"status": "ok", "staleness_hours": 0.2, + "demo": False, "feed": [], "signals": {}})) + monkeypatch.setattr(news_router, "NEWS_DEMO_SNAPSHOT_PATH", path) + body = news_router._demo_snapshot() + assert body["demo"] is True + assert body["staleness_hours"] is None + + +def test_committed_snapshot_does_not_restate_the_demo_flag(): + raw = json.loads(news_router.NEWS_DEMO_SNAPSHOT_PATH.read_text(encoding="utf-8")) + assert "demo" not in raw, "the loader owns `demo`; keep one source of truth" + + +def test_each_response_is_a_private_copy(fresh_snapshot_cache): + first = news_router._demo_snapshot() + first["feed"].clear() + first["signals"].clear() + second = news_router._demo_snapshot() + assert second["feed"] and second["signals"] + + +def test_unreadable_snapshot_fails_closed(monkeypatch, tmp_path, frozen_feed, fresh_snapshot_cache): + monkeypatch.setattr(news_router, "NEWS_DEMO_SNAPSHOT_PATH", tmp_path / "missing.json") + body = TestClient(app).get("/api/news/signals").json() + assert body["status"] == "unavailable" + + +@pytest.mark.parametrize("content", ["[]", "null", '"text"', "42", '{"status": "ok"}']) +def test_non_payload_snapshot_fails_closed(monkeypatch, tmp_path, frozen_feed, + fresh_snapshot_cache, content): + """Valid JSON that is not a panel object must fail closed, not 500.""" + path = tmp_path / "snap.json" + path.write_text(content) + monkeypatch.setattr(news_router, "NEWS_DEMO_SNAPSHOT_PATH", path) + resp = TestClient(app).get("/api/news/signals") + assert resp.status_code == 200 + assert resp.json()["status"] == "unavailable" + + +def test_a_failed_load_is_not_cached(monkeypatch, tmp_path, fresh_snapshot_cache): + """A transient read error at boot must not pin `unavailable` until restart.""" + path = tmp_path / "snap.json" + monkeypatch.setattr(news_router, "NEWS_DEMO_SNAPSHOT_PATH", path) + assert news_router._demo_snapshot()["status"] == "unavailable" + path.write_text(json.dumps({"status": "ok", "feed": [{"headline": "h"}], "signals": {}})) + recovered = news_router._demo_snapshot() + assert recovered["status"] == "ok" and recovered["demo"] is True diff --git a/dashboard/config/news_demo_snapshot.json b/dashboard/config/news_demo_snapshot.json new file mode 100644 index 00000000..8ae83c9c --- /dev/null +++ b/dashboard/config/news_demo_snapshot.json @@ -0,0 +1,504 @@ +{ + "status": "ok", + "status_reason": null, + "generated_at": "2026-10-11T11:20:47+00:00", + "staleness_hours": null, + "news_overview": "Mixed signals across major tech and finance stocks, with some facing operational challenges while others show growth potential.", + "signals": { + "AAPL": { + "sentiment": "bullish", + "sentiment_score": 0.3, + "rationale": "Tim Cook's transition to executive chair and the upcoming iPhone launch suggest stability, but lack of strong growth indicators.", + "headline": "Tim Cook Told Apple CEO John Ternus ‘Don’t Mimic Me’ Before Stepping Aside Ahead of Apple’s Big iPhone Launch: 'I’m Not a Meddler'", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/articles/tim-cook-told-apple-ceo-094507099.html?.tsrc=rss", + "published": 1791711907.0, + "guid": "5e0f585f-5b39-30ca-936c-46053bf0833b", + "n_articles": 5 + }, + "AMZN": { + "sentiment": "bearish", + "sentiment_score": -0.5, + "rationale": "Concerns over heavy AI spending and security flaws in AWS indicate potential operational risks.", + "headline": "Amazon (AMZN) Wants Investors to Finance $8 Billion of Nvidia Chips. Is AI Spending Getting Too Heavy?", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/ai/articles/amazon-amzn-wants-investors-finance-221126922.html?.tsrc=rss", + "published": 1791670286.0, + "guid": "571676f3-668c-36a0-ac54-c725a0e00a0a", + "n_articles": 6 + }, + "BA": { + "sentiment": "bearish", + "sentiment_score": -0.6, + "rationale": "Boeing's stock decline amidst new orders raises questions about market confidence.", + "headline": "Boeing Stock Is Down 12% in 2026 Even as Avolon Orders 140 More MAX Jets. Time to Sell or Load Up?", + "source": "tikr.com", + "url": "https://www.tikr.com/blog/boeing-stock-is-down-12-in-2026-even-as-avolon-orders-140-more-max-jets-time-to-sell-or-load-up?ref=yahoofinance&.tsrc=rss", + "published": 1791646031.0, + "guid": "78849b9d-ef3d-32f5-a81b-78b44f124fe0", + "n_articles": 1 + }, + "CVX": { + "sentiment": "bearish", + "sentiment_score": -0.4, + "rationale": "Chevron's ability to maintain dividends amidst oil market challenges raises concerns about future stability.", + "headline": "Chevron’s Dividend Has Survived Every Oil Crash. Can It Survive This One?", + "source": "24/7 Wall St", + "url": "https://247wallst.com/investing/2026/10/10/chevrons-dividend-has-survived-every-oil-crash-can-it-survive-this-one/", + "published": 1791632755.0, + "guid": "3914775f-7b1b-3be8-9b4b-beac106f62df", + "n_articles": 1 + }, + "DIS": { + "sentiment": "bullish", + "sentiment_score": 0.2, + "rationale": "CEO's comments on box office performance suggest a strategic pivot, but lack of strong indicators for recovery.", + "headline": "Walt Disney’s CEO explains why box office bombs may no longer matter", + "source": "TheStreet", + "url": "https://www.thestreet.com/entertainment/walt-disney-plans-to-give-fans-more-of-what-they-love", + "published": 1791670797.0, + "guid": "fe5804b0-39d0-3743-a320-093bc3860042", + "n_articles": 1 + }, + "GOOGL": { + "sentiment": "bullish", + "sentiment_score": 0.4, + "rationale": "Investments in AI and autonomous driving show growth potential, but execution remains critical.", + "headline": "Alphabet Is the Robotaxi Stock to Buy as Waymo Chases 1 Million Rides a Week", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/alphabet-is-the-robotaxi-stock-to-buy-as-waymo-chases-1-million-rides-a-week/", + "published": 1791711422.0, + "guid": "ab7823e1-daed-3a38-a2be-e275c3d5e1d9", + "n_articles": 4 + }, + "IBM": { + "sentiment": "bullish", + "sentiment_score": 0.3, + "rationale": "New security collaboration indicates a proactive approach to enterprise needs, suggesting potential growth.", + "headline": "International Business Machines (IBM) Unveils Real Time App Security Collaboration", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/articles/international-business-machines-ibm-unveils-031626945.html?.tsrc=rss", + "published": 1791688586.0, + "guid": "4704c1af-0ba7-3b93-9df1-e04c22d7c031", + "n_articles": 1 + }, + "MMM": { + "sentiment": "bullish", + "sentiment_score": 0.4, + "rationale": "Strong historical performance raises interest in current valuation, indicating potential for investment.", + "headline": "3M (MMM) Stock Could Be A Bargain On Future Cash Flow", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/3m-mmm-stock-could-bargain-111606490.html?.tsrc=rss", + "published": 1791630966.0, + "guid": "67e89eef-eadb-3694-95e6-5fb3e532efa2", + "n_articles": 1 + }, + "MRK": { + "sentiment": "bullish", + "sentiment_score": 0.3, + "rationale": "Positive study results could enhance Merck's market position, but further validation needed.", + "headline": "Merck Presents One-Year Results from the Pivotal Phase 2b/3 BRUNELLO Study Evaluating Remigromig, a Tri-specific Agonist of the Wingless-related Integration Site (Wnt) Pathway, in Adults with Diabetic Macular Edema at AAO 2026", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/healthcare/articles/merck-presents-one-results-pivotal-221000461.html?.tsrc=rss", + "published": 1791670200.0, + "guid": "7317f687-ee94-3b32-a090-433f49172915", + "n_articles": 1 + }, + "MSFT": { + "sentiment": "bullish", + "sentiment_score": 0.5, + "rationale": "Strong Azure growth indicates solid performance, but valuation concerns persist.", + "headline": "Microsoft (MSFT) Posted 43% Azure Growth, Yet Trades Below Its Own Five-Year P/E. A Real Discount?", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/microsoft-msft-posted-43-azure-132642768.html?.tsrc=rss", + "published": 1791652302.0, + "guid": "ee2d800d-3332-3de6-9dee-71d270913fb8", + "n_articles": 2 + }, + "NKE": { + "sentiment": "bearish", + "sentiment_score": -0.3, + "rationale": "Recent stock decline suggests potential challenges ahead for Nike.", + "headline": "Here's What History Says October Will Bring for Nike Stock", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/10/what-history-says-october-will-bring-nike-stock/", + "published": 1791653820.0, + "guid": "9773ecd3-0448-3705-b658-dbbd1e736de3", + "n_articles": 1 + }, + "UNH": { + "sentiment": "bullish", + "sentiment_score": 0.3, + "rationale": "Medicare Advantage overhaul could enhance investment case, but execution will be key.", + "headline": "How UnitedHealth’s 2027 Medicare Advantage Overhaul Could Reshape the Investment Case for UNH", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/healthcare/articles/unitedhealth-2027-medicare-advantage-overhaul-111237401.html?.tsrc=rss", + "published": 1791630757.0, + "guid": "54025a5c-c98c-3495-8b1a-94215c2adc79", + "n_articles": 1 + }, + "WMT": { + "sentiment": "bearish", + "sentiment_score": -0.4, + "rationale": "Operational hurdles in supply chain automation raise concerns about Walmart's efficiency.", + "headline": "Is Walmart (WMT) Taking Too Long To Make Supply Chain Automation Pay Off?", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/walmart-wmt-taking-too-long-041848805.html?.tsrc=rss", + "published": 1791692328.0, + "guid": "921cce11-60f6-3dbb-a88b-8696ca9cf88d", + "n_articles": 3 + } + }, + "feed": [ + { + "headline": "The Stock Market Is on the Verge of Doing Something for the First Time in 156 Years -- and if Historical Precedent Is Accurate, a Crash May Follow", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/stock-market-on-verge-doing-something-first-time-156-years-historical-precedent-crash-may-follow/", + "published": 1791716162.0, + "ticker": "NVDA" + }, + { + "headline": "Better Cryptocurrency to Buy Now With $1,000: Chainlink vs. XRP", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/better-cryptocurrency-to-buy-now-with-1000-chainli/", + "published": 1791715800.0, + "ticker": "NVDA" + }, + { + "headline": "‘We’re looking at a bubble here’: Experts including Ray Dalio advise on how to protect your wealth as bond yields spike", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/looking-bubble-experts-including-ray-104500189.html?.tsrc=rss", + "published": 1791715500.0, + "ticker": "AMZN" + }, + { + "headline": "Schwab's Dividend ETF's Worst Year Since 2012 Was a 5.5% Loss. The Cost Showed Up in the Good Years.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/schwab-s-dividend-etf-s-worst-year-since-2012-was-a-5-5-loss-the-cost-showed-up-in-the-good-years/", + "published": 1791715322.0, + "ticker": "NVDA" + }, + { + "headline": "Meet the Only AI Stock I'd Buy Over Nvidia in October", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/meet-the-only-ai-stock-id-buy-over-nvidia-in-octob/", + "published": 1791715020.0, + "ticker": "NVDA" + }, + { + "headline": "Symbotic Has a $22.5 Billion Backlog. Here's Why It Could Be the Best Robotics Stock Nobody Talks About.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/symbotic-billion-backlog-why-robotic-stock/", + "published": 1791714900.0, + "ticker": "NVDA" + }, + { + "headline": "3 Things to Watch When Taiwan Semiconductor (TSMC) Reports Earnings on Oct. 15", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/3-things-to-watch-when-taiwan-semiconductor-report/", + "published": 1791714300.0, + "ticker": "NVDA" + }, + { + "headline": "1 Reason to Buy Carnival Stock (CCL) in October", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/1-reason-to-buy-carnival-stock-ccl-in-october/", + "published": 1791714180.0, + "ticker": "NVDA" + }, + { + "headline": "3 Surging Cryptocurrencies to Buy Now and Hold for 5 Years or More", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/3-surging-cryptocurrencies-to-buy-now-and-hold-for/", + "published": 1791714000.0, + "ticker": "NVDA" + }, + { + "headline": "By 1995, A Retired IRS Auditor Had Turned $5,000 Into $22 Million Using A Surprisingly Boring Investing Strategy.", + "source": "24/7 Wall St", + "url": "https://247wallst.com/investing/2026/10/11/by-1995-a-retired-irs-auditor-had-turned-5000-into-22-million-using-a-surprisingly-boring-investing-strategy/", + "published": 1791713875.0, + "ticker": "KO" + }, + { + "headline": "Vanguard's S&P 500 Index Fund Charges About $3 a Year on $10,000. The Average Stock Fund Charges About $40.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/vanguard-s-s-and-p-500-index-fund-charges-about-usd3-a-year-on-usd10-000-the-average-stock-fund-charges-about-usd40/", + "published": 1791713221.0, + "ticker": "NVDA" + }, + { + "headline": "Crypto Investors Can Now Buy a Cathie Wood Investment Fund That Holds SpaceX and OpenAI. Here's What You Need to Know to Get Started.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/crypto-investors-can-now-buy-a-cathie-wood-investm/", + "published": 1791713100.0, + "ticker": "NVDA" + }, + { + "headline": "Welcome back to earnings season: What to watch this week", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/article/welcome-back-to-earnings-season-what-to-watch-this-week-100000102.html?.tsrc=rss", + "published": 1791712800.0, + "ticker": "JNJ" + }, + { + "headline": "IREN Leans On NVIDIA AI Validation As SemiAnalysis Questions Its Infrastructure Claims", + "source": "Stocktwits", + "url": "https://stocktwits.com/news-articles/markets/equity/iren-leans-on-nvidia-ai-validation-as-semi-analysis-questions-its-infrastructure-claims/cZx5zBmRBFG", + "published": 1791712343.0, + "ticker": "BTC-USD" + }, + { + "headline": "Bank of America Pays Out $0.32 Per Share Each Quarter. Here's How Many Shares You'd Need for $1,000 a Year in Dividends.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/bank-of-america-payout-shares-dividend/", + "published": 1791712200.0, + "ticker": "NVDA" + }, + { + "headline": "Tim Cook Told Apple CEO John Ternus ‘Don’t Mimic Me’ Before Stepping Aside Ahead of Apple’s Big iPhone Launch: 'I’m Not a Meddler'", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/articles/tim-cook-told-apple-ceo-094507099.html?.tsrc=rss", + "published": 1791711907.0, + "ticker": "AAPL" + }, + { + "headline": "Alphabet Is the Robotaxi Stock to Buy as Waymo Chases 1 Million Rides a Week", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/alphabet-is-the-robotaxi-stock-to-buy-as-waymo-chases-1-million-rides-a-week/", + "published": 1791711422.0, + "ticker": "GOOGL" + }, + { + "headline": "As GLP-1 Drugs Change How People Eat, Drugmakers Collect and Snack Makers Feel the Squeeze", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/as-glp-1-drugs-change-how-people-eat-drugmakers-co/", + "published": 1791711300.0, + "ticker": "NVDA" + }, + { + "headline": "Prediction: This Is How Much Micron Stock Will Be Worth By the End of 2027", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/prediction-this-is-how-much-micron-stock-will-be-worth-by-the-end-of-2027/", + "published": 1791711002.0, + "ticker": "NVDA" + }, + { + "headline": "Solana ETFs Just Set a New Record With $188 Million in Weekly Inflows. Here's Why I'm Bullish on Solana for 2027.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/solana-etfs-just-set-a-new-record-with-188-million/", + "published": 1791710640.0, + "ticker": "NVDA" + }, + { + "headline": "AbbVie's Dividend Yield Is at Its Lowest Level Since 2018. Here's Why the Stock Is Still a Great Pick for Income Investors Anyway.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/abbvies-dividend-yield-is-at-its-lowest-level-sinc/", + "published": 1791709440.0, + "ticker": "NVDA" + }, + { + "headline": "Costco Stock Is 14% Below Its Record. History Says Buyers Have Usually Been Rewarded.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/costco-stock-is-14-below-its-record-history-says-buyers-have-usually-been-rewarded/", + "published": 1791708062.0, + "ticker": "NVDA" + }, + { + "headline": "Grail Stock Hit a 12-Month High After Its FDA Panel Win. But Here's the Risk Investors Shouldn't Ignore.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/grail-stock-hit-12-month-high-fda-panel-win-risk/", + "published": 1791707460.0, + "ticker": "NVDA" + }, + { + "headline": "Siri AI at Day 28: Apple Ships the HomePad Screen Oct 13 — but the Enterprise Half Doesn’t Exist", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/ai/articles/siri-ai-day-28-apple-082748320.html?.tsrc=rss", + "published": 1791707268.0, + "ticker": "AAPL" + }, + { + "headline": "President Donald Trump Has Floated the Idea of a Federal Gas Tax Holiday, but It Would Likely Backfire", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/donald-trump-float-idea-federal-gas-tax-holiday-would-likely-backfire/", + "published": 1791707161.0, + "ticker": "NVDA" + }, + { + "headline": "Four Years In, $40 Trillion Up: Why This AI-Fueled Bull Market Has Room to Run", + "source": "via Yahoo Finance", + "url": "https://finance.yahoo.com/m/4a6ddf57-8100-34f1-89a7-923bd93d7e6d/four-years-in%2C-%2440-trillion.html?.tsrc=rss", + "published": 1791705600.0, + "ticker": "AAPL" + }, + { + "headline": "Most Investors Are Wrong About Who Owns the Most Bitcoin. That Misunderstanding Creates a Buying Opportunity.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/11/most-investors-are-wrong-about-who-owns-the-most-b/", + "published": 1791701820.0, + "ticker": "BTC-USD" + }, + { + "headline": "Why Infosys Can Shrug Off the US Green-Card Suspension, For Now", + "source": "tikr.com", + "url": "https://www.tikr.com/blog/why-infosys-can-shrug-off-the-us-green-card-suspension-for-now?ref=yahoofinance&.tsrc=rss", + "published": 1791701777.0, + "ticker": "MSFT" + }, + { + "headline": "Bitcoin struggles above $83,000 a year after $19 billion crypto crash", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/crypto/articles/bitcoin-struggles-above-83-000-055426754.html?.tsrc=rss", + "published": 1791698066.0, + "ticker": "BTC-USD" + }, + { + "headline": "Tesla Renames Full Self-Driving Package to Assisted Driving Across Europe", + "source": "tikr.com", + "url": "https://www.tikr.com/blog/tesla-renames-fsd-assisted-driving-europe?ref=yahoofinance&.tsrc=rss", + "published": 1791695503.0, + "ticker": "TSLA" + }, + { + "headline": "Is Walmart (WMT) Taking Too Long To Make Supply Chain Automation Pay Off?", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/walmart-wmt-taking-too-long-041848805.html?.tsrc=rss", + "published": 1791692328.0, + "ticker": "WMT" + }, + { + "headline": "International Business Machines (IBM) Unveils Real Time App Security Collaboration", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/articles/international-business-machines-ibm-unveils-031626945.html?.tsrc=rss", + "published": 1791688586.0, + "ticker": "IBM" + }, + { + "headline": "Meta (META) Blocks Rival Ads Across Multiple Countries", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/ai/articles/meta-meta-blocks-rival-ads-031618784.html?.tsrc=rss", + "published": 1791688578.0, + "ticker": "META" + }, + { + "headline": "How Much Would You Need to Invest in These 5 Pharma Stocks to Earn $1,000 a Month in Dividends?", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/10/how-much-would-you-need-to-invest-in-these-5-pharm/", + "published": 1791686100.0, + "ticker": "AMGN" + }, + { + "headline": "Can CoreWeave Unseat Amazon Web Services as the Top Cloud Provider?", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/10/can-coreweave-unseat-amazon-web-services-as-the-to/", + "published": 1791675000.0, + "ticker": "AMZN" + }, + { + "headline": "Sam's Club is letting its 200,000 members decide what it sells", + "source": "TheStreet", + "url": "https://www.thestreet.com/retail/sams-club-200000-members-decide-what-it-sells", + "published": 1791673620.0, + "ticker": "WMT" + }, + { + "headline": "A COVID Vaccine Stock on the S&P 500 Is Up 600% This Year", + "source": "beincrypto.com", + "url": "https://beincrypto.com/moderna-stock-best-sp-500-2026/", + "published": 1791671034.0, + "ticker": "MRK" + }, + { + "headline": "Walt Disney’s CEO explains why box office bombs may no longer matter", + "source": "TheStreet", + "url": "https://www.thestreet.com/entertainment/walt-disney-plans-to-give-fans-more-of-what-they-love", + "published": 1791670797.0, + "ticker": "DIS" + }, + { + "headline": "Is AT&T (T) Ready for a Starlink World After Its Latest Fiber and iPhone Duo Moves?", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/t-t-ready-starlink-world-221137273.html?.tsrc=rss", + "published": 1791670297.0, + "ticker": "AAPL" + }, + { + "headline": "Amazon (AMZN) Wants Investors to Finance $8 Billion of Nvidia Chips. Is AI Spending Getting Too Heavy?", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/ai/articles/amazon-amzn-wants-investors-finance-221126922.html?.tsrc=rss", + "published": 1791670286.0, + "ticker": "AMZN" + }, + { + "headline": "Merck Presents One-Year Results from the Pivotal Phase 2b/3 BRUNELLO Study Evaluating Remigromig, a Tri-specific Agonist of the Wingless-related Integration Site (Wnt) Pathway, in Adults with Diabetic Macular Edema at AAO 2026", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/healthcare/articles/merck-presents-one-results-pivotal-221000461.html?.tsrc=rss", + "published": 1791670200.0, + "ticker": "MRK" + }, + { + "headline": "Solana Confirms a Transaction in 200 Milliseconds and Bitcoin Takes 10 Minutes. Does Speed Make a Coin Worth More?", + "source": "24/7 Wall St", + "url": "https://247wallst.com/investing/cryptocurrency/2026/10/10/solana-confirms-a-transaction-in-200-milliseconds-and-bitcoin-takes-10-minutes-does-speed-make-a-coin-worth-more/", + "published": 1791670030.0, + "ticker": "BTC-USD" + }, + { + "headline": "Veeva Systems’ (VEEV) Comeback Gets Jim Cramer Talking", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/ai/articles/veeva-systems-veev-comeback-gets-215549111.html?.tsrc=rss", + "published": 1791669349.0, + "ticker": "AMGN" + }, + { + "headline": "Walmart CEO pledges to avoid shady pricing practice", + "source": "TheStreet", + "url": "https://www.thestreet.com/retail/walmart-ceo-pledges-to-avoid-shady-pricing-practice", + "published": 1791667980.0, + "ticker": "WMT" + }, + { + "headline": "Whales Bought 15,000 Bitcoin in Three Days While MARA Sold $81 Million. Who Has It Right?", + "source": "24/7 Wall St", + "url": "https://247wallst.com/investing/cryptocurrency/2026/10/10/whales-bought-15000-bitcoin-in-three-days-while-mara-sold-81-million-who-has-it-right/", + "published": 1791667838.0, + "ticker": "BTC-USD" + }, + { + "headline": "How to Earn $500 a Month From Goldman Sachs Stock Ahead of Q3 Earnings", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/markets/stocks/articles/earn-500-month-goldman-sachs-213021867.html?.tsrc=rss", + "published": 1791667821.0, + "ticker": "GS" + }, + { + "headline": "VOO Doesn't Hold SpaceX, Taiwan Semiconductor, Samsung Electronics, SK Hynix, or ASML. Meet the Vanguard ETF That Does.", + "source": "Motley Fool", + "url": "https://www.fool.com/investing/2026/10/10/voo-doesnt-own-spacex-taiwan-semiconductor-samsung/", + "published": 1791665493.0, + "ticker": "BRK-B" + }, + { + "headline": "Infosys sees no material impact from U.S. PERM suspension", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/articles/infosys-sees-no-material-impact-204406039.html?.tsrc=rss", + "published": 1791665046.0, + "ticker": "MSFT" + }, + { + "headline": "Eight Altcoins Have Surged Three Times More Than Bitcoin in 30 Days. Is Altcoin Season Here, or Is This Just a Momentary Uptick?", + "source": "24/7 Wall St", + "url": "https://247wallst.com/investing/cryptocurrency/2026/10/10/eight-altcoins-have-surged-three-times-more-than-bitcoin-in-30-days-is-altcoin-season-here-or-is-this-just-a-momentary-uptick/", + "published": 1791663319.0, + "ticker": "BTC-USD" + }, + { + "headline": "Trump awards science medals to Musk, Nadella and 4 other tech titans — 5 immigrants — hours after Vance targets them", + "source": "Yahoo Finance", + "url": "https://finance.yahoo.com/technology/articles/trump-awards-science-medals-musk-201500010.html?.tsrc=rss", + "published": 1791663300.0, + "ticker": "MSFT" + } + ] +} diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html index efd7d647..f28d8a44 100644 --- a/dashboard/frontend/app.html +++ b/dashboard/frontend/app.html @@ -2521,7 +2521,7 @@

Refund Credits purchase

- +