Phase 1: initial codes

This commit is contained in:
2026-05-26 11:04:27 -04:00
parent 03bff49523
commit f49a283059
32 changed files with 4194 additions and 0 deletions
View File
+50
View File
@@ -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)
+94
View File
@@ -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."""
+93
View File
@@ -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
]
+364
View File
@@ -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;
}
+332
View File
@@ -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; }
View File
+60
View File
@@ -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]:
...
+98
View File
@@ -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 []
+105
View File
@@ -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 []
View File
+63
View File
@@ -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
+177
View File
@@ -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
+85
View File
@@ -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()
View File
+39
View File
@@ -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()
+74
View File
@@ -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)
+42
View File
@@ -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()
View File
+437
View File
@@ -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()
+355
View File
@@ -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()
+135
View File
@@ -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()
+236
View File
@@ -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)
+227
View File
@@ -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)
+313
View File
@@ -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)
+262
View File
@@ -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)
+216
View File
@@ -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))
+197
View File
@@ -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()
View File
+70
View File
@@ -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")
+40
View File
@@ -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"
+30
View File
@@ -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()