From 07de8ca5cc62727f52b1be867f00721890b17fce Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 16 May 2026 00:15:35 -0700 Subject: Redesign news tab UI surface --- components/news.py | 453 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 378 insertions(+), 75 deletions(-) diff --git a/components/news.py b/components/news.py index cb43ea8..90f0ddb 100644 --- a/components/news.py +++ b/components/news.py @@ -1,29 +1,36 @@ -"""News feed with sentiment badges.""" -import streamlit as st -from datetime import datetime -from services.news_service import get_company_news, get_news_sentiment +"""News tab rendered as a client-side HTML surface.""" +from datetime import date as _date +from datetime import datetime as _dt +from html import escape as _esc + +import streamlit.components.v1 as components + +from services.data_service import get_company_info, get_latest_price from services.fmp_service import get_company_news as get_fmp_news -from utils.security import escape_html, validate_outbound_url +from services.news_service import get_company_news, get_news_sentiment +from utils.security import validate_outbound_url +_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} -def _sentiment_badge(sentiment: str) -> str: - badges = { - "bullish": "๐ŸŸข Bullish (heuristic)", - "bearish": "๐Ÿ”ด Bearish (heuristic)", - "neutral": "โšช Neutral (heuristic)", - } - return badges.get(sentiment.lower(), "โšช Neutral (heuristic)") + +def _safe_float(value): + try: + return float(value) + except (TypeError, ValueError): + return None def _classify_sentiment(article: dict) -> str: - """Classify sentiment from Finnhub article data.""" - # Finnhub doesn't return per-article sentiment, use headline heuristics - positive = ["beats", "surges", "rises", "gains", "profit", "record", "upgrade", - "buy", "outperform", "growth", "strong", "higher", "rally"] - negative = ["misses", "falls", "drops", "loss", "cut", "downgrade", "sell", - "underperform", "weak", "lower", "decline", "warning", "layoff"] + positive = [ + "beats", "surges", "rises", "gains", "profit", "record", "upgrade", "buy", "outperform", + "growth", "strong", "higher", "rally", "raises", "expands", "upside", + ] + negative = [ + "misses", "falls", "drops", "loss", "cut", "downgrade", "sell", "underperform", "weak", + "lower", "decline", "warning", "layoff", "lawsuit", "probe", "cuts", + ] headline = (article.get("headline") or article.get("title") or "").lower() - summary = (article.get("summary") or "").lower() + summary = (article.get("summary") or article.get("text") or "").lower() text = headline + " " + summary pos = sum(1 for w in positive if w in text) neg = sum(1 for w in negative if w in text) @@ -34,66 +41,362 @@ def _classify_sentiment(article: dict) -> str: return "neutral" -def _fmt_time(timestamp) -> str: +def _normalize_dt(raw): + if raw is None: + return None + if isinstance(raw, _dt): + return raw + if isinstance(raw, _date): + return _dt(raw.year, raw.month, raw.day) + if isinstance(raw, (int, float)): + try: + return _dt.utcfromtimestamp(float(raw)) + except Exception: + return None + + text = str(raw).strip() + if not text: + return None + + norm = text.replace("Z", "+00:00") try: - if isinstance(timestamp, (int, float)): - return datetime.utcfromtimestamp(timestamp).strftime("%b %d, %Y") - return str(timestamp)[:10] + dt = _dt.fromisoformat(norm) + if dt.tzinfo is not None: + return dt.astimezone().replace(tzinfo=None) + return dt except Exception: - return "" + pass + + for fmt in ( + "%Y-%m-%d", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%d %H:%M", + "%m/%d/%Y", + ): + try: + return _dt.strptime(text[: len(fmt)], fmt) + except Exception: + pass + + if len(text) >= 10: + try: + return _dt.strptime(text[:10], "%Y-%m-%d") + except Exception: + pass + + return None def render_news(ticker: str): - # Overall sentiment summary from Finnhub - sentiment_data = get_news_sentiment(ticker) - if sentiment_data: - buzz = sentiment_data.get("buzz", {}) - score = sentiment_data.get("sentiment", {}) - col1, col2, col3 = st.columns(3) - col1.metric("Articles (7d)", buzz.get("articlesInLastWeek", "โ€”")) - bull_pct = score.get("bullishPercent") - bear_pct = score.get("bearishPercent") - col2.metric("Bullish %", f"{bull_pct * 100:.1f}%" if bull_pct is not None else "โ€”") - col3.metric("Bearish %", f"{bear_pct * 100:.1f}%" if bear_pct is not None else "โ€”") - st.caption("Sentiment tags below are rule-based headline heuristics, not model-scored article sentiment.") - st.divider() - - # Fetch articles โ€” Finnhub first, FMP as fallback - articles = get_company_news(ticker) - if not articles: - articles = get_fmp_news(ticker) - - if not articles: - st.info("No recent news found.") - return - - for article in articles: - headline = article.get("headline") or article.get("title", "No title") - source = article.get("source") or article.get("site", "") - url = validate_outbound_url(article.get("url") or article.get("newsURL")) - timestamp = article.get("datetime") or article.get("publishedDate", "") - summary = article.get("summary") or article.get("text") or "" - headline_html = escape_html(headline) + import json as _json + + info = get_company_info(ticker) or {} + price = get_latest_price(ticker) + sentiment_data = get_news_sentiment(ticker) or {} + # Finnhub first, FMP fallback. + raw_articles = get_company_news(ticker) or [] + provider = "finnhub" + if not raw_articles: + raw_articles = get_fmp_news(ticker) or [] + provider = "fmp" + + rows = [] + for i, article in enumerate(raw_articles): + article = article or {} + title = str(article.get("headline") or article.get("title") or "").strip() + if not title: + title = "Untitled item" + + source = str(article.get("source") or article.get("site") or "").strip() or "Unknown source" + summary = str(article.get("summary") or article.get("text") or "").strip() + dt_obj = _normalize_dt(article.get("datetime") or article.get("publishedDate")) + ts = int(dt_obj.timestamp()) if dt_obj else None + date_iso = dt_obj.strftime("%Y-%m-%d") if dt_obj else None + date_pretty = dt_obj.strftime("%b %d, %Y") if dt_obj else "โ€”" + time_pretty = dt_obj.strftime("%H:%M") if dt_obj else "โ€”" + url = validate_outbound_url(article.get("url") or article.get("newsURL")) sentiment = _classify_sentiment(article) - badge = _sentiment_badge(sentiment) - time_str = _fmt_time(timestamp) - - with st.container(): - col1, col2 = st.columns([5, 1]) - with col1: - if url: - st.markdown( - f'{headline_html}', - unsafe_allow_html=True, - ) - else: - st.markdown(f"{headline_html}", unsafe_allow_html=True) - meta = " ยท ".join(filter(None, [source, time_str])) - if meta: - st.caption(meta) - if summary: - st.caption(summary[:200] + ("โ€ฆ" if len(summary) > 200 else "")) - with col2: - st.markdown(badge) - st.divider() + + rows.append( + { + "id": i, + "title": title, + "summary": summary, + "source": source, + "url": url, + "sentiment": sentiment, + "date": date_iso, + "date_pretty": date_pretty, + "time_pretty": time_pretty, + "ts": ts, + } + ) + + rows.sort(key=lambda r: (r.get("ts") is not None, r.get("ts") or 0), reverse=True) + + buzz = sentiment_data.get("buzz") or {} + score = sentiment_data.get("sentiment") or {} + bull_pct = score.get("bullishPercent") + bear_pct = score.get("bearishPercent") + weekly_articles = buzz.get("articlesInLastWeek") + + bull_str = f"{float(bull_pct) * 100:.1f}%" if _safe_float(bull_pct) is not None else "โ€”" + bear_str = f"{float(bear_pct) * 100:.1f}%" if _safe_float(bear_pct) is not None else "โ€”" + + cur_num = _safe_float(info.get("currentPrice") or info.get("regularMarketPrice") or price) + prev_num = _safe_float(info.get("previousClose")) + if cur_num is not None and prev_num is not None and prev_num > 0: + chg_pct = (cur_num - prev_num) / prev_num * 100.0 + chg_sign = "+" if chg_pct >= 0 else "" + chg_arrow = "โ–ฒ" if chg_pct >= 0 else "โ–ผ" + chg_str = chg_arrow + " " + chg_sign + "{:.2f}%".format(chg_pct) + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str = "โ€”" + chg_cls = "" + + exchange_raw = str(info.get("exchange") or "") + exchange = _XMAP.get(exchange_raw, exchange_raw) or "โ€”" + co_name = _esc(info.get("longName") or info.get("shortName") or ticker.upper()) + price_str = "${:,.2f}".format(cur_num) if cur_num is not None else "โ€”" + + rows_js = "const NEWS_ROWS=" + _json.dumps(rows) + ";" + + n_rows = max(len(rows), 18) + height = 1240 + n_rows * 28 + + _ROOT = ( + "" + ) + + fonts_link = ( + "" + "" + ) + + _NEWS_CSS = """""" + + ctx_html = ( + '
' + + '' + + _esc(ticker.upper()) + + "" + + '' + + co_name + + "" + + 'News' + + '
' + + "" + + _esc(exchange) + + "" + + '' + + price_str + + "" + + '' + + chg_str + + "" + + "
" + ) + + lede_html = ( + '
' + + '
' + + 'Coverage' + + '
What the tape is saying
' + + '

