diff --git a/stockmind/ai/__init__.py b/stockmind/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stockmind/ai/claude_client.py b/stockmind/ai/claude_client.py new file mode 100644 index 0000000..d61e5a2 --- /dev/null +++ b/stockmind/ai/claude_client.py @@ -0,0 +1,50 @@ +from __future__ import annotations +import anthropic + + +INPUT_COST_PER_1M = 3.0 # claude-sonnet-4-6 $/1M tokens +OUTPUT_COST_PER_1M = 15.0 + + +class ClaudeClient: + MODEL = "claude-sonnet-4-6" + + def __init__(self, api_key: str): + self._client = anthropic.Anthropic(api_key=api_key) + + def ask( + self, + prompt: str, + system: str = "", + max_tokens: int = 1024, + ) -> tuple[str, dict]: + messages = [{"role": "user", "content": prompt}] + kwargs = {"model": self.MODEL, "max_tokens": max_tokens, "messages": messages} + if system: + kwargs["system"] = system + + response = self._client.messages.create(**kwargs) + text = response.content[0].text if response.content else "" + usage = { + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + "cost_usd": self._estimate_cost(response.usage.input_tokens, response.usage.output_tokens), + } + return text, usage + + def stream_ask(self, prompt: str, system: str = "", max_tokens: int = 1024): + messages = [{"role": "user", "content": prompt}] + kwargs = {"model": self.MODEL, "max_tokens": max_tokens, "messages": messages} + if system: + kwargs["system"] = system + + with self._client.messages.stream(**kwargs) as stream: + for text in stream.text_stream: + yield text + + def _estimate_cost(self, input_tokens: int, output_tokens: int) -> float: + return (input_tokens / 1_000_000 * INPUT_COST_PER_1M) + (output_tokens / 1_000_000 * OUTPUT_COST_PER_1M) + + @staticmethod + def estimate_tokens(text: str) -> int: + return max(1, len(text) // 4) diff --git a/stockmind/ai/prompts.py b/stockmind/ai/prompts.py new file mode 100644 index 0000000..672dc39 --- /dev/null +++ b/stockmind/ai/prompts.py @@ -0,0 +1,94 @@ +STOCK_SUMMARY_SYSTEM = "You are a professional equity analyst. Be concise, factual, and avoid hype." + +STOCK_SUMMARY_PROMPT = """ +Ticker: {symbol} +Company: {name} +Sector: {sector} +Industry: {industry} +Market Cap: {market_cap} +P/E Ratio: {pe_ratio} +EPS: {eps} +52W High: {week_52_high} | 52W Low: {week_52_low} +Dividend Yield: {dividend_yield} + +Recent News Headlines: +{headlines} + +Write a concise 3-paragraph stock summary: +1. Business overview and recent performance +2. Key financial metrics analysis +3. Near-term catalysts and risks +""" + +TECHNICAL_READ_SYSTEM = "You are a technical analyst specializing in chart pattern recognition." + +TECHNICAL_READ_PROMPT = """ +Ticker: {symbol} +Period Analyzed: {period} +Current Price: {price} +SMA20: {sma20} | SMA50: {sma50} +RSI(14): {rsi} +MACD: {macd} | Signal: {macd_signal} +Bollinger Bands: Upper {bb_upper} | Lower {bb_lower} +Recent price action: {price_action} + +Provide a technical analysis in 2-3 paragraphs covering: +1. Current trend, key support/resistance levels +2. Indicator readings and what they signal +3. Actionable technical outlook (bullish/bearish/neutral) +""" + +SENTIMENT_SCORE_SYSTEM = "You are a financial sentiment analyst. Classify sentiment precisely." + +SENTIMENT_SCORE_PROMPT = """ +Ticker: {symbol} +Analyze the sentiment of these news headlines: + +{headlines} + +Respond in this exact format: +OVERALL: [BULLISH/BEARISH/NEUTRAL] +SCORE: [0-100 where 0=extreme bearish, 50=neutral, 100=extreme bullish] +REASONING: [2-3 sentences explaining the dominant themes] +HEADLINE_BREAKDOWN: +[For each headline: + or - or ~ and one line explanation] +""" + +PRICE_OUTLOOK_SYSTEM = "You are a quantitative strategist. Base your outlook on data, not speculation." + +PRICE_OUTLOOK_PROMPT = """ +Ticker: {symbol} +Current Price: {price} +Technical snapshot: RSI={rsi}, trend={trend}, momentum={momentum} +Recent news sentiment: {sentiment} +Sector performance: {sector_perf} + +Provide a short-term price outlook in this format: +1-DAY BIAS: [BULLISH/BEARISH/NEUTRAL] — [confidence %] — [one-line rationale] +1-WEEK BIAS: [BULLISH/BEARISH/NEUTRAL] — [confidence %] — [one-line rationale] +1-MONTH BIAS: [BULLISH/BEARISH/NEUTRAL] — [confidence %] — [one-line rationale] +KEY RISKS: [2 bullet points] +""" + +PORTFOLIO_REVIEW_SYSTEM = "You are a portfolio risk manager. Identify risks clearly and suggest actionable improvements." + +PORTFOLIO_REVIEW_PROMPT = """ +Portfolio Holdings: +{holdings} + +Total Value: {total_value} +Cash Position: {cash} +Largest Position: {top_position} +Sector Allocation: {sector_allocation} + +Provide a portfolio review covering: +1. Concentration risk (any position > 20% or sector > 40%) +2. Correlation risk (holdings that move together) +3. Missing diversification (sectors, asset classes) +4. Top 3 actionable recommendations +""" + +CHAT_SYSTEM = """You are StockMind AI, an expert financial assistant integrated into a stock market application. +You have access to real-time market context provided by the user. Answer questions about stocks, crypto, +market trends, investment strategies, and financial analysis. Be concise and professional. +Always note that your analysis is for informational purposes only and not financial advice.""" diff --git a/stockmind/ai/sentiment.py b/stockmind/ai/sentiment.py new file mode 100644 index 0000000..b93efad --- /dev/null +++ b/stockmind/ai/sentiment.py @@ -0,0 +1,93 @@ +from __future__ import annotations +from datetime import datetime, timedelta +from newsapi import NewsApiClient +from ai.claude_client import ClaudeClient +from ai.prompts import SENTIMENT_SCORE_SYSTEM, SENTIMENT_SCORE_PROMPT + + +def fetch_news(symbol: str, api_key: str, page_size: int = 10) -> list[dict]: + try: + client = NewsApiClient(api_key=api_key) + from_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + response = client.get_everything( + q=symbol, + language="en", + sort_by="publishedAt", + page_size=page_size, + from_param=from_date, + ) + articles = response.get("articles", []) + return [ + { + "title": a.get("title", ""), + "description": a.get("description", ""), + "url": a.get("url", ""), + "source": a.get("source", {}).get("name", ""), + "published_at": a.get("publishedAt", ""), + } + for a in articles + if a.get("title") + ] + except Exception as e: + return [] + + +def analyze_sentiment(symbol: str, articles: list[dict], claude: ClaudeClient) -> tuple[str, dict]: + if not articles: + return "No news available for sentiment analysis.", {} + + headlines = "\n".join( + f"{i + 1}. {a['title']}" for i, a in enumerate(articles[:15]) + ) + prompt = SENTIMENT_SCORE_PROMPT.format(symbol=symbol, headlines=headlines) + text, usage = claude.ask(prompt, system=SENTIMENT_SCORE_SYSTEM, max_tokens=800) + return text, usage + + +def cache_news(symbol: str, articles: list[dict], sentiment: str = "") -> None: + from db.database import get_session + from db.models import NewsCache + from datetime import datetime + + with get_session() as session: + session.query(NewsCache).filter_by(symbol=symbol).delete() + for a in articles: + session.add(NewsCache( + symbol=symbol, + title=a.get("title", ""), + description=a.get("description", ""), + url=a.get("url", ""), + source=a.get("source", ""), + published_at=a.get("published_at", ""), + sentiment=sentiment, + fetched_at=datetime.utcnow(), + )) + session.commit() + + +def get_cached_news(symbol: str, max_age_hours: int = 1) -> list[dict] | None: + from db.database import get_session + from db.models import NewsCache + from datetime import datetime, timedelta + + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + with get_session() as session: + rows = ( + session.query(NewsCache) + .filter(NewsCache.symbol == symbol, NewsCache.fetched_at > cutoff) + .order_by(NewsCache.published_at.desc()) + .all() + ) + if not rows: + return None + return [ + { + "title": r.title, + "description": r.description, + "url": r.url, + "source": r.source, + "published_at": r.published_at, + "sentiment": r.sentiment, + } + for r in rows + ] diff --git a/stockmind/assets/styles/dark.qss b/stockmind/assets/styles/dark.qss new file mode 100644 index 0000000..a931213 --- /dev/null +++ b/stockmind/assets/styles/dark.qss @@ -0,0 +1,364 @@ +/* ─── StockMind Dark Theme ─── */ +* { + font-family: "Segoe UI", "Arial", sans-serif; + font-size: 10pt; + color: #cdd6f4; +} + +QMainWindow, QWidget { + background-color: #1e1e2e; +} + +/* ─── Sidebar ─── */ +#sidebar { + background-color: #181825; + border-right: 1px solid #313244; + min-width: 56px; + max-width: 56px; +} + +#sidebar QPushButton { + background-color: transparent; + border: none; + border-radius: 8px; + padding: 10px; + margin: 2px 6px; + color: #6c7086; + font-size: 9pt; + text-align: center; +} + +#sidebar QPushButton:hover { + background-color: #313244; + color: #cdd6f4; +} + +#sidebar QPushButton:checked, #sidebar QPushButton[active="true"] { + background-color: #89b4fa; + color: #1e1e2e; +} + +#logo_label { + color: #89b4fa; + font-size: 13pt; + font-weight: bold; + padding: 12px 0; +} + +/* ─── Frames / Cards ─── */ +QFrame#card { + background-color: #24273a; + border: 1px solid #313244; + border-radius: 10px; + padding: 8px; +} + +/* ─── Labels ─── */ +QLabel { + background-color: transparent; +} + +QLabel#title { + font-size: 15pt; + font-weight: bold; + color: #cdd6f4; +} + +QLabel#subtitle { + font-size: 9pt; + color: #6c7086; +} + +QLabel#price_label { + font-size: 22pt; + font-weight: bold; + color: #cdd6f4; +} + +QLabel.positive { color: #a6e3a1; } +QLabel.negative { color: #f38ba8; } + +/* ─── Buttons ─── */ +QPushButton { + background-color: #313244; + color: #cdd6f4; + border: 1px solid #45475a; + border-radius: 6px; + padding: 6px 14px; + font-size: 9pt; +} + +QPushButton:hover { + background-color: #45475a; + border-color: #585b70; +} + +QPushButton:pressed { + background-color: #585b70; +} + +QPushButton#primary_btn { + background-color: #89b4fa; + color: #1e1e2e; + border: none; + font-weight: bold; +} + +QPushButton#primary_btn:hover { + background-color: #b4befe; +} + +QPushButton#danger_btn { + background-color: #f38ba8; + color: #1e1e2e; + border: none; +} + +QPushButton#danger_btn:hover { + background-color: #f2c4d0; +} + +QPushButton:disabled { + background-color: #1e1e2e; + color: #45475a; + border-color: #313244; +} + +/* ─── Inputs ─── */ +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + padding: 5px 8px; + color: #cdd6f4; + selection-background-color: #89b4fa; + selection-color: #1e1e2e; +} + +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { + border-color: #89b4fa; +} + +QComboBox::drop-down { + border: none; + width: 20px; +} + +QComboBox::down-arrow { + width: 10px; + height: 10px; +} + +QComboBox QAbstractItemView { + background-color: #24273a; + border: 1px solid #313244; + border-radius: 6px; + selection-background-color: #313244; +} + +/* ─── Tables ─── */ +QTableWidget, QTableView { + background-color: #1e1e2e; + alternate-background-color: #24273a; + border: none; + gridline-color: #313244; + selection-background-color: #313244; + selection-color: #cdd6f4; +} + +QTableWidget::item, QTableView::item { + padding: 4px 8px; + border: none; +} + +QTableWidget::item:selected, QTableView::item:selected { + background-color: #313244; +} + +QHeaderView::section { + background-color: #181825; + color: #6c7086; + border: none; + border-bottom: 1px solid #313244; + padding: 6px 8px; + font-size: 9pt; + font-weight: bold; + text-transform: uppercase; +} + +/* ─── Scrollbars ─── */ +QScrollBar:vertical { + background: transparent; + width: 8px; + border-radius: 4px; +} + +QScrollBar::handle:vertical { + background-color: #45475a; + border-radius: 4px; + min-height: 30px; +} + +QScrollBar::handle:vertical:hover { + background-color: #585b70; +} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } + +QScrollBar:horizontal { + background: transparent; + height: 8px; + border-radius: 4px; +} + +QScrollBar::handle:horizontal { + background-color: #45475a; + border-radius: 4px; + min-width: 30px; +} + +QScrollBar::handle:horizontal:hover { background-color: #585b70; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } + +/* ─── Tabs ─── */ +QTabWidget::pane { + border: 1px solid #313244; + border-radius: 8px; + background-color: #1e1e2e; +} + +QTabBar::tab { + background-color: #24273a; + color: #6c7086; + border: none; + padding: 8px 16px; + border-radius: 6px 6px 0 0; + margin-right: 2px; +} + +QTabBar::tab:selected { + background-color: #313244; + color: #cdd6f4; +} + +QTabBar::tab:hover { + background-color: #313244; + color: #cdd6f4; +} + +/* ─── Text Edit ─── */ +QTextEdit, QPlainTextEdit { + background-color: #181825; + border: 1px solid #313244; + border-radius: 6px; + color: #cdd6f4; + selection-background-color: #89b4fa; + selection-color: #1e1e2e; + padding: 4px; +} + +/* ─── Checkboxes ─── */ +QCheckBox { + spacing: 6px; + color: #cdd6f4; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 2px solid #45475a; + border-radius: 4px; + background: transparent; +} + +QCheckBox::indicator:checked { + background-color: #89b4fa; + border-color: #89b4fa; +} + +/* ─── Splitter ─── */ +QSplitter::handle { + background-color: #313244; +} + +QSplitter::handle:horizontal { width: 2px; } +QSplitter::handle:vertical { height: 2px; } + +/* ─── Status Bar ─── */ +QStatusBar { + background-color: #181825; + border-top: 1px solid #313244; + color: #6c7086; + font-size: 8pt; +} + +/* ─── Tooltips ─── */ +QToolTip { + background-color: #24273a; + color: #cdd6f4; + border: 1px solid #313244; + border-radius: 6px; + padding: 4px 8px; + font-size: 9pt; +} + +/* ─── Group Box ─── */ +QGroupBox { + border: 1px solid #313244; + border-radius: 8px; + margin-top: 12px; + padding-top: 8px; + font-size: 9pt; + color: #6c7086; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #89b4fa; +} + +/* ─── List Widget ─── */ +QListWidget { + background-color: #1e1e2e; + border: none; + alternate-background-color: #24273a; + outline: none; +} + +QListWidget::item { + padding: 6px 10px; + border-radius: 4px; + margin: 1px 4px; +} + +QListWidget::item:selected { + background-color: #313244; + color: #cdd6f4; +} + +QListWidget::item:hover { + background-color: #24273a; +} + +/* ─── Progress Bar ─── */ +QProgressBar { + background-color: #313244; + border: none; + border-radius: 4px; + height: 6px; + text-align: center; + color: transparent; +} + +QProgressBar::chunk { + background-color: #89b4fa; + border-radius: 4px; +} + +/* ─── Dialog ─── */ +QDialog { + background-color: #1e1e2e; +} diff --git a/stockmind/assets/styles/light.qss b/stockmind/assets/styles/light.qss new file mode 100644 index 0000000..9e4a9c1 --- /dev/null +++ b/stockmind/assets/styles/light.qss @@ -0,0 +1,332 @@ +/* ─── StockMind Light Theme ─── */ +* { + font-family: "Segoe UI", "Arial", sans-serif; + font-size: 10pt; + color: #4c4f69; +} + +QMainWindow, QWidget { + background-color: #eff1f5; +} + +/* ─── Sidebar ─── */ +#sidebar { + background-color: #e6e9ef; + border-right: 1px solid #ccd0da; + min-width: 56px; + max-width: 56px; +} + +#sidebar QPushButton { + background-color: transparent; + border: none; + border-radius: 8px; + padding: 10px; + margin: 2px 6px; + color: #9ca0b0; + font-size: 9pt; + text-align: center; +} + +#sidebar QPushButton:hover { + background-color: #ccd0da; + color: #4c4f69; +} + +#sidebar QPushButton:checked, #sidebar QPushButton[active="true"] { + background-color: #1e66f5; + color: #ffffff; +} + +#logo_label { + color: #1e66f5; + font-size: 13pt; + font-weight: bold; + padding: 12px 0; +} + +/* ─── Frames / Cards ─── */ +QFrame#card { + background-color: #ffffff; + border: 1px solid #ccd0da; + border-radius: 10px; + padding: 8px; +} + +/* ─── Labels ─── */ +QLabel { + background-color: transparent; +} + +QLabel#title { + font-size: 15pt; + font-weight: bold; + color: #4c4f69; +} + +QLabel#subtitle { + font-size: 9pt; + color: #9ca0b0; +} + +QLabel#price_label { + font-size: 22pt; + font-weight: bold; + color: #4c4f69; +} + +QLabel.positive { color: #40a02b; } +QLabel.negative { color: #d20f39; } + +/* ─── Buttons ─── */ +QPushButton { + background-color: #e6e9ef; + color: #4c4f69; + border: 1px solid #ccd0da; + border-radius: 6px; + padding: 6px 14px; + font-size: 9pt; +} + +QPushButton:hover { + background-color: #ccd0da; + border-color: #bcc0cc; +} + +QPushButton:pressed { + background-color: #bcc0cc; +} + +QPushButton#primary_btn { + background-color: #1e66f5; + color: #ffffff; + border: none; + font-weight: bold; +} + +QPushButton#primary_btn:hover { + background-color: #2d75ff; +} + +QPushButton#danger_btn { + background-color: #d20f39; + color: #ffffff; + border: none; +} + +QPushButton#danger_btn:hover { + background-color: #e0204a; +} + +QPushButton:disabled { + background-color: #eff1f5; + color: #bcc0cc; + border-color: #ccd0da; +} + +/* ─── Inputs ─── */ +QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { + background-color: #ffffff; + border: 1px solid #ccd0da; + border-radius: 6px; + padding: 5px 8px; + color: #4c4f69; + selection-background-color: #1e66f5; + selection-color: #ffffff; +} + +QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { + border-color: #1e66f5; +} + +QComboBox::drop-down { border: none; width: 20px; } +QComboBox QAbstractItemView { + background-color: #ffffff; + border: 1px solid #ccd0da; + border-radius: 6px; + selection-background-color: #e6e9ef; + color: #4c4f69; +} + +/* ─── Tables ─── */ +QTableWidget, QTableView { + background-color: #ffffff; + alternate-background-color: #f5f5fa; + border: none; + gridline-color: #e6e9ef; + selection-background-color: #e6e9ef; + selection-color: #4c4f69; +} + +QTableWidget::item, QTableView::item { + padding: 4px 8px; + border: none; +} + +QHeaderView::section { + background-color: #e6e9ef; + color: #9ca0b0; + border: none; + border-bottom: 1px solid #ccd0da; + padding: 6px 8px; + font-size: 9pt; + font-weight: bold; + text-transform: uppercase; +} + +/* ─── Scrollbars ─── */ +QScrollBar:vertical { + background: transparent; + width: 8px; + border-radius: 4px; +} +QScrollBar::handle:vertical { + background-color: #ccd0da; + border-radius: 4px; + min-height: 30px; +} +QScrollBar::handle:vertical:hover { background-color: #bcc0cc; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; } + +QScrollBar:horizontal { + background: transparent; + height: 8px; + border-radius: 4px; +} +QScrollBar::handle:horizontal { + background-color: #ccd0da; + border-radius: 4px; + min-width: 30px; +} +QScrollBar::handle:horizontal:hover { background-color: #bcc0cc; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; } + +/* ─── Tabs ─── */ +QTabWidget::pane { + border: 1px solid #ccd0da; + border-radius: 8px; + background-color: #ffffff; +} + +QTabBar::tab { + background-color: #e6e9ef; + color: #9ca0b0; + border: none; + padding: 8px 16px; + border-radius: 6px 6px 0 0; + margin-right: 2px; +} + +QTabBar::tab:selected { + background-color: #ffffff; + color: #4c4f69; +} + +QTabBar::tab:hover { + background-color: #dce0e8; + color: #4c4f69; +} + +/* ─── Text Edit ─── */ +QTextEdit, QPlainTextEdit { + background-color: #ffffff; + border: 1px solid #ccd0da; + border-radius: 6px; + color: #4c4f69; + selection-background-color: #1e66f5; + selection-color: #ffffff; + padding: 4px; +} + +/* ─── Checkboxes ─── */ +QCheckBox { + spacing: 6px; + color: #4c4f69; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 2px solid #ccd0da; + border-radius: 4px; + background: white; +} +QCheckBox::indicator:checked { + background-color: #1e66f5; + border-color: #1e66f5; +} + +/* ─── Splitter ─── */ +QSplitter::handle { background-color: #ccd0da; } +QSplitter::handle:horizontal { width: 2px; } +QSplitter::handle:vertical { height: 2px; } + +/* ─── Status Bar ─── */ +QStatusBar { + background-color: #e6e9ef; + border-top: 1px solid #ccd0da; + color: #9ca0b0; + font-size: 8pt; +} + +/* ─── Tooltips ─── */ +QToolTip { + background-color: #ffffff; + color: #4c4f69; + border: 1px solid #ccd0da; + border-radius: 6px; + padding: 4px 8px; + font-size: 9pt; +} + +/* ─── Group Box ─── */ +QGroupBox { + border: 1px solid #ccd0da; + border-radius: 8px; + margin-top: 12px; + padding-top: 8px; + font-size: 9pt; + color: #9ca0b0; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #1e66f5; +} + +/* ─── List Widget ─── */ +QListWidget { + background-color: #ffffff; + border: none; + alternate-background-color: #f5f5fa; + outline: none; +} +QListWidget::item { + padding: 6px 10px; + border-radius: 4px; + margin: 1px 4px; +} +QListWidget::item:selected { + background-color: #e6e9ef; + color: #4c4f69; +} +QListWidget::item:hover { + background-color: #f0f1f7; +} + +/* ─── Progress Bar ─── */ +QProgressBar { + background-color: #e6e9ef; + border: none; + border-radius: 4px; + height: 6px; + color: transparent; +} +QProgressBar::chunk { + background-color: #1e66f5; + border-radius: 4px; +} + +/* ─── Dialog ─── */ +QDialog { background-color: #eff1f5; } diff --git a/stockmind/brokers/__init__.py b/stockmind/brokers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stockmind/brokers/base_broker.py b/stockmind/brokers/base_broker.py new file mode 100644 index 0000000..9524aad --- /dev/null +++ b/stockmind/brokers/base_broker.py @@ -0,0 +1,60 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Position: + symbol: str + shares: float + avg_cost: float + market_value: float = 0.0 + current_price: float = 0.0 + pnl: float = 0.0 + pnl_pct: float = 0.0 + description: str = "" + + +@dataclass +class AccountBalance: + total_equity: float = 0.0 + cash: float = 0.0 + buying_power: float = 0.0 + day_pnl: float = 0.0 + day_pnl_pct: float = 0.0 + + +@dataclass +class Order: + order_id: str + symbol: str + side: str # buy / sell + quantity: float + price: float + status: str + order_type: str + placed_at: str = "" + + +class BaseBroker(ABC): + name: str = "base" + + @abstractmethod + def connect(self) -> bool: + ... + + @abstractmethod + def is_connected(self) -> bool: + ... + + @abstractmethod + def get_positions(self) -> list[Position]: + ... + + @abstractmethod + def get_balance(self) -> AccountBalance: + ... + + @abstractmethod + def get_orders(self, limit: int = 25) -> list[Order]: + ... diff --git a/stockmind/brokers/robinhood.py b/stockmind/brokers/robinhood.py new file mode 100644 index 0000000..016c9af --- /dev/null +++ b/stockmind/brokers/robinhood.py @@ -0,0 +1,98 @@ +from __future__ import annotations +from brokers.base_broker import BaseBroker, Position, AccountBalance, Order + + +class RobinhoodBroker(BaseBroker): + name = "robinhood" + + def __init__(self, username: str, password: str): + self._username = username + self._password = password + self._connected = False + + def connect(self) -> bool: + if not self._username or not self._password: + return False + try: + import robin_stocks.robinhood as rh + rh.login(self._username, self._password) + self._connected = True + return True + except Exception as e: + self._connected = False + raise RuntimeError(f"Robinhood login failed: {e}") + + def is_connected(self) -> bool: + return self._connected + + def get_positions(self) -> list[Position]: + if not self.is_connected(): + return [] + try: + import robin_stocks.robinhood as rh + holdings = rh.account.build_holdings() + result = [] + for symbol, data in holdings.items(): + shares = float(data.get("quantity", 0)) + avg_cost = float(data.get("average_buy_price", 0)) + current_price = float(data.get("price", 0)) + market_value = shares * current_price + cost_basis = shares * avg_cost + pnl = market_value - cost_basis + pnl_pct = (pnl / cost_basis * 100) if cost_basis else 0 + result.append(Position( + symbol=symbol, + shares=shares, + avg_cost=avg_cost, + market_value=market_value, + current_price=current_price, + pnl=pnl, + pnl_pct=pnl_pct, + description=data.get("name", ""), + )) + return result + except Exception: + return [] + + def get_balance(self) -> AccountBalance: + if not self.is_connected(): + return AccountBalance() + try: + import robin_stocks.robinhood as rh + profile = rh.profiles.load_portfolio_profile() + return AccountBalance( + total_equity=float(profile.get("equity", 0)), + cash=float(profile.get("withdrawable_amount", 0)), + buying_power=float(profile.get("buying_power", profile.get("withdrawable_amount", 0))), + day_pnl=float(profile.get("equity_previous_close", 0)) - float(profile.get("equity", 0)), + ) + except Exception: + return AccountBalance() + + def get_orders(self, limit: int = 25) -> list[Order]: + if not self.is_connected(): + return [] + try: + import robin_stocks.robinhood as rh + orders = rh.orders.get_all_stock_orders()[:limit] + result = [] + for o in orders: + symbol = "" + try: + instr = rh.stocks.get_instrument_by_url(o.get("instrument", "")) + symbol = instr.get("symbol", "") if instr else "" + except Exception: + pass + result.append(Order( + order_id=o.get("id", ""), + symbol=symbol, + side=o.get("side", ""), + quantity=float(o.get("quantity", 0)), + price=float(o.get("price") or o.get("average_price") or 0), + status=o.get("state", ""), + order_type=o.get("type", ""), + placed_at=o.get("created_at", ""), + )) + return result + except Exception: + return [] diff --git a/stockmind/brokers/schwab.py b/stockmind/brokers/schwab.py new file mode 100644 index 0000000..18f62d4 --- /dev/null +++ b/stockmind/brokers/schwab.py @@ -0,0 +1,105 @@ +from __future__ import annotations +from brokers.base_broker import BaseBroker, Position, AccountBalance, Order + + +class SchwabBroker(BaseBroker): + name = "schwab" + + def __init__(self, app_key: str, app_secret: str): + self._app_key = app_key + self._app_secret = app_secret + self._client = None + self._connected = False + + def connect(self) -> bool: + if not self._app_key or not self._app_secret: + return False + try: + import schwabdev + self._client = schwabdev.Client(self._app_key, self._app_secret) + self._connected = True + return True + except Exception as e: + self._connected = False + raise RuntimeError(f"Schwab connection failed: {e}") + + def is_connected(self) -> bool: + return self._connected and self._client is not None + + def get_positions(self) -> list[Position]: + if not self.is_connected(): + return [] + try: + response = self._client.account_linked().json() + account_hash = response[0].get("hashValue", "") + positions_resp = self._client.account(account_hash, fields="positions").json() + raw_positions = positions_resp.get("securitiesAccount", {}).get("positions", []) + result = [] + for p in raw_positions: + instr = p.get("instrument", {}) + symbol = instr.get("symbol", "") + shares = float(p.get("longQuantity", 0)) + avg_cost = float(p.get("averagePrice", 0)) + market_value = float(p.get("marketValue", 0)) + current_price = market_value / shares if shares else 0 + cost_basis = avg_cost * shares + pnl = market_value - cost_basis + pnl_pct = (pnl / cost_basis * 100) if cost_basis else 0 + result.append(Position( + symbol=symbol, + shares=shares, + avg_cost=avg_cost, + market_value=market_value, + current_price=current_price, + pnl=pnl, + pnl_pct=pnl_pct, + description=instr.get("description", ""), + )) + return result + except Exception: + return [] + + def get_balance(self) -> AccountBalance: + if not self.is_connected(): + return AccountBalance() + try: + response = self._client.account_linked().json() + account_hash = response[0].get("hashValue", "") + acct = self._client.account(account_hash).json() + balances = acct.get("securitiesAccount", {}).get("currentBalances", {}) + return AccountBalance( + total_equity=float(balances.get("equity", 0)), + cash=float(balances.get("cashBalance", 0)), + buying_power=float(balances.get("buyingPower", 0)), + day_pnl=float(acct.get("securitiesAccount", {}).get("currentBalances", {}).get("dayProfitLoss", 0)), + ) + except Exception: + return AccountBalance() + + def get_orders(self, limit: int = 25) -> list[Order]: + if not self.is_connected(): + return [] + try: + response = self._client.account_linked().json() + account_hash = response[0].get("hashValue", "") + from datetime import datetime, timedelta + from_time = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%S+0000") + to_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S+0000") + orders_resp = self._client.account_orders(account_hash, from_time, to_time, max_results=limit).json() + result = [] + for o in orders_resp: + legs = o.get("orderLegCollection", [{}]) + instr = legs[0].get("instrument", {}) if legs else {} + result.append(Order( + order_id=str(o.get("orderId", "")), + symbol=instr.get("symbol", ""), + side=legs[0].get("instruction", "").lower() if legs else "", + quantity=float(o.get("quantity", 0)), + price=float(o.get("price", 0)), + status=o.get("status", ""), + order_type=o.get("orderType", ""), + placed_at=o.get("enteredTime", ""), + )) + return result + except Exception: + return [] diff --git a/stockmind/core/__init__.py b/stockmind/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stockmind/core/alerts.py b/stockmind/core/alerts.py new file mode 100644 index 0000000..965dcd8 --- /dev/null +++ b/stockmind/core/alerts.py @@ -0,0 +1,63 @@ +from __future__ import annotations +from PyQt6.QtCore import QThread, pyqtSignal +from core.market_data import get_quote +from utils.notifications import send_toast + + +class AlertWorker(QThread): + alert_triggered = pyqtSignal(str, str, str) # symbol, type, message + check_complete = pyqtSignal() + + def __init__(self, interval_seconds: int = 300): + super().__init__() + self._interval = interval_seconds + self._running = False + + def run(self): + self._running = True + while self._running: + self._check_alerts() + self.check_complete.emit() + for _ in range(self._interval * 10): + if not self._running: + return + self.msleep(100) + + def stop(self): + self._running = False + + def _check_alerts(self): + from db.database import get_session + from db.models import Alert + from datetime import datetime + + with get_session() as session: + active = session.query(Alert).filter_by(is_active=True).all() + for alert in active: + try: + quote = get_quote(alert.symbol, force=True) + price = quote.get("price", 0.0) + volume = quote.get("volume", 0) + triggered = False + message = "" + + if alert.alert_type == "price_above" and price >= alert.target_value: + triggered = True + message = f"{alert.symbol} hit ${price:.2f} (above ${alert.target_value:.2f})" + elif alert.alert_type == "price_below" and price <= alert.target_value: + triggered = True + message = f"{alert.symbol} hit ${price:.2f} (below ${alert.target_value:.2f})" + elif alert.alert_type == "volume_spike": + avg_vol = quote.get("avg_volume") or (volume / 1.5) + if avg_vol and volume >= avg_vol * alert.target_value: + triggered = True + message = f"{alert.symbol} volume spike: {volume:,} ({alert.target_value:.1f}x avg)" + + if triggered: + alert.is_active = False + alert.triggered_at = datetime.utcnow() + session.commit() + send_toast("StockMind Alert", message) + self.alert_triggered.emit(alert.symbol, alert.alert_type, message) + except Exception: + continue diff --git a/stockmind/core/market_data.py b/stockmind/core/market_data.py new file mode 100644 index 0000000..0ce4612 --- /dev/null +++ b/stockmind/core/market_data.py @@ -0,0 +1,177 @@ +from __future__ import annotations +import time +from datetime import datetime, timedelta +from typing import Optional +import pandas as pd +import yfinance as yf + +_quote_cache: dict[str, tuple[dict, float]] = {} +_CACHE_TTL = 60 # seconds + + +def get_quote(symbol: str, force: bool = False) -> dict: + now = time.time() + if not force and symbol in _quote_cache: + data, ts = _quote_cache[symbol] + if now - ts < _CACHE_TTL: + return data + + try: + ticker = yf.Ticker(symbol) + info = ticker.fast_info + hist = ticker.history(period="2d", interval="1d") + + price = float(info.last_price or 0) + prev_close = float(info.previous_close or price) + change = price - prev_close + change_pct = (change / prev_close * 100) if prev_close else 0.0 + + data = { + "symbol": symbol, + "price": price, + "change": change, + "change_pct": change_pct, + "volume": int(info.three_month_average_volume or 0), + "market_cap": getattr(info, "market_cap", None), + "prev_close": prev_close, + "day_high": float(getattr(info, "day_high", price) or price), + "day_low": float(getattr(info, "day_low", price) or price), + "fifty_two_week_high": float(getattr(info, "fifty_two_week_high", 0) or 0), + "fifty_two_week_low": float(getattr(info, "fifty_two_week_low", 0) or 0), + "error": None, + } + except Exception as e: + data = { + "symbol": symbol, "price": 0.0, "change": 0.0, "change_pct": 0.0, + "volume": 0, "market_cap": None, "prev_close": 0.0, + "day_high": 0.0, "day_low": 0.0, + "fifty_two_week_high": 0.0, "fifty_two_week_low": 0.0, + "error": str(e), + } + + _quote_cache[symbol] = (data, now) + return data + + +def get_history(symbol: str, period: str = "6mo", interval: str = "1d") -> pd.DataFrame: + try: + ticker = yf.Ticker(symbol) + df = ticker.history(period=period, interval=interval) + df.index = pd.to_datetime(df.index) + return df + except Exception: + return pd.DataFrame() + + +def get_fundamentals(symbol: str) -> dict: + try: + ticker = yf.Ticker(symbol) + info = ticker.info + return { + "name": info.get("longName", symbol), + "sector": info.get("sector", ""), + "industry": info.get("industry", ""), + "market_cap": info.get("marketCap"), + "pe_ratio": info.get("trailingPE"), + "forward_pe": info.get("forwardPE"), + "eps": info.get("trailingEps"), + "revenue": info.get("totalRevenue"), + "profit_margin": info.get("profitMargins"), + "dividend_yield": info.get("dividendYield"), + "beta": info.get("beta"), + "week_52_high": info.get("fiftyTwoWeekHigh"), + "week_52_low": info.get("fiftyTwoWeekLow"), + "avg_volume": info.get("averageVolume"), + "description": info.get("longBusinessSummary", ""), + } + except Exception: + return {"name": symbol, "sector": "", "industry": "", "error": True} + + +def search_symbols(query: str) -> list[dict]: + if not query or len(query) < 1: + return [] + try: + results = yf.Search(query, max_results=10) + quotes = results.quotes if hasattr(results, "quotes") else [] + return [ + {"symbol": q.get("symbol", ""), "name": q.get("shortname", q.get("longname", ""))} + for q in quotes + if q.get("symbol") + ] + except Exception: + return [] + + +PERIOD_MAP = { + "1D": ("1d", "5m"), + "1W": ("5d", "15m"), + "1M": ("1mo", "1h"), + "3M": ("3mo", "1d"), + "6M": ("6mo", "1d"), + "1Y": ("1y", "1d"), + "5Y": ("5y", "1wk"), +} + + +def get_chart_data(symbol: str, period_label: str = "6M") -> pd.DataFrame: + period, interval = PERIOD_MAP.get(period_label, ("6mo", "1d")) + return get_history(symbol, period=period, interval=interval) + + +def get_batch_quotes(symbols: list[str]) -> dict[str, dict]: + results = {} + for sym in symbols: + results[sym] = get_quote(sym) + return results + + +SCREENER_UNIVERSE = [ + "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "BRK-B", "UNH", "JPM", + "V", "XOM", "JNJ", "PG", "MA", "HD", "CVX", "MRK", "ABBV", "PEP", + "KO", "AVGO", "COST", "LLY", "MCD", "TMO", "ACN", "BAC", "CSCO", "WMT", + "ABT", "CRM", "DIS", "NFLX", "AMD", "INTC", "QCOM", "TXN", "PYPL", "AMGN", + "SPY", "QQQ", "DIA", "GLD", "SLV", + "BTC-USD", "ETH-USD", "SOL-USD", "BNB-USD", "ADA-USD", +] + + +def get_screener_data(symbols: list[str] | None = None) -> list[dict]: + if symbols is None: + symbols = SCREENER_UNIVERSE + results = [] + for sym in symbols: + try: + ticker = yf.Ticker(sym) + info = ticker.info + fast = ticker.fast_info + hist = ticker.history(period="1y", interval="1d") + rsi_val = None + if len(hist) >= 14: + delta = hist["Close"].diff() + gain = delta.clip(lower=0).rolling(14).mean() + loss = (-delta.clip(upper=0)).rolling(14).mean() + rs = gain / loss + rsi_series = 100 - (100 / (1 + rs)) + rsi_val = round(float(rsi_series.iloc[-1]), 1) if not rsi_series.empty else None + + price = float(fast.last_price or 0) + week_52_high = float(getattr(fast, "year_high", 0) or 0) + week_52_low = float(getattr(fast, "year_low", 0) or 0) + + results.append({ + "symbol": sym, + "name": info.get("shortName", sym), + "price": price, + "sector": info.get("sector", ""), + "market_cap": info.get("marketCap"), + "pe_ratio": info.get("trailingPE"), + "rsi": rsi_val, + "week_52_high": week_52_high, + "week_52_low": week_52_low, + "pct_from_52h": round((price - week_52_high) / week_52_high * 100, 1) if week_52_high else None, + "pct_from_52l": round((price - week_52_low) / week_52_low * 100, 1) if week_52_low else None, + }) + except Exception: + continue + return results diff --git a/stockmind/core/portfolio.py b/stockmind/core/portfolio.py new file mode 100644 index 0000000..df137be --- /dev/null +++ b/stockmind/core/portfolio.py @@ -0,0 +1,85 @@ +from __future__ import annotations +import pandas as pd +import yfinance as yf +from core.market_data import get_quote, get_history + + +def calculate_pnl(positions: list[dict], quotes: dict[str, dict]) -> list[dict]: + result = [] + for pos in positions: + sym = pos["symbol"] + q = quotes.get(sym, {}) + price = q.get("price", 0.0) + shares = pos.get("shares", 0.0) + avg_cost = pos.get("avg_cost", 0.0) + market_value = price * shares + cost_basis = avg_cost * shares + pnl = market_value - cost_basis + pnl_pct = (pnl / cost_basis * 100) if cost_basis else 0.0 + result.append({ + **pos, + "price": price, + "market_value": market_value, + "cost_basis": cost_basis, + "pnl": pnl, + "pnl_pct": pnl_pct, + "change_pct": q.get("change_pct", 0.0), + }) + return result + + +def get_benchmark_performance(period: str = "1y") -> pd.DataFrame: + return get_history("SPY", period=period) + + +def calculate_sector_allocation(positions: list[dict]) -> dict[str, float]: + sector_values: dict[str, float] = {} + for pos in positions: + sector = pos.get("sector", "Unknown") or "Unknown" + value = pos.get("market_value", 0.0) + sector_values[sector] = sector_values.get(sector, 0.0) + value + total = sum(sector_values.values()) + if total == 0: + return {} + return {s: (v / total * 100) for s, v in sorted(sector_values.items(), key=lambda x: -x[1])} + + +def get_portfolio_performance(positions: list[dict], period: str = "1y") -> pd.DataFrame: + if not positions: + return pd.DataFrame() + + symbols = [p["symbol"] for p in positions] + weights = {} + total_value = sum(p.get("market_value", 0) for p in positions) + + if total_value == 0: + return pd.DataFrame() + + for p in positions: + weights[p["symbol"]] = p.get("market_value", 0) / total_value + + frames = [] + for sym in symbols: + hist = get_history(sym, period=period) + if not hist.empty: + pct = hist["Close"].pct_change().fillna(0) + pct.name = sym + frames.append(pct * weights.get(sym, 0)) + + if not frames: + return pd.DataFrame() + + combined = pd.concat(frames, axis=1).fillna(0) + portfolio_returns = combined.sum(axis=1) + portfolio_cumulative = (1 + portfolio_returns).cumprod() - 1 + + spy = get_history("SPY", period=period) + if not spy.empty: + spy_returns = spy["Close"].pct_change().fillna(0) + spy_cumulative = (1 + spy_returns).cumprod() - 1 + return pd.DataFrame({ + "portfolio": portfolio_cumulative, + "spy": spy_cumulative, + }).dropna() + + return pd.DataFrame({"portfolio": portfolio_cumulative}).dropna() diff --git a/stockmind/db/__init__.py b/stockmind/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stockmind/db/database.py b/stockmind/db/database.py new file mode 100644 index 0000000..4694a07 --- /dev/null +++ b/stockmind/db/database.py @@ -0,0 +1,39 @@ +import os +from contextlib import contextmanager +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from db.models import Base + +_DB_PATH = Path(__file__).parent.parent.parent / "stockmind.db" +_ENGINE = None +_SessionLocal = None + + +def _get_engine(): + global _ENGINE + if _ENGINE is None: + _ENGINE = create_engine(f"sqlite:///{_DB_PATH}", connect_args={"check_same_thread": False}) + return _ENGINE + + +def init_db(): + engine = _get_engine() + Base.metadata.create_all(engine) + global _SessionLocal + _SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +@contextmanager +def get_session() -> Session: + global _SessionLocal + if _SessionLocal is None: + init_db() + session = _SessionLocal() + try: + yield session + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/stockmind/db/models.py b/stockmind/db/models.py new file mode 100644 index 0000000..e46f876 --- /dev/null +++ b/stockmind/db/models.py @@ -0,0 +1,74 @@ +from datetime import datetime +from sqlalchemy import String, Float, Integer, Boolean, DateTime, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class WatchlistItem(Base): + __tablename__ = "watchlist_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + symbol: Mapped[str] = mapped_column(String(20), nullable=False) + name: Mapped[str] = mapped_column(String(100), default="") + group_name: Mapped[str] = mapped_column(String(50), default="Default") + added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class PortfolioPosition(Base): + __tablename__ = "portfolio_positions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + symbol: Mapped[str] = mapped_column(String(20), nullable=False) + shares: Mapped[float] = mapped_column(Float, default=0.0) + avg_cost: Mapped[float] = mapped_column(Float, default=0.0) + broker: Mapped[str] = mapped_column(String(30), default="manual") + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class Alert(Base): + __tablename__ = "alerts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + symbol: Mapped[str] = mapped_column(String(20), nullable=False) + alert_type: Mapped[str] = mapped_column(String(20), nullable=False) # price_above, price_below, volume_spike + target_value: Mapped[float] = mapped_column(Float, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + triggered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class Setting(Base): + __tablename__ = "settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + value: Mapped[str] = mapped_column(Text, default="") + + +class NewsCache(Base): + __tablename__ = "news_cache" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + symbol: Mapped[str] = mapped_column(String(20), nullable=False) + title: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str] = mapped_column(Text, default="") + url: Mapped[str] = mapped_column(Text, default="") + source: Mapped[str] = mapped_column(String(100), default="") + published_at: Mapped[str] = mapped_column(String(50), default="") + sentiment: Mapped[str] = mapped_column(String(20), default="") + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class QuoteCache(Base): + __tablename__ = "quote_cache" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + symbol: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) + price: Mapped[float] = mapped_column(Float, default=0.0) + change: Mapped[float] = mapped_column(Float, default=0.0) + change_pct: Mapped[float] = mapped_column(Float, default=0.0) + volume: Mapped[int] = mapped_column(Integer, default=0) + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/stockmind/main.py b/stockmind/main.py new file mode 100644 index 0000000..4e99045 --- /dev/null +++ b/stockmind/main.py @@ -0,0 +1,42 @@ +import sys +import os + +# Add the stockmind package root to sys.path so imports resolve correctly +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +from db.database import init_db +from utils.config import Config +from ui.main_window import MainWindow + + +def main(): + os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") + + app = QApplication(sys.argv) + app.setApplicationName("StockMind") + app.setApplicationVersion("1.0.0") + app.setOrganizationName("StockMind") + app.setFont(QFont("Segoe UI", 10)) + + init_db() + config = Config() + + theme = config.get_setting("theme", "dark") + qss_path = os.path.join(_HERE, "assets", "styles", f"{theme}.qss") + if os.path.exists(qss_path): + with open(qss_path, "r") as f: + app.setStyleSheet(f.read()) + + window = MainWindow(config) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/stockmind/ui/__init__.py b/stockmind/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stockmind/ui/ai_panel.py b/stockmind/ui/ai_panel.py new file mode 100644 index 0000000..672ca58 --- /dev/null +++ b/stockmind/ui/ai_panel.py @@ -0,0 +1,437 @@ +from __future__ import annotations +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit, + QTabWidget, QLineEdit, QSplitter, QFrame, QMessageBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont, QTextCursor + + +class AIWorker(QThread): + result_ready = pyqtSignal(str, dict) + error = pyqtSignal(str) + + def __init__(self, func, *args, **kwargs): + super().__init__() + self._func = func + self._args = args + self._kwargs = kwargs + + def run(self): + try: + text, usage = self._func(*self._args, **self._kwargs) + self.result_ready.emit(text, usage) + except Exception as e: + self.error.emit(str(e)) + + +class AIResultWidget(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("card") + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + + self._text = QTextEdit() + self._text.setReadOnly(True) + self._text.setFont(QFont("Segoe UI", 10)) + layout.addWidget(self._text) + + self._cost_label = QLabel("") + self._cost_label.setObjectName("subtitle") + layout.addWidget(self._cost_label) + + def set_text(self, text: str): + self._text.setPlainText(text) + cursor = self._text.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.Start) + self._text.setTextCursor(cursor) + + def set_loading(self): + self._text.setPlainText("Analyzing… please wait.") + self._cost_label.clear() + + def set_error(self, error: str): + self._text.setPlainText(f"Error: {error}") + self._cost_label.clear() + + def set_usage(self, usage: dict): + if usage: + in_tok = usage.get("input_tokens", 0) + out_tok = usage.get("output_tokens", 0) + cost = usage.get("cost_usd", 0) + self._cost_label.setText(f"Tokens: {in_tok} in / {out_tok} out | Est. cost: ${cost:.4f}") + + +class AIPanelWidget(QWidget): + def __init__(self, config, parent=None): + super().__init__(parent) + self._config = config + self._symbol = "AAPL" + self._worker: AIWorker | 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("AI Analysis") + header.setObjectName("title") + header_row.addWidget(header) + + self._symbol_label = QLabel(f"Analyzing: {self._symbol}") + self._symbol_label.setObjectName("subtitle") + header_row.addWidget(self._symbol_label) + header_row.addStretch() + root.addLayout(header_row) + + self._tabs = QTabWidget() + self._tabs.addTab(self._build_summary_tab(), "Summary") + self._tabs.addTab(self._build_technical_tab(), "Technical") + self._tabs.addTab(self._build_sentiment_tab(), "Sentiment") + self._tabs.addTab(self._build_outlook_tab(), "Outlook") + self._tabs.addTab(self._build_chat_tab(), "Chat") + self._tabs.addTab(self._build_portfolio_tab(), "Portfolio") + root.addWidget(self._tabs) + + def _make_tab_layout(self, title: str, btn_text: str, on_click) -> tuple[QWidget, AIResultWidget]: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + + top_row = QHBoxLayout() + lbl = QLabel(title) + lbl.setObjectName("subtitle") + top_row.addWidget(lbl) + top_row.addStretch() + btn = QPushButton(btn_text) + btn.setObjectName("primary_btn") + btn.setMaximumWidth(160) + btn.clicked.connect(on_click) + top_row.addWidget(btn) + layout.addLayout(top_row) + + result = AIResultWidget() + layout.addWidget(result) + return tab, result + + def _build_summary_tab(self) -> QWidget: + tab, self._summary_result = self._make_tab_layout( + "AI Stock Summary — fundamentals + news digest", + "Run Summary", + self._run_summary, + ) + return tab + + def _build_technical_tab(self) -> QWidget: + tab, self._technical_result = self._make_tab_layout( + "AI Technical Read — chart pattern analysis", + "Run Technical", + self._run_technical, + ) + return tab + + def _build_sentiment_tab(self) -> QWidget: + tab, self._sentiment_result = self._make_tab_layout( + "AI Sentiment Score — news → Bull/Bear/Neutral", + "Run Sentiment", + self._run_sentiment, + ) + return tab + + def _build_outlook_tab(self) -> QWidget: + tab, self._outlook_result = self._make_tab_layout( + "AI Price Outlook — 1D/1W/1M directional bias", + "Run Outlook", + self._run_outlook, + ) + return tab + + def _build_chat_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + + self._chat_display = QTextEdit() + self._chat_display.setReadOnly(True) + self._chat_display.setFont(QFont("Segoe UI", 10)) + layout.addWidget(self._chat_display, stretch=1) + + self._chat_cost_label = QLabel("") + self._chat_cost_label.setObjectName("subtitle") + layout.addWidget(self._chat_cost_label) + + input_row = QHBoxLayout() + self._chat_input = QLineEdit() + self._chat_input.setPlaceholderText(f"Ask anything about {self._symbol}…") + self._chat_input.returnPressed.connect(self._send_chat) + send_btn = QPushButton("Send") + send_btn.setObjectName("primary_btn") + send_btn.clicked.connect(self._send_chat) + input_row.addWidget(self._chat_input) + input_row.addWidget(send_btn) + layout.addLayout(input_row) + return tab + + def _build_portfolio_tab(self) -> QWidget: + tab, self._portfolio_result = self._make_tab_layout( + "AI Portfolio Review — risks, correlation, recommendations", + "Review Portfolio", + self._run_portfolio_review, + ) + return tab + + def _get_client(self): + from ai.claude_client import ClaudeClient + key = self._config.anthropic_key + if not key: + raise RuntimeError("Anthropic API key not configured. Go to Settings.") + return ClaudeClient(key) + + def set_symbol(self, symbol: str): + self._symbol = symbol + self._symbol_label.setText(f"Analyzing: {symbol}") + self._chat_input.setPlaceholderText(f"Ask anything about {symbol}…") + + def _run_summary(self): + self._summary_result.set_loading() + try: + client = self._get_client() + except RuntimeError as e: + self._summary_result.set_error(str(e)) + return + + def _fetch(): + from core.market_data import get_fundamentals, get_quote + from ai.sentiment import fetch_news + from ai.prompts import STOCK_SUMMARY_SYSTEM, STOCK_SUMMARY_PROMPT + from utils.formatters import fmt_currency, fmt_large_number + + info = get_fundamentals(self._symbol) + news = fetch_news(self._symbol, self._config.news_api_key, page_size=5) + headlines = "\n".join(f"- {a['title']}" for a in news[:5]) or "No recent news." + prompt = STOCK_SUMMARY_PROMPT.format( + symbol=self._symbol, + name=info.get("name", self._symbol), + sector=info.get("sector", "N/A"), + industry=info.get("industry", "N/A"), + market_cap=fmt_large_number(info.get("market_cap")), + pe_ratio=f"{info.get('pe_ratio', 'N/A')}", + eps=f"{info.get('eps', 'N/A')}", + week_52_high=fmt_currency(info.get("week_52_high")), + week_52_low=fmt_currency(info.get("week_52_low")), + dividend_yield=f"{info.get('dividend_yield', 'N/A')}", + headlines=headlines, + ) + return client.ask(prompt, system=STOCK_SUMMARY_SYSTEM, max_tokens=1024) + + self._worker = AIWorker(_fetch) + self._worker.result_ready.connect(lambda t, u: (self._summary_result.set_text(t), self._summary_result.set_usage(u))) + self._worker.error.connect(self._summary_result.set_error) + self._worker.start() + + def _run_technical(self): + self._technical_result.set_loading() + try: + client = self._get_client() + except RuntimeError as e: + self._technical_result.set_error(str(e)) + return + + def _fetch(): + import numpy as np + import pandas as pd + from core.market_data import get_chart_data + from ai.prompts import TECHNICAL_READ_SYSTEM, TECHNICAL_READ_PROMPT + from utils.formatters import fmt_currency + + df = get_chart_data(self._symbol, "3M") + if df.empty: + raise RuntimeError("No chart data available.") + + closes = df["Close"].values.astype(float) + current_price = closes[-1] + sma20 = float(pd.Series(closes).rolling(20).mean().iloc[-1]) if len(closes) >= 20 else None + sma50 = float(pd.Series(closes).rolling(50).mean().iloc[-1]) if len(closes) >= 50 else None + + delta = pd.Series(closes).diff() + gain = delta.clip(lower=0).rolling(14).mean() + loss = (-delta.clip(upper=0)).rolling(14).mean() + rsi_val = float(100 - (100 / (1 + gain / loss)).iloc[-1]) if len(closes) >= 14 else None + + ema12 = pd.Series(closes).ewm(span=12).mean() + ema26 = pd.Series(closes).ewm(span=26).mean() + macd_val = float((ema12 - ema26).iloc[-1]) + signal_val = float((ema12 - ema26).ewm(span=9).mean().iloc[-1]) + + sma20_s = pd.Series(closes).rolling(20) + bb_upper = float((sma20_s.mean() + 2 * sma20_s.std()).iloc[-1]) if len(closes) >= 20 else None + bb_lower = float((sma20_s.mean() - 2 * sma20_s.std()).iloc[-1]) if len(closes) >= 20 else None + + trend = "uptrend" if len(closes) >= 20 and closes[-1] > closes[-20] else "downtrend" + prompt = TECHNICAL_READ_PROMPT.format( + symbol=self._symbol, period="3M", + price=fmt_currency(current_price), + sma20=fmt_currency(sma20) if sma20 else "N/A", + sma50=fmt_currency(sma50) if sma50 else "N/A", + rsi=f"{rsi_val:.1f}" if rsi_val else "N/A", + macd=f"{macd_val:.3f}", macd_signal=f"{signal_val:.3f}", + bb_upper=fmt_currency(bb_upper) if bb_upper else "N/A", + bb_lower=fmt_currency(bb_lower) if bb_lower else "N/A", + price_action=trend, + ) + return client.ask(prompt, system=TECHNICAL_READ_SYSTEM, max_tokens=800) + + self._worker = AIWorker(_fetch) + self._worker.result_ready.connect(lambda t, u: (self._technical_result.set_text(t), self._technical_result.set_usage(u))) + self._worker.error.connect(self._technical_result.set_error) + self._worker.start() + + def _run_sentiment(self): + self._sentiment_result.set_loading() + try: + client = self._get_client() + except RuntimeError as e: + self._sentiment_result.set_error(str(e)) + return + + def _fetch(): + from ai.sentiment import fetch_news, analyze_sentiment + articles = fetch_news(self._symbol, self._config.news_api_key) + return analyze_sentiment(self._symbol, articles, client) + + self._worker = AIWorker(_fetch) + self._worker.result_ready.connect(lambda t, u: (self._sentiment_result.set_text(t), self._sentiment_result.set_usage(u))) + self._worker.error.connect(self._sentiment_result.set_error) + self._worker.start() + + def _run_outlook(self): + self._outlook_result.set_loading() + try: + client = self._get_client() + except RuntimeError as e: + self._outlook_result.set_error(str(e)) + return + + def _fetch(): + import pandas as pd + from core.market_data import get_chart_data, get_quote + from ai.prompts import PRICE_OUTLOOK_SYSTEM, PRICE_OUTLOOK_PROMPT + from utils.formatters import fmt_currency + + df = get_chart_data(self._symbol, "1M") + closes = df["Close"].values.astype(float) if not df.empty else [] + rsi_val = "N/A" + trend = "neutral" + momentum = "neutral" + + if len(closes) >= 14: + delta = pd.Series(closes).diff() + gain = delta.clip(lower=0).rolling(14).mean() + loss = (-delta.clip(upper=0)).rolling(14).mean() + rsi_v = float(100 - (100 / (1 + gain / loss)).iloc[-1]) + rsi_val = f"{rsi_v:.1f}" + trend = "uptrend" if closes[-1] > closes[-5] else "downtrend" + momentum = "bullish" if rsi_v > 55 else ("bearish" if rsi_v < 45 else "neutral") + + quote = get_quote(self._symbol) + prompt = PRICE_OUTLOOK_PROMPT.format( + symbol=self._symbol, + price=fmt_currency(quote.get("price", 0)), + rsi=rsi_val, trend=trend, momentum=momentum, + sentiment="N/A", sector_perf="N/A", + ) + return client.ask(prompt, system=PRICE_OUTLOOK_SYSTEM, max_tokens=600) + + self._worker = AIWorker(_fetch) + self._worker.result_ready.connect(lambda t, u: (self._outlook_result.set_text(t), self._outlook_result.set_usage(u))) + self._worker.error.connect(self._outlook_result.set_error) + self._worker.start() + + def _send_chat(self): + question = self._chat_input.text().strip() + if not question: + return + try: + client = self._get_client() + except RuntimeError as e: + self._chat_display.append(f"\nError: {e}") + return + + self._chat_display.append(f"\nYou: {question}") + self._chat_input.clear() + self._chat_display.append("AI: thinking…\n") + + def _fetch(): + from ai.prompts import CHAT_SYSTEM + context = f"Current ticker being analyzed: {self._symbol}\nUser question: {question}" + return client.ask(context, system=CHAT_SYSTEM, max_tokens=1024) + + def _on_result(text: str, usage: dict): + cursor = self._chat_display.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + self._chat_display.append(f"AI: {text}\n") + in_tok = usage.get("input_tokens", 0) + out_tok = usage.get("output_tokens", 0) + cost = usage.get("cost_usd", 0) + self._chat_cost_label.setText(f"Last: {in_tok}in/{out_tok}out — ${cost:.4f}") + + self._worker = AIWorker(_fetch) + self._worker.result_ready.connect(_on_result) + self._worker.error.connect(lambda e: self._chat_display.append(f"Error: {e}\n")) + self._worker.start() + + def _run_portfolio_review(self): + self._portfolio_result.set_loading() + try: + client = self._get_client() + except RuntimeError as e: + self._portfolio_result.set_error(str(e)) + return + + def _fetch(): + from db.database import get_session + from db.models import PortfolioPosition + from core.market_data import get_batch_quotes + from core.portfolio import calculate_pnl, calculate_sector_allocation + from ai.prompts import PORTFOLIO_REVIEW_SYSTEM, PORTFOLIO_REVIEW_PROMPT + from utils.formatters import fmt_currency, fmt_percent + + with get_session() as session: + rows = session.query(PortfolioPosition).all() + raw = [{"symbol": r.symbol, "shares": r.shares, "avg_cost": r.avg_cost} for r in rows] + + if not raw: + raise RuntimeError("No portfolio positions found. Add positions first.") + + quotes = get_batch_quotes([r["symbol"] for r in raw]) + positions = calculate_pnl(raw, quotes) + total_value = sum(p.get("market_value", 0) for p in positions) + cash = 0.0 + top_pos = max(positions, key=lambda p: p.get("market_value", 0), default={}) + sector_alloc = calculate_sector_allocation(positions) + + holdings_str = "\n".join( + f"- {p['symbol']}: {p.get('shares', 0):.2f} shares @ {fmt_currency(p.get('avg_cost', 0))}, " + f"value={fmt_currency(p.get('market_value', 0))}, P&L={fmt_percent(p.get('pnl_pct', 0))}" + for p in positions + ) + sector_str = ", ".join(f"{s}: {v:.1f}%" for s, v in sector_alloc.items()) + + prompt = PORTFOLIO_REVIEW_PROMPT.format( + holdings=holdings_str, + total_value=fmt_currency(total_value), + cash=fmt_currency(cash), + top_position=top_pos.get("symbol", "N/A"), + sector_allocation=sector_str, + ) + return client.ask(prompt, system=PORTFOLIO_REVIEW_SYSTEM, max_tokens=1200) + + self._worker = AIWorker(_fetch) + self._worker.result_ready.connect(lambda t, u: (self._portfolio_result.set_text(t), self._portfolio_result.set_usage(u))) + self._worker.error.connect(self._portfolio_result.set_error) + self._worker.start() diff --git a/stockmind/ui/chart_widget.py b/stockmind/ui/chart_widget.py new file mode 100644 index 0000000..df0807e --- /dev/null +++ b/stockmind/ui/chart_widget.py @@ -0,0 +1,355 @@ +from __future__ import annotations +import numpy as np +import pandas as pd +import pyqtgraph as pg +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QCheckBox, QCompleter, QSizePolicy, QFrame +) +from PyQt6.QtCore import Qt, QStringListModel, QThread, pyqtSignal, QTimer +from PyQt6.QtGui import QPicture, QPainter, QColor, QPen, QBrush, QFont +from PyQt6.QtCore import QRectF, QPointF +from utils.formatters import fmt_currency, fmt_percent, pnl_color + +PERIODS = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y"] +INDICATORS = ["SMA20", "SMA50", "EMA20", "BB", "Volume", "RSI", "MACD"] + +COLORS = { + "up": "#a6e3a1", + "down": "#f38ba8", + "sma20": "#89b4fa", + "sma50": "#fab387", + "ema20": "#cba6f7", + "bb_upper": "#89dceb", + "bb_lower": "#89dceb", + "bb_fill": "#1a3a4a", + "volume_up": "#a6e3a1", + "volume_down": "#f38ba8", + "rsi_line": "#cba6f7", + "macd_line": "#89b4fa", + "macd_signal": "#fab387", + "macd_pos": "#a6e3a1", + "macd_neg": "#f38ba8", +} + + +class CandlestickItem(pg.GraphicsObject): + def __init__(self, df: pd.DataFrame): + super().__init__() + self._df = df + self._picture = QPicture() + self._generate() + + def _generate(self): + self._picture = QPicture() + p = QPainter(self._picture) + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + w = 0.3 + + for i, (ts, row) in enumerate(self._df.iterrows()): + t = float(i) + o, c, lo, hi = float(row["Open"]), float(row["Close"]), float(row["Low"]), float(row["High"]) + is_up = c >= o + color = QColor(COLORS["up"] if is_up else COLORS["down"]) + pen = QPen(color) + pen.setWidthF(1.0) + p.setPen(pen) + p.setBrush(QBrush(color)) + p.drawLine(QPointF(t, lo), QPointF(t, hi)) + body_top = max(o, c) + body_bot = min(o, c) + body_h = max(body_top - body_bot, 0.001) + p.drawRect(QRectF(t - w, body_bot, w * 2, body_h)) + + p.end() + + def paint(self, p, *args): + p.drawPicture(0, 0, self._picture) + + def boundingRect(self): + return QRectF(self._picture.boundingRect()) + + +class DateAxisItem(pg.AxisItem): + def __init__(self, timestamps: list, *args, **kwargs): + super().__init__(*args, **kwargs) + self._timestamps = timestamps + + def tickStrings(self, values, scale, spacing): + result = [] + for v in values: + idx = int(round(v)) + if 0 <= idx < len(self._timestamps): + ts = self._timestamps[idx] + if hasattr(ts, "strftime"): + result.append(ts.strftime("%m/%d")) + else: + result.append(str(ts)[:10]) + else: + result.append("") + return result + + +class ChartDataWorker(QThread): + data_ready = pyqtSignal(object, str) + error = pyqtSignal(str) + + def __init__(self, symbol: str, period: str): + super().__init__() + self.symbol = symbol + self.period = period + + def run(self): + try: + from core.market_data import get_chart_data + df = get_chart_data(self.symbol, self.period) + self.data_ready.emit(df, self.symbol) + except Exception as e: + self.error.emit(str(e)) + + +class ChartWidget(QWidget): + symbol_changed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._symbol = "AAPL" + self._period = "6M" + self._df: pd.DataFrame | None = None + self._worker: ChartDataWorker | None = None + self._active_indicators: set[str] = {"Volume"} + self._setup_ui() + self._setup_autocomplete() + self.load_symbol(self._symbol) + + def _setup_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + # Search + quote bar + top_bar = QHBoxLayout() + self._search = QLineEdit() + self._search.setPlaceholderText("Search symbol… (e.g. AAPL, BTC-USD)") + self._search.setMaximumWidth(280) + self._search.returnPressed.connect(self._on_search) + top_bar.addWidget(self._search) + + self._quote_label = QLabel() + self._quote_label.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) + top_bar.addWidget(self._quote_label) + self._change_label = QLabel() + top_bar.addWidget(self._change_label) + top_bar.addStretch() + root.addLayout(top_bar) + + # Period buttons + period_bar = QHBoxLayout() + self._period_btns: dict[str, QPushButton] = {} + for p in PERIODS: + btn = QPushButton(p) + btn.setCheckable(True) + btn.setMaximumWidth(48) + btn.clicked.connect(lambda checked, period=p: self._set_period(period)) + self._period_btns[p] = btn + period_bar.addWidget(btn) + + period_bar.addSpacing(20) + + for ind in INDICATORS: + cb = QCheckBox(ind) + cb.setChecked(ind == "Volume") + cb.stateChanged.connect(lambda state, i=ind: self._toggle_indicator(i, state)) + period_bar.addWidget(cb) + + period_bar.addStretch() + root.addLayout(period_bar) + + self._period_btns["6M"].setChecked(True) + + # Chart area + pg.setConfigOptions(antialias=True, foreground="#6c7086", background="#1e1e2e") + self._layout_widget = pg.GraphicsLayoutWidget() + root.addWidget(self._layout_widget) + + self._status_label = QLabel("Loading…") + self._status_label.setObjectName("subtitle") + root.addWidget(self._status_label) + + self._build_plots() + + def _build_plots(self): + self._layout_widget.clear() + self._main_plot: pg.PlotItem = self._layout_widget.addPlot(row=0, col=0) + self._main_plot.showGrid(x=False, y=True, alpha=0.15) + self._main_plot.getAxis("left").setWidth(60) + self._main_plot.getAxis("bottom").setStyle(showValues=False) + self._main_plot.setMinimumHeight(260) + + self._vol_plot: pg.PlotItem = self._layout_widget.addPlot(row=1, col=0) + self._vol_plot.setMaximumHeight(80) + self._vol_plot.showGrid(x=False, y=True, alpha=0.1) + self._vol_plot.getAxis("left").setWidth(60) + self._vol_plot.setXLink(self._main_plot) + + self._rsi_plot: pg.PlotItem = self._layout_widget.addPlot(row=2, col=0) + self._rsi_plot.setMaximumHeight(100) + self._rsi_plot.showGrid(x=False, y=True, alpha=0.1) + self._rsi_plot.getAxis("left").setWidth(60) + self._rsi_plot.setXLink(self._main_plot) + self._rsi_plot.setYRange(0, 100) + self._rsi_plot.hide() + + self._macd_plot: pg.PlotItem = self._layout_widget.addPlot(row=3, col=0) + self._macd_plot.setMaximumHeight(100) + self._macd_plot.showGrid(x=False, y=True, alpha=0.1) + self._macd_plot.getAxis("left").setWidth(60) + self._macd_plot.setXLink(self._main_plot) + self._macd_plot.hide() + + def _setup_autocomplete(self): + common = [ + "AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "SPY", "QQQ", + "JPM", "BAC", "V", "MA", "NFLX", "AMD", "INTC", "DIS", "WMT", "KO", + "BTC-USD", "ETH-USD", "SOL-USD", "BNB-USD", "XRP-USD", + ] + self._completer_model = QStringListModel(common) + completer = QCompleter(self._completer_model) + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._search.setCompleter(completer) + + def _on_search(self): + text = self._search.text().strip().upper() + if text: + self.load_symbol(text) + + def _set_period(self, period: str): + for p, btn in self._period_btns.items(): + btn.setChecked(p == period) + self._period = period + if self._symbol: + self.load_symbol(self._symbol) + + def _toggle_indicator(self, indicator: str, state: int): + if state: + self._active_indicators.add(indicator) + else: + self._active_indicators.discard(indicator) + if self._df is not None: + self._render_chart(self._df) + + def load_symbol(self, symbol: str): + self._symbol = symbol.upper() + self._status_label.setText(f"Loading {self._symbol}…") + self._quote_label.setText(self._symbol) + self._change_label.clear() + if self._worker and self._worker.isRunning(): + self._worker.quit() + self._worker = ChartDataWorker(self._symbol, self._period) + self._worker.data_ready.connect(self._on_data_ready) + self._worker.error.connect(lambda e: self._status_label.setText(f"Error: {e}")) + self._worker.start() + self.symbol_changed.emit(self._symbol) + + def _on_data_ready(self, df: pd.DataFrame, symbol: str): + if df.empty: + self._status_label.setText(f"No data for {symbol}") + return + self._df = df + self._render_chart(df) + self._status_label.setText(f"{symbol} | {len(df)} bars | {self._period}") + self._update_quote_display(df) + + def _update_quote_display(self, df: pd.DataFrame): + last = float(df["Close"].iloc[-1]) + prev = float(df["Close"].iloc[-2]) if len(df) > 1 else last + change = last - prev + change_pct = (change / prev * 100) if prev else 0 + self._quote_label.setText(f"{self._symbol} {fmt_currency(last)}") + self._change_label.setText(f"{'+' if change >= 0 else ''}{fmt_currency(change)} {fmt_percent(change_pct, plus=True)}") + self._change_label.setStyleSheet(f"color: {pnl_color(change)};") + + def _render_chart(self, df: pd.DataFrame): + self._build_plots() + xs = np.arange(len(df)) + timestamps = list(df.index) + + date_axis = DateAxisItem(timestamps, orientation="bottom") + self._main_plot.getAxis("bottom").hide() + + candle = CandlestickItem(df) + self._main_plot.addItem(candle) + + # Overlaid indicators + closes = df["Close"].values.astype(float) + + if "SMA20" in self._active_indicators and len(closes) >= 20: + sma20 = pd.Series(closes).rolling(20).mean().values + self._main_plot.plot(xs, sma20, pen=pg.mkPen(COLORS["sma20"], width=1.5), name="SMA20") + + if "SMA50" in self._active_indicators and len(closes) >= 50: + sma50 = pd.Series(closes).rolling(50).mean().values + self._main_plot.plot(xs, sma50, pen=pg.mkPen(COLORS["sma50"], width=1.5), name="SMA50") + + if "EMA20" in self._active_indicators and len(closes) >= 20: + ema20 = pd.Series(closes).ewm(span=20).mean().values + self._main_plot.plot(xs, ema20, pen=pg.mkPen(COLORS["ema20"], width=1.5), name="EMA20") + + if "BB" in self._active_indicators and len(closes) >= 20: + sma = pd.Series(closes).rolling(20).mean() + std = pd.Series(closes).rolling(20).std() + upper = (sma + 2 * std).values + lower = (sma - 2 * std).values + self._main_plot.plot(xs, upper, pen=pg.mkPen(COLORS["bb_upper"], width=1, style=Qt.PenStyle.DashLine)) + self._main_plot.plot(xs, lower, pen=pg.mkPen(COLORS["bb_lower"], width=1, style=Qt.PenStyle.DashLine)) + fill = pg.FillBetweenItem( + self._main_plot.plot(xs, upper, pen=None), + self._main_plot.plot(xs, lower, pen=None), + brush=pg.mkBrush(QColor(137, 220, 235, 30)), + ) + self._main_plot.addItem(fill) + + # Volume + if "Volume" in self._active_indicators: + volumes = df["Volume"].values.astype(float) + colors = [COLORS["volume_up"] if df["Close"].iloc[i] >= df["Open"].iloc[i] else COLORS["volume_down"] for i in range(len(df))] + brushes = [pg.mkBrush(QColor(c)) for c in colors] + bar = pg.BarGraphItem(x=xs, height=volumes, width=0.6, brushes=brushes, pens=[pg.mkPen(None)] * len(xs)) + self._vol_plot.addItem(bar) + self._vol_plot.show() + else: + self._vol_plot.hide() + + # RSI + if "RSI" in self._active_indicators and len(closes) >= 14: + delta = pd.Series(closes).diff() + gain = delta.clip(lower=0).rolling(14).mean() + loss = (-delta.clip(upper=0)).rolling(14).mean() + rs = gain / loss + rsi = (100 - (100 / (1 + rs))).values + self._rsi_plot.clear() + self._rsi_plot.plot(xs, rsi, pen=pg.mkPen(COLORS["rsi_line"], width=1.5)) + self._rsi_plot.addLine(y=70, pen=pg.mkPen("#f38ba8", width=1, style=Qt.PenStyle.DashLine)) + self._rsi_plot.addLine(y=30, pen=pg.mkPen("#a6e3a1", width=1, style=Qt.PenStyle.DashLine)) + self._rsi_plot.show() + else: + self._rsi_plot.hide() + + # MACD + if "MACD" in self._active_indicators and len(closes) >= 26: + ema12 = pd.Series(closes).ewm(span=12).mean() + ema26 = pd.Series(closes).ewm(span=26).mean() + macd_line = (ema12 - ema26).values + signal_line = pd.Series(macd_line).ewm(span=9).mean().values + histogram = macd_line - signal_line + self._macd_plot.clear() + hist_colors = [COLORS["macd_pos"] if v >= 0 else COLORS["macd_neg"] for v in histogram] + bar2 = pg.BarGraphItem(x=xs, height=histogram, width=0.6, + brushes=[pg.mkBrush(QColor(c)) for c in hist_colors], + pens=[pg.mkPen(None)] * len(xs)) + self._macd_plot.addItem(bar2) + self._macd_plot.plot(xs, macd_line, pen=pg.mkPen(COLORS["macd_line"], width=1.5)) + self._macd_plot.plot(xs, signal_line, pen=pg.mkPen(COLORS["macd_signal"], width=1.5)) + self._macd_plot.show() + else: + self._macd_plot.hide() diff --git a/stockmind/ui/dashboard_widget.py b/stockmind/ui/dashboard_widget.py new file mode 100644 index 0000000..16251ae --- /dev/null +++ b/stockmind/ui/dashboard_widget.py @@ -0,0 +1,135 @@ +from __future__ import annotations +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGridLayout, QFrame, QSizePolicy +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread +from PyQt6.QtGui import QFont +from utils.formatters import fmt_currency, fmt_percent, pnl_color + +INDICES = [ + ("SPY", "S&P 500"), + ("QQQ", "NASDAQ 100"), + ("DIA", "Dow Jones"), + ("BTC-USD", "Bitcoin"), + ("ETH-USD", "Ethereum"), +] + + +class QuoteCard(QFrame): + clicked = pyqtSignal(str) + + def __init__(self, symbol: str, name: str, parent=None): + super().__init__(parent) + self.symbol = symbol + self.setObjectName("card") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.setMinimumHeight(100) + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(2) + + top = QHBoxLayout() + self._symbol_label = QLabel(symbol) + self._symbol_label.setFont(QFont("Segoe UI", 10, QFont.Weight.Bold)) + self._name_label = QLabel(name) + self._name_label.setObjectName("subtitle") + self._name_label.setAlignment(Qt.AlignmentFlag.AlignRight) + top.addWidget(self._symbol_label) + top.addWidget(self._name_label) + layout.addLayout(top) + + self._price_label = QLabel("—") + self._price_label.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) + layout.addWidget(self._price_label) + + self._change_label = QLabel("—") + self._change_label.setObjectName("subtitle") + layout.addWidget(self._change_label) + + def update_quote(self, data: dict): + price = data.get("price", 0) + change = data.get("change", 0) + change_pct = data.get("change_pct", 0) + self._price_label.setText(fmt_currency(price)) + sign = "+" if change >= 0 else "" + self._change_label.setText(f"{sign}{fmt_currency(change, 2)} {fmt_percent(change_pct, plus=True)}") + color = pnl_color(change) + self._change_label.setStyleSheet(f"color: {color};") + + def mousePressEvent(self, event): + self.clicked.emit(self.symbol) + super().mousePressEvent(event) + + +class FetchWorker(QThread): + quotes_ready = pyqtSignal(dict) + + def __init__(self, symbols: list[str]): + super().__init__() + self._symbols = symbols + + def run(self): + from core.market_data import get_batch_quotes + data = get_batch_quotes(self._symbols) + self.quotes_ready.emit(data) + + +class DashboardWidget(QWidget): + symbol_selected = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._cards: dict[str, QuoteCard] = {} + self._worker: FetchWorker | None = None + self._setup_ui() + self._start_refresh() + + def _setup_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(20, 20, 20, 20) + root.setSpacing(16) + + header = QLabel("Market Overview") + header.setObjectName("title") + root.addWidget(header) + + self._status_label = QLabel("Refreshing...") + self._status_label.setObjectName("subtitle") + root.addWidget(self._status_label) + + grid = QGridLayout() + grid.setSpacing(12) + for i, (symbol, name) in enumerate(INDICES): + card = QuoteCard(symbol, name) + card.clicked.connect(self.symbol_selected) + self._cards[symbol] = card + grid.addWidget(card, i // 3, i % 3) + + root.addLayout(grid) + root.addStretch() + + def _start_refresh(self): + self._timer = QTimer(self) + self._timer.timeout.connect(self._fetch_quotes) + self._timer.start(300_000) # 5 min + self._fetch_quotes() + + def _fetch_quotes(self): + if self._worker and self._worker.isRunning(): + return + self._status_label.setText("Refreshing...") + self._worker = FetchWorker([s for s, _ in INDICES]) + self._worker.quotes_ready.connect(self._on_quotes_ready) + self._worker.start() + + def _on_quotes_ready(self, data: dict): + from datetime import datetime + for sym, quote in data.items(): + if sym in self._cards: + self._cards[sym].update_quote(quote) + self._status_label.setText(f"Updated {datetime.now().strftime('%H:%M:%S')} (15-min delayed)") + + def force_refresh(self): + self._fetch_quotes() diff --git a/stockmind/ui/main_window.py b/stockmind/ui/main_window.py new file mode 100644 index 0000000..c0e5e27 --- /dev/null +++ b/stockmind/ui/main_window.py @@ -0,0 +1,236 @@ +from __future__ import annotations +import os +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, + QLabel, QPushButton, QStackedWidget, QSizePolicy, QStatusBar, + QApplication +) +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QFont, QIcon + +NAV_ITEMS = [ + ("dashboard", "⊞", "Dashboard"), + ("chart", "📈", "Chart"), + ("watchlist", "★", "Watchlist"), + ("portfolio", "💼", "Portfolio"), + ("ai", "✦", "AI"), + ("news", "📰", "News"), + ("screener", "⚙", "Screener"), + ("settings", "⚙", "Settings"), +] + + +class SidebarButton(QPushButton): + def __init__(self, icon_text: str, tooltip: str, parent=None): + super().__init__(icon_text, parent) + self.setCheckable(True) + self.setFixedSize(44, 44) + self.setToolTip(tooltip) + self.setFont(QFont("Segoe UI Emoji", 14)) + + +class MainWindow(QMainWindow): + def __init__(self, config): + super().__init__() + self._config = config + self._nav_buttons: dict[str, SidebarButton] = {} + self._alert_worker = None + self._setup_status_bar() + self._setup_ui() + self._start_alerts() + + def _setup_ui(self): + self.setWindowTitle("StockMind") + self.setMinimumSize(1200, 700) + self.resize(1440, 860) + + central = QWidget() + self.setCentralWidget(central) + layout = QHBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Sidebar + sidebar = self._build_sidebar() + layout.addWidget(sidebar) + + # Content stack + self._stack = QStackedWidget() + layout.addWidget(self._stack) + + self._build_pages() + self._set_page("dashboard") + + def _build_sidebar(self) -> QWidget: + sidebar = QWidget() + sidebar.setObjectName("sidebar") + sidebar.setFixedWidth(56) + sb_layout = QVBoxLayout(sidebar) + sb_layout.setContentsMargins(6, 8, 6, 8) + sb_layout.setSpacing(4) + sb_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + logo = QLabel("S") + logo.setObjectName("logo_label") + logo.setAlignment(Qt.AlignmentFlag.AlignCenter) + sb_layout.addWidget(logo) + sb_layout.addSpacing(8) + + icons = { + "dashboard": "⊞", + "chart": "📈", + "watchlist": "★", + "portfolio": "💼", + "ai": "✦", + "news": "📰", + "screener": "🔍", + } + labels = { + "dashboard": "Dashboard", + "chart": "Chart", + "watchlist": "Watchlist", + "portfolio": "Portfolio", + "ai": "AI", + "news": "News", + "screener": "Screener", + } + + for page_id, icon in icons.items(): + btn = SidebarButton(icon, labels[page_id]) + btn.clicked.connect(lambda checked, pid=page_id: self._set_page(pid)) + self._nav_buttons[page_id] = btn + sb_layout.addWidget(btn) + + sb_layout.addStretch() + + # Theme toggle + self._theme_btn = SidebarButton("☀", "Toggle Theme") + self._theme_btn.setCheckable(False) + self._theme_btn.clicked.connect(self._toggle_theme) + sb_layout.addWidget(self._theme_btn) + + # Settings + settings_btn = SidebarButton("⚙", "Settings") + settings_btn.setCheckable(False) + settings_btn.clicked.connect(self._open_settings) + sb_layout.addWidget(settings_btn) + + return sidebar + + def _build_pages(self): + from ui.dashboard_widget import DashboardWidget + from ui.chart_widget import ChartWidget + from ui.watchlist_widget import WatchlistWidget + from ui.portfolio_widget import PortfolioWidget + from ui.ai_panel import AIPanelWidget + from ui.news_widget import NewsWidget + from ui.screener_widget import ScreenerWidget + + self._dashboard = DashboardWidget() + self._chart = ChartWidget() + self._watchlist = WatchlistWidget() + self._portfolio = PortfolioWidget(self._config) + self._ai_panel = AIPanelWidget(self._config) + self._news = NewsWidget(self._config) + self._screener = ScreenerWidget() + + pages = [ + ("dashboard", self._dashboard), + ("chart", self._chart), + ("watchlist", self._watchlist), + ("portfolio", self._portfolio), + ("ai", self._ai_panel), + ("news", self._news), + ("screener", self._screener), + ] + self._page_indices: dict[str, int] = {} + for page_id, widget in pages: + idx = self._stack.addWidget(widget) + self._page_indices[page_id] = idx + + # Wire cross-widget signals + self._dashboard.symbol_selected.connect(self._navigate_to_chart) + self._watchlist.symbol_selected.connect(self._navigate_to_chart) + self._portfolio.symbol_selected.connect(self._navigate_to_chart) + self._screener.symbol_selected.connect(self._navigate_to_chart) + self._screener.add_to_watchlist.connect(lambda s: self._watchlist.add_symbol_external(s)) + self._chart.symbol_changed.connect(self._on_symbol_changed) + + def _set_page(self, page_id: str): + for pid, btn in self._nav_buttons.items(): + btn.setChecked(pid == page_id) + if page_id in self._page_indices: + self._stack.setCurrentIndex(self._page_indices[page_id]) + self._status_page_label.setText(page_id.capitalize()) + + def _navigate_to_chart(self, symbol: str): + self._chart.load_symbol(symbol) + self._set_page("chart") + + def _on_symbol_changed(self, symbol: str): + self._ai_panel.set_symbol(symbol) + self._news.set_symbol(symbol) + + def _toggle_theme(self): + current = self._config.get_setting("theme", "dark") + new_theme = "light" if current == "dark" else "dark" + self._config.set_setting("theme", new_theme) + self._apply_theme(new_theme) + self._theme_btn.setText("☀" if new_theme == "dark" else "🌙") + + def _apply_theme(self, theme: str): + base_dir = os.path.dirname(os.path.dirname(__file__)) + path = os.path.join(base_dir, "assets", "styles", f"{theme}.qss") + if os.path.exists(path): + with open(path, "r") as f: + QApplication.instance().setStyleSheet(f.read()) + + def _open_settings(self): + from ui.settings_dialog import SettingsDialog + dlg = SettingsDialog(self._config, self) + dlg.exec() + + def _start_alerts(self): + from core.alerts import AlertWorker + self._alert_worker = AlertWorker(interval_seconds=300) + self._alert_worker.alert_triggered.connect(self._on_alert_triggered) + self._alert_worker.start() + + def _on_alert_triggered(self, symbol: str, alert_type: str, message: str): + self.statusBar().showMessage(f"⚡ Alert: {message}", 10000) + + def _setup_status_bar(self): + sb = self.statusBar() + self._status_page_label = QLabel("Dashboard") + self._status_page_label.setObjectName("subtitle") + sb.addWidget(self._status_page_label) + + self._market_status_label = QLabel() + self._market_status_label.setObjectName("subtitle") + sb.addPermanentWidget(self._market_status_label) + self._update_market_status() + + timer = QTimer(self) + timer.timeout.connect(self._update_market_status) + timer.start(60_000) + + def _update_market_status(self): + from datetime import datetime, timezone + import zoneinfo + try: + et = zoneinfo.ZoneInfo("America/New_York") + now_et = datetime.now(et) + weekday = now_et.weekday() + hour = now_et.hour + minute = now_et.minute + is_market_hours = (weekday < 5) and (9 * 60 + 30 <= hour * 60 + minute < 16 * 60) + status = "🟢 Market Open" if is_market_hours else "🔴 Market Closed" + self._market_status_label.setText(status) + except Exception: + self._market_status_label.setText("") + + def closeEvent(self, event): + if self._alert_worker: + self._alert_worker.stop() + self._alert_worker.wait(2000) + super().closeEvent(event) diff --git a/stockmind/ui/news_widget.py b/stockmind/ui/news_widget.py new file mode 100644 index 0000000..de38d7b --- /dev/null +++ b/stockmind/ui/news_widget.py @@ -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) diff --git a/stockmind/ui/portfolio_widget.py b/stockmind/ui/portfolio_widget.py new file mode 100644 index 0000000..821f1f5 --- /dev/null +++ b/stockmind/ui/portfolio_widget.py @@ -0,0 +1,313 @@ +from __future__ import annotations +import math +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, + QTableWidgetItem, QTabWidget, QSplitter, QFrame, QMessageBox, + QDialog, QFormLayout, QLineEdit, QDialogButtonBox, QDoubleSpinBox, QSizePolicy +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QRectF +from PyQt6.QtGui import QColor, QPainter, QBrush, QPen, QFont +from utils.formatters import fmt_currency, fmt_percent, fmt_large_number, pnl_color + + +class SyncWorker(QThread): + positions_ready = pyqtSignal(list, object) + error = pyqtSignal(str) + + def __init__(self, broker): + super().__init__() + self._broker = broker + + def run(self): + try: + positions = self._broker.get_positions() + balance = self._broker.get_balance() + self.positions_ready.emit(positions, balance) + except Exception as e: + self.error.emit(str(e)) + + +class PieChartWidget(QWidget): + def __init__(self, data: dict[str, float] | None = None, parent=None): + super().__init__(parent) + self._data = data or {} + self.setMinimumSize(220, 220) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + def set_data(self, data: dict[str, float]): + self._data = data + self.update() + + def paintEvent(self, event): + if not self._data: + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + w, h = self.width(), self.height() + margin = 40 + side = min(w - margin * 2, h - margin * 2) + x = (w - side) // 2 + y = (h - side) // 2 + rect = QRectF(x, y, side, side) + + palette = ["#89b4fa", "#a6e3a1", "#f38ba8", "#fab387", "#cba6f7", + "#f9e2af", "#89dceb", "#94e2d5", "#b4befe", "#eba0ac"] + + total = sum(self._data.values()) + if total == 0: + return + + angle = 0.0 + items = sorted(self._data.items(), key=lambda x: -x[1]) + for i, (label, value) in enumerate(items): + span = value / total * 360 * 16 + color = QColor(palette[i % len(palette)]) + painter.setBrush(QBrush(color)) + painter.setPen(QPen(QColor("#1e1e2e"), 2)) + painter.drawPie(rect, int(angle), int(span)) + angle += span + + # Legend + legend_x = 8 + legend_y = 8 + painter.setFont(QFont("Segoe UI", 8)) + for i, (label, value) in enumerate(items[:8]): + color = QColor(palette[i % len(palette)]) + painter.setBrush(QBrush(color)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRect(legend_x, legend_y + i * 16, 10, 10) + painter.setPen(QPen(QColor("#cdd6f4"))) + painter.drawText(legend_x + 14, legend_y + i * 16 + 9, + f"{label[:14]} {value:.1f}%") + + +class AddPositionDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Add Position") + self.setMinimumWidth(300) + layout = QFormLayout(self) + + self.symbol_input = QLineEdit() + self.symbol_input.setPlaceholderText("e.g. AAPL") + self.shares_input = QDoubleSpinBox() + self.shares_input.setRange(0.0001, 1_000_000) + self.shares_input.setDecimals(4) + self.cost_input = QDoubleSpinBox() + self.cost_input.setRange(0.0, 1_000_000) + self.cost_input.setDecimals(2) + self.cost_input.setPrefix("$") + + layout.addRow("Symbol:", self.symbol_input) + layout.addRow("Shares:", self.shares_input) + layout.addRow("Avg Cost/Share:", self.cost_input) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + +class PortfolioWidget(QWidget): + symbol_selected = pyqtSignal(str) + + def __init__(self, config, parent=None): + super().__init__(parent) + self._config = config + self._positions: list[dict] = [] + self._worker: SyncWorker | None = None + self._setup_ui() + self._load_manual_positions() + + def _setup_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + header_row = QHBoxLayout() + header = QLabel("Portfolio") + header.setObjectName("title") + header_row.addWidget(header) + header_row.addStretch() + + schwab_btn = QPushButton("Sync Schwab") + schwab_btn.clicked.connect(self._sync_schwab) + rh_btn = QPushButton("Sync Robinhood") + rh_btn.clicked.connect(self._sync_robinhood) + add_btn = QPushButton("+ Add Manual") + add_btn.setObjectName("primary_btn") + add_btn.clicked.connect(self._add_manual) + + for btn in (schwab_btn, rh_btn, add_btn): + header_row.addWidget(btn) + root.addLayout(header_row) + + # Balance summary + self._balance_frame = QFrame() + self._balance_frame.setObjectName("card") + bal_layout = QHBoxLayout(self._balance_frame) + self._equity_label = QLabel("Total Equity\n—") + self._cash_label = QLabel("Cash\n—") + self._bp_label = QLabel("Buying Power\n—") + self._day_pnl_label = QLabel("Day P&L\n—") + for lbl in (self._equity_label, self._cash_label, self._bp_label, self._day_pnl_label): + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setFont(QFont("Segoe UI", 9)) + bal_layout.addWidget(lbl) + root.addWidget(self._balance_frame) + + # Holdings table + pie chart + splitter = QSplitter(Qt.Orientation.Horizontal) + + self._table = QTableWidget() + self._table.setColumnCount(7) + self._table.setHorizontalHeaderLabels(["Symbol", "Shares", "Avg Cost", "Price", "Value", "P&L $", "P&L %"]) + self._table.setAlternatingRowColors(True) + self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self._table.horizontalHeader().setStretchLastSection(True) + self._table.doubleClicked.connect(self._on_row_double_click) + splitter.addWidget(self._table) + + self._pie_chart = PieChartWidget() + splitter.addWidget(self._pie_chart) + splitter.setSizes([700, 250]) + root.addWidget(splitter) + + self._status_label = QLabel("") + self._status_label.setObjectName("subtitle") + root.addWidget(self._status_label) + + def _load_manual_positions(self): + from db.database import get_session + from db.models import PortfolioPosition + from core.market_data import get_batch_quotes + from core.portfolio import calculate_pnl + + with get_session() as session: + rows = session.query(PortfolioPosition).all() + raw = [{"symbol": r.symbol, "shares": r.shares, "avg_cost": r.avg_cost, "broker": r.broker} for r in rows] + + if raw: + quotes = get_batch_quotes([r["symbol"] for r in raw]) + self._positions = calculate_pnl(raw, quotes) + else: + self._positions = [] + self._render_table() + + def _render_table(self): + self._table.setRowCount(len(self._positions)) + total_value = 0.0 + sector_alloc: dict[str, float] = {} + + for row, pos in enumerate(self._positions): + symbol = pos.get("symbol", "") + items = [ + symbol, + f"{pos.get('shares', 0):.4f}", + fmt_currency(pos.get("avg_cost", 0)), + fmt_currency(pos.get("price", 0)), + fmt_currency(pos.get("market_value", 0)), + fmt_currency(pos.get("pnl", 0)), + fmt_percent(pos.get("pnl_pct", 0), plus=True), + ] + for col, text in enumerate(items): + cell = QTableWidgetItem(text) + cell.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + if col == 0: + cell.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + if col in (5, 6): + pnl = pos.get("pnl", 0) + cell.setForeground(QColor(pnl_color(pnl))) + self._table.setItem(row, col, cell) + + mv = pos.get("market_value", 0) + total_value += mv + sector = pos.get("sector", "Unknown") or "Unknown" + sector_alloc[sector] = sector_alloc.get(sector, 0) + mv + + if total_value > 0: + sector_pct = {s: v / total_value * 100 for s, v in sector_alloc.items()} + self._pie_chart.set_data(sector_pct) + + self._status_label.setText(f"Total Value: {fmt_currency(total_value)} | {len(self._positions)} positions") + + def _update_balance(self, balance): + self._equity_label.setText(f"Total Equity\n{fmt_currency(balance.total_equity)}") + self._cash_label.setText(f"Cash\n{fmt_currency(balance.cash)}") + self._bp_label.setText(f"Buying Power\n{fmt_currency(balance.buying_power)}") + day_pnl = balance.day_pnl + color = pnl_color(day_pnl) + self._day_pnl_label.setText(f"Day P&L\n{fmt_currency(day_pnl)}") + self._day_pnl_label.setStyleSheet(f"color: {color};") + + def _sync_schwab(self): + from brokers.schwab import SchwabBroker + key = self._config.schwab_app_key + secret = self._config.schwab_app_secret + if not key or not secret: + QMessageBox.warning(self, "Schwab", "Schwab API keys not configured. Go to Settings.") + return + try: + broker = SchwabBroker(key, secret) + broker.connect() + self._status_label.setText("Syncing Schwab…") + self._worker = SyncWorker(broker) + self._worker.positions_ready.connect(self._on_broker_sync) + self._worker.error.connect(lambda e: QMessageBox.critical(self, "Schwab Error", e)) + self._worker.start() + except Exception as e: + QMessageBox.critical(self, "Schwab Error", str(e)) + + def _sync_robinhood(self): + from brokers.robinhood import RobinhoodBroker + user = self._config.robinhood_username + pwd = self._config.robinhood_password + if not user or not pwd: + QMessageBox.warning(self, "Robinhood", "Robinhood credentials not configured. Go to Settings.") + return + try: + broker = RobinhoodBroker(user, pwd) + broker.connect() + self._status_label.setText("Syncing Robinhood…") + self._worker = SyncWorker(broker) + self._worker.positions_ready.connect(self._on_broker_sync) + self._worker.error.connect(lambda e: QMessageBox.critical(self, "Robinhood Error", e)) + self._worker.start() + except Exception as e: + QMessageBox.critical(self, "Robinhood Error", str(e)) + + def _on_broker_sync(self, positions, balance): + from core.market_data import get_batch_quotes + from core.portfolio import calculate_pnl + raw = [{"symbol": p.symbol, "shares": p.shares, "avg_cost": p.avg_cost, "broker": "broker"} for p in positions] + quotes = get_batch_quotes([p.symbol for p in positions]) + self._positions = calculate_pnl(raw, quotes) + self._render_table() + self._update_balance(balance) + + def _add_manual(self): + dlg = AddPositionDialog(self) + if dlg.exec() == QDialog.DialogCode.Accepted: + symbol = dlg.symbol_input.text().strip().upper() + shares = dlg.shares_input.value() + avg_cost = dlg.cost_input.value() + if symbol and shares > 0: + from db.database import get_session + from db.models import PortfolioPosition + with get_session() as session: + existing = session.query(PortfolioPosition).filter_by(symbol=symbol, broker="manual").first() + if existing: + existing.shares = shares + existing.avg_cost = avg_cost + else: + session.add(PortfolioPosition(symbol=symbol, shares=shares, avg_cost=avg_cost)) + session.commit() + self._load_manual_positions() + + def _on_row_double_click(self, index): + row = index.row() + if row < len(self._positions): + symbol = self._positions[row].get("symbol", "") + if symbol: + self.symbol_selected.emit(symbol) diff --git a/stockmind/ui/screener_widget.py b/stockmind/ui/screener_widget.py new file mode 100644 index 0000000..52bedbc --- /dev/null +++ b/stockmind/ui/screener_widget.py @@ -0,0 +1,262 @@ +from __future__ import annotations +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, + QTableWidgetItem, QDoubleSpinBox, QComboBox, QGroupBox, QFormLayout, + QCheckBox, QProgressBar, QMenu +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QColor +from utils.formatters import fmt_currency, fmt_percent, fmt_large_number, pnl_color + +SECTORS = ["All", "Technology", "Healthcare", "Financials", "Consumer Cyclical", + "Communication Services", "Industrials", "Consumer Defensive", + "Energy", "Basic Materials", "Real Estate", "Utilities", "Crypto"] + + +class ScreenerWorker(QThread): + data_ready = pyqtSignal(list) + progress = pyqtSignal(int) + error = pyqtSignal(str) + + def __init__(self, filters: dict): + super().__init__() + self._filters = filters + + def run(self): + try: + from core.market_data import get_screener_data, SCREENER_UNIVERSE + raw = get_screener_data(SCREENER_UNIVERSE) + self.progress.emit(80) + filtered = self._apply_filters(raw) + self.data_ready.emit(filtered) + except Exception as e: + self.error.emit(str(e)) + + def _apply_filters(self, data: list[dict]) -> list[dict]: + f = self._filters + result = [] + for row in data: + if f.get("sector") and f["sector"] != "All": + if row.get("sector", "") != f["sector"]: + continue + + if row.get("pe_ratio") is not None: + pe = row["pe_ratio"] + if f.get("pe_min") and pe < f["pe_min"]: + continue + if f.get("pe_max") and pe > f["pe_max"]: + continue + + if row.get("market_cap") is not None: + mc = row["market_cap"] + if f.get("mc_min") and mc < f["mc_min"] * 1e9: + continue + if f.get("mc_max") and mc > f["mc_max"] * 1e9: + continue + + if row.get("rsi") is not None: + rsi = row["rsi"] + if f.get("rsi_min") and rsi < f["rsi_min"]: + continue + if f.get("rsi_max") and rsi > f["rsi_max"]: + continue + + if row.get("pct_from_52h") is not None: + pct = row["pct_from_52h"] + if f.get("from_52h_min") and pct < f["from_52h_min"]: + continue + if f.get("from_52h_max") and pct > f["from_52h_max"]: + continue + + result.append(row) + return result + + +class ScreenerWidget(QWidget): + symbol_selected = pyqtSignal(str) + add_to_watchlist = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._worker: ScreenerWorker | None = None + self._results: list[dict] = [] + self._setup_ui() + + def _setup_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(10) + + header = QLabel("Stock Screener") + header.setObjectName("title") + root.addWidget(header) + + # Filters + filters_box = QGroupBox("Filters") + filters_layout = QHBoxLayout(filters_box) + + # Sector + sector_form = QFormLayout() + self._sector_combo = QComboBox() + self._sector_combo.addItems(SECTORS) + sector_form.addRow("Sector:", self._sector_combo) + filters_layout.addLayout(sector_form) + + # PE + pe_form = QFormLayout() + self._pe_min = QDoubleSpinBox() + self._pe_min.setRange(0, 10000) + self._pe_min.setSpecialValueText("—") + self._pe_max = QDoubleSpinBox() + self._pe_max.setRange(0, 10000) + self._pe_max.setSpecialValueText("—") + pe_form.addRow("P/E Min:", self._pe_min) + pe_form.addRow("P/E Max:", self._pe_max) + filters_layout.addLayout(pe_form) + + # Market Cap + mc_form = QFormLayout() + self._mc_min = QDoubleSpinBox() + self._mc_min.setRange(0, 100000) + self._mc_min.setSuffix("B") + self._mc_min.setSpecialValueText("—") + self._mc_max = QDoubleSpinBox() + self._mc_max.setRange(0, 100000) + self._mc_max.setSuffix("B") + self._mc_max.setSpecialValueText("—") + mc_form.addRow("MCap Min:", self._mc_min) + mc_form.addRow("MCap Max:", self._mc_max) + filters_layout.addLayout(mc_form) + + # RSI + rsi_form = QFormLayout() + self._rsi_min = QDoubleSpinBox() + self._rsi_min.setRange(0, 100) + self._rsi_min.setSpecialValueText("—") + self._rsi_max = QDoubleSpinBox() + self._rsi_max.setRange(0, 100) + self._rsi_max.setValue(100) + rsi_form.addRow("RSI Min:", self._rsi_min) + rsi_form.addRow("RSI Max:", self._rsi_max) + filters_layout.addLayout(rsi_form) + + # 52W + week_form = QFormLayout() + self._from_52h_min = QDoubleSpinBox() + self._from_52h_min.setRange(-100, 0) + self._from_52h_min.setSpecialValueText("—") + self._from_52h_min.setSuffix("%") + self._from_52h_max = QDoubleSpinBox() + self._from_52h_max.setRange(-100, 100) + self._from_52h_max.setSpecialValueText("—") + self._from_52h_max.setSuffix("%") + week_form.addRow("% from 52H min:", self._from_52h_min) + week_form.addRow("% from 52H max:", self._from_52h_max) + filters_layout.addLayout(week_form) + + root.addWidget(filters_box) + + # Run button + progress + btn_row = QHBoxLayout() + self._run_btn = QPushButton("Run Screener") + self._run_btn.setObjectName("primary_btn") + self._run_btn.clicked.connect(self._run_screener) + self._progress = QProgressBar() + self._progress.setMaximumWidth(200) + self._progress.hide() + btn_row.addWidget(self._run_btn) + btn_row.addWidget(self._progress) + btn_row.addStretch() + self._result_label = QLabel("") + self._result_label.setObjectName("subtitle") + btn_row.addWidget(self._result_label) + root.addLayout(btn_row) + + # Results table + self._table = QTableWidget() + self._table.setColumnCount(8) + self._table.setHorizontalHeaderLabels(["Symbol", "Name", "Price", "Sector", "MCap", "P/E", "RSI", "% from 52H"]) + self._table.setAlternatingRowColors(True) + self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self._table.horizontalHeader().setStretchLastSection(True) + self._table.doubleClicked.connect(self._on_row_double_click) + self._table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._table.customContextMenuRequested.connect(self._show_context_menu) + root.addWidget(self._table) + + def _run_screener(self): + if self._worker and self._worker.isRunning(): + return + self._run_btn.setEnabled(False) + self._progress.setValue(0) + self._progress.show() + self._result_label.setText("Scanning…") + + filters = { + "sector": self._sector_combo.currentText(), + "pe_min": self._pe_min.value() or None, + "pe_max": self._pe_max.value() or None, + "mc_min": self._mc_min.value() or None, + "mc_max": self._mc_max.value() or None, + "rsi_min": self._rsi_min.value() or None, + "rsi_max": self._rsi_max.value() if self._rsi_max.value() < 100 else None, + "from_52h_min": self._from_52h_min.value() or None, + "from_52h_max": self._from_52h_max.value() or None, + } + + self._worker = ScreenerWorker(filters) + self._worker.data_ready.connect(self._on_results) + self._worker.progress.connect(self._progress.setValue) + self._worker.error.connect(lambda e: ( + self._result_label.setText(f"Error: {e}"), + self._run_btn.setEnabled(True), + self._progress.hide(), + )) + self._worker.start() + + def _on_results(self, results: list[dict]): + self._results = results + self._table.setRowCount(len(results)) + for row, r in enumerate(results): + pct_52h = r.get("pct_from_52h") + items_data = [ + (r.get("symbol", ""), Qt.AlignmentFlag.AlignLeft), + (r.get("name", "")[:30], Qt.AlignmentFlag.AlignLeft), + (fmt_currency(r.get("price")), Qt.AlignmentFlag.AlignRight), + (r.get("sector", ""), Qt.AlignmentFlag.AlignLeft), + (fmt_large_number(r.get("market_cap")), Qt.AlignmentFlag.AlignRight), + (f"{r['pe_ratio']:.1f}" if r.get("pe_ratio") else "—", Qt.AlignmentFlag.AlignRight), + (f"{r['rsi']:.1f}" if r.get("rsi") else "—", Qt.AlignmentFlag.AlignRight), + (fmt_percent(pct_52h, plus=True) if pct_52h is not None else "—", Qt.AlignmentFlag.AlignRight), + ] + for col, (text, align) in enumerate(items_data): + cell = QTableWidgetItem(str(text)) + cell.setTextAlignment(align | Qt.AlignmentFlag.AlignVCenter) + if col == 7 and pct_52h is not None: + cell.setForeground(QColor(pnl_color(pct_52h))) + self._table.setItem(row, col, cell) + + self._run_btn.setEnabled(True) + self._progress.hide() + self._result_label.setText(f"{len(results)} results") + + def _on_row_double_click(self, index): + row = index.row() + if row < len(self._results): + sym = self._results[row].get("symbol", "") + if sym: + self.symbol_selected.emit(sym) + + def _show_context_menu(self, pos): + row = self._table.rowAt(pos.y()) + if row < 0 or row >= len(self._results): + return + sym = self._results[row].get("symbol", "") + menu = QMenu(self) + chart_action = menu.addAction(f"View Chart: {sym}") + watch_action = menu.addAction("Add to Watchlist") + action = menu.exec(self._table.mapToGlobal(pos)) + if action == chart_action: + self.symbol_selected.emit(sym) + elif action == watch_action: + self.add_to_watchlist.emit(sym) diff --git a/stockmind/ui/settings_dialog.py b/stockmind/ui/settings_dialog.py new file mode 100644 index 0000000..02827cc --- /dev/null +++ b/stockmind/ui/settings_dialog.py @@ -0,0 +1,216 @@ +from __future__ import annotations +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QTabWidget, QWidget, QFormLayout, QGroupBox, QMessageBox, QCheckBox +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + + +class SettingsDialog(QDialog): + def __init__(self, config, parent=None): + super().__init__(parent) + self._config = config + self.setWindowTitle("Settings") + self.setMinimumWidth(480) + self.setMinimumHeight(440) + self._setup_ui() + self._load_values() + + def _setup_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(16, 16, 16, 16) + root.setSpacing(12) + + tabs = QTabWidget() + tabs.addTab(self._build_api_tab(), "API Keys") + tabs.addTab(self._build_brokers_tab(), "Brokers") + tabs.addTab(self._build_general_tab(), "General") + root.addWidget(tabs) + + btn_row = QHBoxLayout() + btn_row.addStretch() + save_btn = QPushButton("Save") + save_btn.setObjectName("primary_btn") + save_btn.clicked.connect(self._save) + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(cancel_btn) + btn_row.addWidget(save_btn) + root.addLayout(btn_row) + + def _build_api_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(12) + + # Anthropic + anthropic_box = QGroupBox("Anthropic (Claude AI)") + anthropic_form = QFormLayout(anthropic_box) + self._anthropic_key = QLineEdit() + self._anthropic_key.setEchoMode(QLineEdit.EchoMode.Password) + self._anthropic_key.setPlaceholderText("sk-ant-…") + test_anthropic = QPushButton("Test") + test_anthropic.setMaximumWidth(60) + test_anthropic.clicked.connect(self._test_anthropic) + row = QHBoxLayout() + row.addWidget(self._anthropic_key) + row.addWidget(test_anthropic) + anthropic_form.addRow("API Key:", row) + anthropic_form.addRow("", QLabel("Get key: console.anthropic.com | Pay per token")) + layout.addWidget(anthropic_box) + + # NewsAPI + news_box = QGroupBox("NewsAPI") + news_form = QFormLayout(news_box) + self._news_key = QLineEdit() + self._news_key.setEchoMode(QLineEdit.EchoMode.Password) + self._news_key.setPlaceholderText("Your NewsAPI key…") + test_news = QPushButton("Test") + test_news.setMaximumWidth(60) + test_news.clicked.connect(self._test_news) + row2 = QHBoxLayout() + row2.addWidget(self._news_key) + row2.addWidget(test_news) + news_form.addRow("API Key:", row2) + news_form.addRow("", QLabel("Get key: newsapi.org | 100 req/day free")) + layout.addWidget(news_box) + layout.addStretch() + return tab + + def _build_brokers_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(12) + + # Schwab + schwab_box = QGroupBox("Charles Schwab") + schwab_form = QFormLayout(schwab_box) + self._schwab_key = QLineEdit() + self._schwab_key.setPlaceholderText("App Key…") + self._schwab_secret = QLineEdit() + self._schwab_secret.setEchoMode(QLineEdit.EchoMode.Password) + self._schwab_secret.setPlaceholderText("App Secret…") + test_schwab = QPushButton("Test Connection") + test_schwab.clicked.connect(self._test_schwab) + schwab_form.addRow("App Key:", self._schwab_key) + schwab_form.addRow("App Secret:", self._schwab_secret) + schwab_form.addRow("", test_schwab) + schwab_form.addRow("", QLabel("Register at: developer.schwab.com\nCallback URL: https://127.0.0.1")) + layout.addWidget(schwab_box) + + # Robinhood + rh_box = QGroupBox("Robinhood") + rh_form = QFormLayout(rh_box) + self._rh_username = QLineEdit() + self._rh_username.setPlaceholderText("Email…") + self._rh_password = QLineEdit() + self._rh_password.setEchoMode(QLineEdit.EchoMode.Password) + self._rh_password.setPlaceholderText("Password…") + test_rh = QPushButton("Test Login") + test_rh.clicked.connect(self._test_robinhood) + rh_form.addRow("Username:", self._rh_username) + rh_form.addRow("Password:", self._rh_password) + rh_form.addRow("", test_rh) + layout.addWidget(rh_box) + layout.addStretch() + return tab + + def _build_general_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(12) + + general_box = QGroupBox("Appearance & Behavior") + general_form = QFormLayout(general_box) + self._theme_dark = QCheckBox("Dark theme") + self._theme_dark.setChecked(True) + general_form.addRow("Theme:", self._theme_dark) + layout.addWidget(general_box) + layout.addStretch() + return tab + + def _load_values(self): + self._anthropic_key.setText(self._config.get_setting("anthropic_api_key") or self._config.get("ANTHROPIC_API_KEY")) + self._news_key.setText(self._config.get_setting("news_api_key") or self._config.get("NEWS_API_KEY")) + self._schwab_key.setText(self._config.get_setting("schwab_app_key") or self._config.get("SCHWAB_APP_KEY")) + self._schwab_secret.setText(self._config.get_setting("schwab_app_secret") or self._config.get("SCHWAB_APP_SECRET")) + self._rh_username.setText(self._config.get_setting("robinhood_username") or self._config.get("ROBINHOOD_USERNAME")) + self._rh_password.setText(self._config.get_setting("robinhood_password") or self._config.get("ROBINHOOD_PASSWORD")) + theme = self._config.get_setting("theme", "dark") + self._theme_dark.setChecked(theme == "dark") + + def _save(self): + self._config.set_setting("anthropic_api_key", self._anthropic_key.text().strip()) + self._config.set_setting("news_api_key", self._news_key.text().strip()) + self._config.set_setting("schwab_app_key", self._schwab_key.text().strip()) + self._config.set_setting("schwab_app_secret", self._schwab_secret.text().strip()) + self._config.set_setting("robinhood_username", self._rh_username.text().strip()) + self._config.set_setting("robinhood_password", self._rh_password.text().strip()) + self._config.set_setting("theme", "dark" if self._theme_dark.isChecked() else "light") + QMessageBox.information(self, "Saved", "Settings saved. Restart to apply theme changes.") + self.accept() + + def _test_anthropic(self): + key = self._anthropic_key.text().strip() + if not key: + QMessageBox.warning(self, "Test Failed", "Enter an API key first.") + return + try: + import anthropic + client = anthropic.Anthropic(api_key=key) + response = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=10, + messages=[{"role": "user", "content": "ping"}], + ) + QMessageBox.information(self, "Success", "Anthropic API key is valid!") + except Exception as e: + QMessageBox.critical(self, "Test Failed", str(e)) + + def _test_news(self): + key = self._news_key.text().strip() + if not key: + QMessageBox.warning(self, "Test Failed", "Enter an API key first.") + return + try: + from newsapi import NewsApiClient + client = NewsApiClient(api_key=key) + result = client.get_top_headlines(language="en", page_size=1) + if result.get("status") == "ok": + QMessageBox.information(self, "Success", "NewsAPI key is valid!") + else: + QMessageBox.critical(self, "Test Failed", result.get("message", "Unknown error")) + except Exception as e: + QMessageBox.critical(self, "Test Failed", str(e)) + + def _test_schwab(self): + key = self._schwab_key.text().strip() + secret = self._schwab_secret.text().strip() + if not key or not secret: + QMessageBox.warning(self, "Test Failed", "Enter both App Key and Secret first.") + return + try: + from brokers.schwab import SchwabBroker + broker = SchwabBroker(key, secret) + broker.connect() + QMessageBox.information(self, "Success", "Schwab connected! Browser may open for OAuth if first time.") + except Exception as e: + QMessageBox.critical(self, "Test Failed", str(e)) + + def _test_robinhood(self): + user = self._rh_username.text().strip() + pwd = self._rh_password.text().strip() + if not user or not pwd: + QMessageBox.warning(self, "Test Failed", "Enter username and password first.") + return + try: + from brokers.robinhood import RobinhoodBroker + broker = RobinhoodBroker(user, pwd) + broker.connect() + QMessageBox.information(self, "Success", "Robinhood login successful!") + except Exception as e: + QMessageBox.critical(self, "Test Failed", str(e)) diff --git a/stockmind/ui/watchlist_widget.py b/stockmind/ui/watchlist_widget.py new file mode 100644 index 0000000..ec400ae --- /dev/null +++ b/stockmind/ui/watchlist_widget.py @@ -0,0 +1,197 @@ +from __future__ import annotations +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QTreeWidget, QTreeWidgetItem, QMenu, QInputDialog, QMessageBox, QComboBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer +from PyQt6.QtGui import QColor, QFont +from utils.formatters import fmt_currency, fmt_percent, pnl_color + + +class WatchlistFetchWorker(QThread): + quotes_ready = pyqtSignal(dict) + + def __init__(self, symbols: list[str]): + super().__init__() + self._symbols = symbols + + def run(self): + from core.market_data import get_batch_quotes + self.quotes_ready.emit(get_batch_quotes(self._symbols)) + + +class WatchlistWidget(QWidget): + symbol_selected = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._worker: WatchlistFetchWorker | None = None + self._setup_ui() + self._load_from_db() + self._start_timer() + + def _setup_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + header = QLabel("Watchlist") + header.setObjectName("title") + root.addWidget(header) + + # Add symbol row + add_row = QHBoxLayout() + self._symbol_input = QLineEdit() + self._symbol_input.setPlaceholderText("Add symbol…") + self._symbol_input.returnPressed.connect(self._add_symbol) + + self._group_combo = QComboBox() + self._group_combo.setEditable(True) + self._group_combo.addItems(["Default", "Tech", "Crypto", "ETFs"]) + self._group_combo.setMaximumWidth(100) + + add_btn = QPushButton("Add") + add_btn.setObjectName("primary_btn") + add_btn.clicked.connect(self._add_symbol) + + add_row.addWidget(self._symbol_input) + add_row.addWidget(self._group_combo) + add_row.addWidget(add_btn) + root.addLayout(add_row) + + # Tree + self._tree = QTreeWidget() + self._tree.setColumnCount(4) + self._tree.setHeaderLabels(["Symbol", "Price", "Change", "% Change"]) + self._tree.header().setStretchLastSection(False) + self._tree.setColumnWidth(0, 120) + self._tree.setColumnWidth(1, 90) + self._tree.setColumnWidth(2, 80) + self._tree.setColumnWidth(3, 80) + self._tree.setAlternatingRowColors(True) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._show_context_menu) + root.addWidget(self._tree) + + self._status_label = QLabel("") + self._status_label.setObjectName("subtitle") + root.addWidget(self._status_label) + + def _start_timer(self): + self._timer = QTimer(self) + self._timer.timeout.connect(self._refresh_quotes) + self._timer.start(60_000) + + def _load_from_db(self): + from db.database import get_session + from db.models import WatchlistItem + self._tree.clear() + groups: dict[str, QTreeWidgetItem] = {} + with get_session() as session: + items = session.query(WatchlistItem).order_by(WatchlistItem.group_name).all() + for item in items: + group = item.group_name or "Default" + if group not in groups: + group_item = QTreeWidgetItem([group]) + group_item.setFont(0, QFont("Segoe UI", 9, QFont.Weight.Bold)) + group_item.setForeground(0, QColor("#89b4fa")) + self._tree.addTopLevelItem(group_item) + group_item.setExpanded(True) + groups[group] = group_item + + child = QTreeWidgetItem([item.symbol, "—", "—", "—"]) + child.setData(0, Qt.ItemDataRole.UserRole, item.symbol) + groups[group].addChild(child) + + self._refresh_quotes() + + def _refresh_quotes(self): + symbols = self._collect_symbols() + if not symbols or (self._worker and self._worker.isRunning()): + return + self._worker = WatchlistFetchWorker(symbols) + self._worker.quotes_ready.connect(self._apply_quotes) + self._worker.start() + + def _collect_symbols(self) -> list[str]: + symbols = [] + for i in range(self._tree.topLevelItemCount()): + group = self._tree.topLevelItem(i) + for j in range(group.childCount()): + sym = group.child(j).data(0, Qt.ItemDataRole.UserRole) + if sym: + symbols.append(sym) + return symbols + + def _apply_quotes(self, quotes: dict): + from datetime import datetime + for i in range(self._tree.topLevelItemCount()): + group = self._tree.topLevelItem(i) + for j in range(group.childCount()): + child = group.child(j) + sym = child.data(0, Qt.ItemDataRole.UserRole) + q = quotes.get(sym, {}) + price = q.get("price", 0) + change = q.get("change", 0) + change_pct = q.get("change_pct", 0) + child.setText(1, fmt_currency(price)) + child.setText(2, f"{'+' if change >= 0 else ''}{fmt_currency(change)}") + child.setText(3, fmt_percent(change_pct, plus=True)) + color = QColor(pnl_color(change)) + for col in (1, 2, 3): + child.setForeground(col, color) + self._status_label.setText(f"Updated {datetime.now().strftime('%H:%M:%S')}") + + def _add_symbol(self): + symbol = self._symbol_input.text().strip().upper() + group = self._group_combo.currentText().strip() or "Default" + if not symbol: + return + from db.database import get_session + from db.models import WatchlistItem + with get_session() as session: + existing = session.query(WatchlistItem).filter_by(symbol=symbol).first() + if existing: + return + session.add(WatchlistItem(symbol=symbol, group_name=group)) + session.commit() + self._symbol_input.clear() + self._load_from_db() + + if self._group_combo.findText(group) == -1: + self._group_combo.addItem(group) + + def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int): + sym = item.data(0, Qt.ItemDataRole.UserRole) + if sym: + self.symbol_selected.emit(sym) + + def _show_context_menu(self, pos): + item = self._tree.itemAt(pos) + if not item or not item.data(0, Qt.ItemDataRole.UserRole): + return + sym = item.data(0, Qt.ItemDataRole.UserRole) + menu = QMenu(self) + view_action = menu.addAction(f"View Chart: {sym}") + remove_action = menu.addAction("Remove") + action = menu.exec(self._tree.mapToGlobal(pos)) + if action == view_action: + self.symbol_selected.emit(sym) + elif action == remove_action: + self._remove_symbol(sym) + + def _remove_symbol(self, symbol: str): + from db.database import get_session + from db.models import WatchlistItem + with get_session() as session: + session.query(WatchlistItem).filter_by(symbol=symbol).delete() + session.commit() + self._load_from_db() + + def add_symbol_external(self, symbol: str, group: str = "Default"): + self._symbol_input.setText(symbol) + idx = self._group_combo.findText(group) + if idx >= 0: + self._group_combo.setCurrentIndex(idx) + self._add_symbol() diff --git a/stockmind/utils/__init__.py b/stockmind/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stockmind/utils/config.py b/stockmind/utils/config.py new file mode 100644 index 0000000..6f8b708 --- /dev/null +++ b/stockmind/utils/config.py @@ -0,0 +1,70 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +_env_loaded = False + + +def _load_env(): + global _env_loaded + if not _env_loaded: + env_path = Path(__file__).parent.parent.parent / ".env" + load_dotenv(env_path) + _env_loaded = True + + +class Config: + def __init__(self): + _load_env() + self._db_session = None + + def get(self, key: str, default: str = "") -> str: + _load_env() + return os.getenv(key, default) + + def get_setting(self, key: str, default: str = "") -> str: + from db.database import get_session + from db.models import Setting + try: + with get_session() as session: + row = session.query(Setting).filter_by(key=key).first() + if row: + return row.value + except Exception: + pass + return default + + def set_setting(self, key: str, value: str) -> None: + from db.database import get_session + from db.models import Setting + with get_session() as session: + row = session.query(Setting).filter_by(key=key).first() + if row: + row.value = value + else: + session.add(Setting(key=key, value=value)) + session.commit() + + @property + def anthropic_key(self) -> str: + return self.get("ANTHROPIC_API_KEY") or self.get_setting("anthropic_api_key") + + @property + def news_api_key(self) -> str: + return self.get("NEWS_API_KEY") or self.get_setting("news_api_key") + + @property + def schwab_app_key(self) -> str: + return self.get("SCHWAB_APP_KEY") or self.get_setting("schwab_app_key") + + @property + def schwab_app_secret(self) -> str: + return self.get("SCHWAB_APP_SECRET") or self.get_setting("schwab_app_secret") + + @property + def robinhood_username(self) -> str: + return self.get("ROBINHOOD_USERNAME") or self.get_setting("robinhood_username") + + @property + def robinhood_password(self) -> str: + return self.get("ROBINHOOD_PASSWORD") or self.get_setting("robinhood_password") diff --git a/stockmind/utils/formatters.py b/stockmind/utils/formatters.py new file mode 100644 index 0000000..5b80838 --- /dev/null +++ b/stockmind/utils/formatters.py @@ -0,0 +1,40 @@ +def fmt_currency(value: float, decimals: int = 2) -> str: + if value is None: + return "—" + sign = "-" if value < 0 else "" + return f"{sign}${abs(value):,.{decimals}f}" + + +def fmt_percent(value: float, decimals: int = 2, plus: bool = True) -> str: + if value is None: + return "—" + sign = "+" if value > 0 and plus else "" + return f"{sign}{value:.{decimals}f}%" + + +def fmt_large_number(value: float) -> str: + if value is None: + return "—" + abs_val = abs(value) + sign = "-" if value < 0 else "" + if abs_val >= 1_000_000_000_000: + return f"{sign}{abs_val / 1_000_000_000_000:.2f}T" + if abs_val >= 1_000_000_000: + return f"{sign}{abs_val / 1_000_000_000:.2f}B" + if abs_val >= 1_000_000: + return f"{sign}{abs_val / 1_000_000:.2f}M" + if abs_val >= 1_000: + return f"{sign}{abs_val / 1_000:.1f}K" + return f"{sign}{abs_val:.2f}" + + +def fmt_volume(value: int) -> str: + return fmt_large_number(float(value)) if value else "—" + + +def pnl_color(value: float) -> str: + if value > 0: + return "#a6e3a1" + if value < 0: + return "#f38ba8" + return "#cdd6f4" diff --git a/stockmind/utils/notifications.py b/stockmind/utils/notifications.py new file mode 100644 index 0000000..c46faa0 --- /dev/null +++ b/stockmind/utils/notifications.py @@ -0,0 +1,30 @@ +import threading + + +def send_toast(title: str, message: str, duration: int = 5) -> None: + def _send(): + try: + from win10toast import ToastNotifier + toaster = ToastNotifier() + toaster.show_toast(title, message, duration=duration, threaded=True) + except Exception: + try: + import subprocess + ps_script = ( + f"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, " + f"ContentType = WindowsRuntime] | Out-Null; " + f"$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(" + f"[Windows.UI.Notifications.ToastTemplateType]::ToastText02); " + f"$template.SelectSingleNode('//text[@id=1]').InnerText = '{title}'; " + f"$template.SelectSingleNode('//text[@id=2]').InnerText = '{message}'; " + f"$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('StockMind'); " + f"$notifier.Show([Windows.UI.Notifications.ToastNotification]::new($template))" + ) + subprocess.Popen( + ["powershell", "-WindowStyle", "Hidden", "-Command", ps_script], + creationflags=0x08000000, + ) + except Exception: + pass + + threading.Thread(target=_send, daemon=True).start()