228 lines
8.4 KiB
Python
228 lines
8.4 KiB
Python
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)
|