314 lines
12 KiB
Python
314 lines
12 KiB
Python
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)
|