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)