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
+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()