from __future__ import annotations from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QListWidget, QListWidgetItem, QFrame, QProgressBar, QSizePolicy ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl from PyQt6.QtGui import QColor, QFont, QDesktopServices from utils.formatters import pnl_color SENTIMENT_COLORS = { "BULLISH": "#a6e3a1", "BEARISH": "#f38ba8", "NEUTRAL": "#6c7086", "": "#6c7086", } class NewsWorker(QThread): articles_ready = pyqtSignal(list) error = pyqtSignal(str) def __init__(self, symbol: str, api_key: str, analyze: bool, claude_client=None): super().__init__() self._symbol = symbol self._api_key = api_key self._analyze = analyze self._claude = claude_client def run(self): try: from ai.sentiment import fetch_news, analyze_sentiment, cache_news, get_cached_news cached = get_cached_news(self._symbol) if cached: self.articles_ready.emit(cached) return articles = fetch_news(self._symbol, self._api_key) if not articles: self.articles_ready.emit([]) return overall_sentiment = "" if self._analyze and self._claude and articles: try: sentiment_text, _ = analyze_sentiment(self._symbol, articles, self._claude) if "BULLISH" in sentiment_text: overall_sentiment = "BULLISH" elif "BEARISH" in sentiment_text: overall_sentiment = "BEARISH" else: overall_sentiment = "NEUTRAL" for a in articles: a["sentiment"] = overall_sentiment except Exception: pass cache_news(self._symbol, articles, overall_sentiment) self.articles_ready.emit(articles) except Exception as e: self.error.emit(str(e)) class NewsItemWidget(QFrame): def __init__(self, article: dict, parent=None): super().__init__(parent) self.setObjectName("card") self.setCursor(Qt.CursorShape.PointingHandCursor) self._url = article.get("url", "") layout = QVBoxLayout(self) layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(4) top = QHBoxLayout() title = QLabel(article.get("title", "")) title.setWordWrap(True) title.setFont(QFont("Segoe UI", 9, QFont.Weight.Bold)) top.addWidget(title, stretch=1) sentiment = article.get("sentiment", "") if sentiment: badge = QLabel(f" {sentiment} ") badge_color = SENTIMENT_COLORS.get(sentiment.upper(), "#6c7086") badge.setStyleSheet(f"background: {badge_color}; color: #1e1e2e; border-radius: 4px; padding: 1px 4px; font-size: 8pt; font-weight: bold;") badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) top.addWidget(badge) layout.addLayout(top) meta = QLabel(f"{article.get('source', '')} · {article.get('published_at', '')[:10]}") meta.setObjectName("subtitle") layout.addWidget(meta) desc = article.get("description", "") if desc: desc_label = QLabel(desc[:200] + ("…" if len(desc) > 200 else "")) desc_label.setWordWrap(True) desc_label.setObjectName("subtitle") layout.addWidget(desc_label) def mousePressEvent(self, event): if self._url: QDesktopServices.openUrl(QUrl(self._url)) super().mousePressEvent(event) class NewsWidget(QWidget): def __init__(self, config, parent=None): super().__init__(parent) self._config = config self._symbol = "AAPL" self._worker: NewsWorker | None = None self._setup_ui() def _setup_ui(self): root = QVBoxLayout(self) root.setContentsMargins(12, 12, 12, 12) root.setSpacing(8) header_row = QHBoxLayout() header = QLabel("News & Sentiment") header.setObjectName("title") header_row.addWidget(header) header_row.addStretch() self._symbol_label = QLabel(self._symbol) self._symbol_label.setObjectName("subtitle") header_row.addWidget(self._symbol_label) self._fetch_btn = QPushButton("Fetch News") self._fetch_btn.setObjectName("primary_btn") self._fetch_btn.clicked.connect(self._fetch_news) header_row.addWidget(self._fetch_btn) root.addLayout(header_row) # Sentiment gauge row self._sentiment_frame = QFrame() self._sentiment_frame.setObjectName("card") self._sentiment_frame.setMaximumHeight(60) sent_layout = QHBoxLayout(self._sentiment_frame) self._overall_label = QLabel("Overall Sentiment: —") self._overall_label.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) self._gauge = QProgressBar() self._gauge.setRange(0, 100) self._gauge.setValue(50) self._gauge.setMaximumWidth(200) self._gauge.setFormat("") sent_layout.addWidget(self._overall_label) sent_layout.addStretch() sent_layout.addWidget(QLabel("Bearish")) sent_layout.addWidget(self._gauge) sent_layout.addWidget(QLabel("Bullish")) root.addWidget(self._sentiment_frame) # Articles scroll area from PyQt6.QtWidgets import QScrollArea scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) self._articles_widget = QWidget() self._articles_layout = QVBoxLayout(self._articles_widget) self._articles_layout.setSpacing(8) self._articles_layout.setContentsMargins(0, 0, 0, 0) self._articles_layout.addStretch() scroll.setWidget(self._articles_widget) root.addWidget(scroll) self._status_label = QLabel("") self._status_label.setObjectName("subtitle") root.addWidget(self._status_label) def set_symbol(self, symbol: str): self._symbol = symbol self._symbol_label.setText(symbol) def _fetch_news(self): api_key = self._config.news_api_key if not api_key: self._status_label.setText("NewsAPI key not configured. Go to Settings.") return self._fetch_btn.setEnabled(False) self._status_label.setText(f"Fetching news for {self._symbol}…") claude = None if self._config.anthropic_key: try: from ai.claude_client import ClaudeClient claude = ClaudeClient(self._config.anthropic_key) except Exception: pass self._worker = NewsWorker(self._symbol, api_key, analyze=bool(claude), claude_client=claude) self._worker.articles_ready.connect(self._on_articles_ready) self._worker.error.connect(lambda e: (self._status_label.setText(f"Error: {e}"), self._fetch_btn.setEnabled(True))) self._worker.start() def _on_articles_ready(self, articles: list[dict]): # Clear existing articles while self._articles_layout.count() > 1: item = self._articles_layout.takeAt(0) if item.widget(): item.widget().deleteLater() for article in articles: widget = NewsItemWidget(article) self._articles_layout.insertWidget(self._articles_layout.count() - 1, widget) # Update sentiment gauge sentiments = [a.get("sentiment", "").upper() for a in articles if a.get("sentiment")] if sentiments: bullish = sentiments.count("BULLISH") bearish = sentiments.count("BEARISH") total = len(sentiments) score = int((bullish / total) * 100) if total else 50 self._gauge.setValue(score) dominant = "BULLISH" if bullish > bearish else ("BEARISH" if bearish > bullish else "NEUTRAL") color = SENTIMENT_COLORS.get(dominant, "#6c7086") self._overall_label.setText(f"Overall: {dominant}") self._overall_label.setStyleSheet(f"color: {color};") else: self._gauge.setValue(50) self._overall_label.setText("Overall Sentiment: —") self._status_label.setText(f"{len(articles)} articles | 100 req/day limit applies") self._fetch_btn.setEnabled(True)