"""News tab rendered as a client-side HTML surface.""" from datetime import date as _date from datetime import datetime as _dt from datetime import timezone as _tz 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 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 _safe_float(value): try: return float(value) except (TypeError, ValueError): return None def _classify_sentiment(article: dict) -> str: 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 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) if pos > neg: return "bullish" if neg > pos: return "bearish" return "neutral" 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.fromtimestamp(float(raw), tz=_tz.utc).astimezone().replace(tzinfo=None) except Exception: return None text = str(raw).strip() if not text: return None norm = text.replace("Z", "+00:00") try: dt = _dt.fromisoformat(norm) if dt.tzinfo is not None: return dt.astimezone().replace(tzinfo=None) return dt except Exception: 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): 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) 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).replace("*,*::before,*::after{box-sizing:border-box}" ":root{" "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;" "--oxford:#1F3D5C;--oxford-light:#2E5A87;" "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" "--font-display:'EB Garamond',Georgia,serif;" "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" "}" "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);" "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" "" ) 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)