Headline flow for ' + + _esc(ticker.upper()) + + '. Read source mix, timing, and sentiment tags in one stream. Sentiment chips are rule-based heuristics from headline and summary language.

' + + "
" + + '
' + + '
Primary feed' + + _esc(provider) + + "
" + + '
Articles (7d)' + + (str(weekly_articles) if weekly_articles is not None else "โ€”") + + "
" + + '
Loaded rows' + + str(len(rows)) + + "
" + + "
" + ) + + controls_html = ( + '
' + + '
' + + 'Range' + + '' + + '' + + '' + + '' + + "
" + + '
Source
' + + '
' + + 'Sentiment' + + '' + + '' + + '' + + '' + + "
" + ) + + foot_html = ( + '
' + + "News feed sourced from Finnhub (with FMP fallback) ยท Sentiment chips are heuristic labels, not model-scored article sentiment ยท Outbound links are validated before render" + + "
" + ) + + js = ( + "" + ) + + doc = ( + "" + + fonts_link + + _ROOT + + _NEWS_CSS + + "
" + + ctx_html + + '
' + + lede_html + + controls_html + + '
' + + '
' + + foot_html + + "
" + + js + + "
" + ) + + components.html(doc, height=height, scrolling=False) -- cgit v1.3-2-g0d8e