""" Snapshot Tab - Portfolio Overview. Displays current holdings with P&L, weights, and current prices. Includes Update Prices and Add Holding functionality. """ import logging from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTableWidget, QTableWidgetItem, QLabel, QHeaderView, QMessageBox, QProgressDialog, QMenu ) from PyQt6.QtCore import Qt, pyqtSlot from PyQt6.QtGui import QColor, QBrush from src.data.price_fetcher import PriceFetcherThread from src.utils.formatters import format_currency, format_percentage, color_for_pnl from src.gui.dialogs import AddHoldingDialog logger = logging.getLogger(__name__) class SnapshotTab(QWidget): """Portfolio snapshot tab with holdings table.""" def __init__(self, parent): """ Initialize snapshot tab. Args: parent: MainWindow instance """ super().__init__() self.parent = parent self.price_thread = None self.init_ui() self.load_data() def init_ui(self): """Initialize user interface.""" layout = QVBoxLayout(self) # Summary section summary_layout = QHBoxLayout() self.lbl_total_value = QLabel("Total Value: €0.00") self.lbl_total_value.setStyleSheet("font-size: 16px; font-weight: bold;") self.lbl_pnl = QLabel("P&L: €0.00 (0.0%)") self.lbl_pnl.setStyleSheet("font-size: 16px; font-weight: bold;") self.lbl_invested = QLabel("Invested: €0.00") self.lbl_invested.setStyleSheet("font-size: 14px;") self.lbl_cash = QLabel("Cash: €0.00 (0.0%)") self.lbl_cash.setStyleSheet("font-size: 14px;") summary_layout.addWidget(self.lbl_total_value) summary_layout.addWidget(self.lbl_pnl) summary_layout.addStretch() summary_layout.addWidget(self.lbl_invested) summary_layout.addWidget(self.lbl_cash) layout.addLayout(summary_layout) # Buttons section btn_layout = QHBoxLayout() self.btn_update_prices = QPushButton("🔄 Update Prices") self.btn_update_prices.clicked.connect(self.update_prices) self.btn_add_holding = QPushButton("+ Add Holding") self.btn_add_holding.clicked.connect(self.add_holding) btn_layout.addWidget(self.btn_update_prices) btn_layout.addWidget(self.btn_add_holding) btn_layout.addStretch() layout.addLayout(btn_layout) # Holdings table self.table = QTableWidget() self.table.setColumnCount(9) self.table.setHorizontalHeaderLabels([ "Ticker", "Type", "Qty", "Avg Price €", "Current Price €", "Current Value €", "P&L €", "P&L %", "Weight %" ]) # Configure table self.table.setAlternatingRowColors(True) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) self.table.setSortingEnabled(True) # Set column widths header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # Ticker header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Type header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # Qty header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # Avg Price header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) # Current Price header.setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch) # Current Value header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) # P&L € header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) # P&L % header.setSectionResizeMode(8, QHeaderView.ResizeMode.ResizeToContents) # Weight % # Context menu self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self.show_context_menu) layout.addWidget(self.table) def load_data(self): """Load and display portfolio data.""" logger.debug("Loading snapshot data...") try: # Reload portfolio from database self.parent.portfolio.load_holdings() # Update summary labels self.update_summary() # Update table self.update_table() logger.info("Snapshot data loaded successfully") except Exception as e: logger.error(f"Error loading snapshot data: {e}", exc_info=True) QMessageBox.critical( self, "Error", f"Failed to load snapshot data:\n{str(e)}" ) def update_summary(self): """Update summary labels.""" summary = self.parent.portfolio.get_portfolio_summary() # Total value total_value = summary['total_value'] self.lbl_total_value.setText(f"Total Value: {format_currency(total_value)}") # P&L pnl_amount = summary['pnl_amount'] pnl_percent = summary['pnl_percent'] pnl_text = f"P&L: {format_currency(pnl_amount)} ({format_percentage(pnl_percent)})" pnl_color = color_for_pnl(pnl_amount) self.lbl_pnl.setText(pnl_text) self.lbl_pnl.setStyleSheet(f"font-size: 16px; font-weight: bold; color: {pnl_color};") # Invested invested = summary['total_invested'] self.lbl_invested.setText(f"Invested: {format_currency(invested)}") # Cash cash = summary['cash'] cash_pct = (cash / total_value * 100) if total_value > 0 else 0.0 self.lbl_cash.setText(f"Cash: {format_currency(cash)} ({cash_pct:.1f}%)") def update_table(self): """Update holdings table.""" holdings = self.parent.portfolio.get_holdings_summary() self.table.setSortingEnabled(False) # Disable while updating self.table.setRowCount(len(holdings)) for row, holding in enumerate(holdings): # Ticker self.table.setItem(row, 0, QTableWidgetItem(holding['ticker'])) # Type self.table.setItem(row, 1, QTableWidgetItem(holding['asset_type'])) # Quantity qty_item = QTableWidgetItem(f"{holding['quantity']:.0f}") qty_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.table.setItem(row, 2, qty_item) # Avg Price avg_price_item = QTableWidgetItem(f"{holding['avg_price']:.2f}") avg_price_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.table.setItem(row, 3, avg_price_item) # Current Price current_price = holding['current_price'] if current_price: curr_price_item = QTableWidgetItem(f"{current_price:.2f}") else: curr_price_item = QTableWidgetItem("-") curr_price_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.table.setItem(row, 4, curr_price_item) # Current Value value_item = QTableWidgetItem(format_currency(holding['current_value'])) value_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.table.setItem(row, 5, value_item) # P&L € pnl_amount = holding['pnl_amount'] pnl_amount_item = QTableWidgetItem(format_currency(pnl_amount)) pnl_amount_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) pnl_amount_item.setForeground(QBrush(QColor(color_for_pnl(pnl_amount)))) self.table.setItem(row, 6, pnl_amount_item) # P&L % pnl_percent = holding['pnl_percent'] pnl_percent_item = QTableWidgetItem(format_percentage(pnl_percent)) pnl_percent_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) pnl_percent_item.setForeground(QBrush(QColor(color_for_pnl(pnl_percent)))) self.table.setItem(row, 7, pnl_percent_item) # Weight % weight_item = QTableWidgetItem(f"{holding['weight_pct']:.1f}%") weight_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self.table.setItem(row, 8, weight_item) self.table.setSortingEnabled(True) # Re-enable sorting def update_prices(self, auto_refresh=False): """ Update prices from yfinance. Args: auto_refresh: True if triggered by auto-refresh timer """ tickers = self.parent.db_manager.get_all_tickers() if not tickers: if not auto_refresh: QMessageBox.information(self, "No Tickers", "No holdings to update prices for.") return logger.info(f"Updating prices for {len(tickers)} tickers...") # Disable button during update self.btn_update_prices.setEnabled(False) # Create progress dialog self.progress_dialog = QProgressDialog( "Updating prices...", "Cancel", 0, len(tickers), self ) self.progress_dialog.setWindowModality(Qt.WindowModality.WindowModal) self.progress_dialog.setMinimumDuration(0) self.progress_dialog.canceled.connect(self.cancel_price_update) # Create and start price fetcher thread self.price_thread = PriceFetcherThread( self.parent.price_fetcher, tickers, force_refresh=not auto_refresh # Force refresh if manual, use cache if auto ) self.price_thread.progress.connect(self.on_price_progress) self.price_thread.finished.connect(self.on_prices_updated) self.price_thread.error.connect(self.on_price_error) self.price_thread.start() def cancel_price_update(self): """Cancel price update operation.""" if self.price_thread: self.price_thread.stop() logger.info("Price update cancelled by user") @pyqtSlot(int, int) def on_price_progress(self, current, total): """ Update progress dialog. Args: current: Current ticker index total: Total tickers """ if hasattr(self, 'progress_dialog'): self.progress_dialog.setValue(current) self.progress_dialog.setLabelText(f"Updating prices... {current}/{total}") @pyqtSlot(dict) def on_prices_updated(self, prices): """ Handle price update completion. Args: prices: Dictionary of ticker -> price """ logger.info(f"Price update completed: {len(prices)} prices fetched") # Close progress dialog if hasattr(self, 'progress_dialog'): self.progress_dialog.close() # Update portfolio prices self.parent.portfolio.update_prices(prices) # Refresh display self.update_summary() self.update_table() # Update status bar self.parent.status_bar.showMessage( f"Prices updated: {len(prices)} tickers", 5000 ) # Re-enable button self.btn_update_prices.setEnabled(True) @pyqtSlot(str) def on_price_error(self, error_msg): """ Handle price fetch error. Args: error_msg: Error message """ logger.error(f"Price fetch error: {error_msg}") # Close progress dialog if hasattr(self, 'progress_dialog'): self.progress_dialog.close() # Show error message QMessageBox.critical(self, "Price Update Error", error_msg) # Re-enable button self.btn_update_prices.setEnabled(True) def add_holding(self): """Open Add Holding dialog.""" dialog = AddHoldingDialog(self.parent) if dialog.exec(): # Refresh display self.load_data() # Also refresh analytics tab self.parent.analytics_tab.load_data() # Update status bar self.parent.status_bar.showMessage("Holding added successfully", 3000) def show_context_menu(self, pos): """ Show context menu for table. Args: pos: Click position """ # Get selected row selected_rows = self.table.selectionModel().selectedRows() if not selected_rows: return row = selected_rows[0].row() ticker = self.table.item(row, 0).text() # Create context menu menu = QMenu(self) delete_action = menu.addAction("Delete Holding") delete_action.triggered.connect(lambda: self.delete_holding(ticker)) menu.exec(self.table.viewport().mapToGlobal(pos)) def delete_holding(self, ticker): """ Delete a holding. Args: ticker: Ticker to delete """ # Confirm deletion reply = QMessageBox.question( self, "Confirm Delete", f"Delete holding {ticker}?\n\nThis will remove the holding but keep transaction history.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: self.parent.db_manager.delete_holding(ticker) self.load_data() self.parent.status_bar.showMessage(f"Holding {ticker} deleted", 3000) logger.info(f"Holding deleted: {ticker}") except Exception as e: logger.error(f"Error deleting holding: {e}", exc_info=True) QMessageBox.critical( self, "Error", f"Failed to delete holding:\n{str(e)}" )