Phase 1: initial codes
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user