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