StockTray: A Simple Stock Price Tracker for Your System Tray

This is a python seed project — Need quick access to real-time stock prices? Many rely on complex applications or browser-based tools, but sometimes a lightweight solution is all that is needed.

StockTray is a compact application that sits in the system tray and provides instant stock price updates for selected companies.

This application is built with Python and PyQt5 and fetches stock prices using the Alpha Vantage API. The design focuses on showing programmers how they can build their own tray applications.

Fetching Stock Data with Alpha Vantage

The Alpha Vantage API provides free and premium financial data, including stock prices, forex, and cryptocurrency information. It is widely used for personal finance applications, trading bots, and research tools.

StockTray retrieves stock prices using the API’s GLOBAL_QUOTE function. Here’s the request format:

url = f"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={API_KEY}"
response = requests.get(url)
data = response.json()

Each request queries a single stock symbol and returns key details such as open price, high price, and current price.

However, the free API plan has strict limits. Only 25 requests per day are allowed. Exceeding this limit results in a response like this:

{
    "Information": "Thank you for using Alpha Vantage! Our standard API rate limit is 25 requests per day."
}

To handle this, StockTray implements a cooldown timer that prevents excessive API calls.

Managing API Requests with a Cooldown Timer

A key challenge with free APIs is ensuring that requests stay within the rate limit. StockTray enforces a 30-minute cooldown between API calls to avoid reaching the 25-request-per-day restriction.

The cooldown is handled with a timestamp system:

API_COOLDOWN = 1800  # 30 minutes in seconds
self.last_api_time = 0  # Track last request time

Each time the user right-clicks to open the menu, StockTray checks if 30 minutes have passed since the last API request:

current_time = time.time()
time_since_last_query = current_time - self.last_api_time

if time_since_last_query >= API_COOLDOWN:
    self.stock_data = self.get_stock_data()
    self.last_api_time = current_time
else:
    wait_time = API_COOLDOWN - int(time_since_last_query)
    self.stock_data = [
        {"SYM": "WAIT", "OPEN": f"Wait {wait_time}s", "HIGH": "", "PRICE": ""}
    ]

If the cooldown has not expired, the menu displays a countdown message instead of stock prices:

SYM     OPEN        HIGH      PRICE
WAIT    Wait 1253s  

This ensures that the application remains within the API’s usage limits, while still giving users a clear indication of when they can fetch new data.

Presenting Stock Data in a Monospaced Font

Displaying stock data in a system tray menu introduces a formatting challenge. Different stock symbols and numbers vary in width depending on the font, causing misalignment in the table. To keep everything structured, a monospaced font is required.

StockTray sets the font to Courier New, ensuring that each character occupies the same amount of space:

self.monospace_font = QFont("Courier New")
self.monospace_font.setPointSize(10)

Without a monospaced font, stock values shift:

SYM    OPEN   HIGH   PRICE
AAPL   174.2  176.5  175.9
TSLA    654.12 672.5  668.3
WMT   145.6   150.1  149.5

With Courier New, all columns remain aligned:

SYM     OPEN      HIGH      PRICE
AAPL    174.20    176.50    175.90
TSLA    654.12    672.50    668.30
WMT     145.60    150.10    149.50

Preventing Data Overwrites in PyQt5 Menus

Another important design consideration is how PyQt5 manages menu items. Without careful handling, the menu overwrites old data instead of updating it correctly.

StockTray prevents this issue by:

  • Clearing the menu before each update:
self.menu.clear()

Storing actions in a list to maintain references:

for stock in self.stock_data:
    stock_text = f"{stock['SYM']:<6} {stock['OPEN']:<10} {stock['HIGH']:<8} {stock['PRICE']:<8}"
    action = QAction(stock_text)
    action.setEnabled(False)  # Prevents interaction
    action.setFont(self.monospace_font)  # Ensures alignment
    self.stock_actions.append(action)
    self.menu.addAction(action)

By following this approach, StockTray displays stock data correctly every time without menu glitches.

A System Tray App with Smart Design

StockTray is a compact yet powerful stock tracker that prioritizes efficiency, API compliance, and clear data formatting. Key features include:

  • Stock price retrieval from Alpha Vantage
  • 30-minute cooldown to respect API limits
  • Monospaced font for clean, readable alignment
  • System tray integration with a right-click menu
  • Automatic countdown when API access is restricted

The application is useful for investors who want a quick snapshot of stock prices without opening a browser or full desktop app.

For those who need higher API limits, Alpha Vantage offers premium plans that remove daily restrictions. If upgrading is not an option, StockTray’s built-in cooldown system ensures it stays within the free-tier limits.

Potential Improvements

StockTray is designed for simplicity, but there are ways to enhance it further:

  • Support for additional stocks beyond three symbols
  • Configurable API cooldown duration
  • Integration with other financial APIs (Yahoo Finance, IEX Cloud)
  • Custom alerts for price changes
  • Swap out Stock Alerts with some other form of messaging

These features could make the app more versatile while maintaining its lightweight design.

Full Code:

Get your Alpha Vantage Test API Key: Customer Support | Alpha Vantage

pip install PyQt5 requests
import sys
import time
import requests
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QFont, QCursor
from PyQt5.QtCore import QPoint

# Alpha Vantage API Key
API_KEY = "VISIT https://www.alphavantage.co/support/#api-key"
SYMBOLS = ["AAPL", "TSLA", "WMT"]  # Define multiple stock symbols
API_COOLDOWN = 1800  # 30 minute cooldown between API requests (30 min x 60 seconds)

class StockTrayApp:
    def __init__(self):
        self.app = QApplication(sys.argv)

        # Store last API request time
        self.last_api_time = 0

        # Create the system tray icon
        self.tray_icon = QSystemTrayIcon(QIcon("icon.ico"), self.app)
        self.tray_icon.setToolTip("Right-click for stock prices")

        # Create the right-click menu
        self.menu = QMenu()

        # Define a monospaced font (forces perfect alignment of values)
        self.monospace_font = QFont("Courier New")
        self.monospace_font.setPointSize(10)

        # Fetch stock data dynamically
        self.stock_data = self.get_stock_data()

        # Attach click handler to manually show menu at a controlled position
        self.tray_icon.activated.connect(self.show_menu)   
        self.tray_icon.show()

    def show_menu(self, reason):
        """Manually show the right-click menu at a specific adjusted position."""
        if reason == QSystemTrayIcon.Context:  # Right-click detected
            self.menu.clear()  # Refresh menu on each right-click

            current_time = time.time()
            time_since_last_query = current_time - self.last_api_time

            if time_since_last_query >= API_COOLDOWN:
                # Enough time has passed, update stock data
                self.stock_data = self.get_stock_data()
                self.last_api_time = current_time
            else:
                # If right-click happens too soon, display wait message
                wait_time = API_COOLDOWN - int(time_since_last_query)
                self.stock_data = [
                    {"SYM": "WAIT", "OPEN": f"Wait {wait_time}s", "HIGH": "", "PRICE": ""}
                ]

            # Add a stock data header (static, non-clickable)
            header_text = "SYM     OPEN        HIGH      PRICE"
            header_action = QAction(header_text)
            header_action.setEnabled(False)
            header_action.setFont(self.monospace_font)
            self.menu.addAction(header_action)

            # Store actions in a list to prevent overwriting!
            self.stock_actions = []

            for stock in self.stock_data:
                stock_text = f"{stock['SYM']:<6} {stock['OPEN']:<10} {stock['HIGH']:<8} {stock['PRICE']:<8}"
                action = QAction(stock_text)
                action.setEnabled(False)  # Make it non-clickable
                action.setFont(self.monospace_font)  # Apply monospaced font
                self.stock_actions.append(action)
                self.menu.addAction(action)

            # Add an exit option at the end
            exit_action = QAction("Exit", triggered=self.exit_app)
            self.menu.addAction(exit_action)

            # Get the current mouse position
            cursor_pos = QCursor.pos()

            # Move the menu left and up from click position
            adjusted_pos = QPoint(cursor_pos.x() - 344, cursor_pos.y() - 110)           
            self.menu.exec_(adjusted_pos)

    def get_stock_data(self):
        """Fetch stock data from Alpha Vantage for multiple symbols and round values."""
        stock_data_list = []

        for symbol in SYMBOLS:
            try:
                url = f"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={API_KEY}"
                response = requests.get(url)
                data = response.json()

                # print(f"API Response for {symbol}:") # Print full API response for debugging
                # print(data)  # This will print the raw response in the terminal

                # Extract stock data properly
                quote = data.get("Global Quote", {})

                if quote:
                    stock_data_list.append({
                        "SYM": quote.get("01. symbol", symbol),
                        "OPEN": self.round_value(quote.get("02. open", "N/A")),
                        "HIGH": self.round_value(quote.get("03. high", "N/A")),
                        "PRICE": self.round_value(quote.get("05. price", "N/A")),
                    })
                else:
                    print(f"No data found for {symbol}, API might be rate-limited!")
                    stock_data_list.append({
                        "SYM": symbol,
                        "OPEN": "N/A",
                        "HIGH": "N/A",
                        "PRICE": "N/A",
                    })

            except Exception as e:
                print(f"Error fetching data for {symbol}: {e}")
                stock_data_list.append({
                    "SYM": symbol,
                    "OPEN": "N/A",
                    "HIGH": "N/A",
                    "PRICE": "N/A",
                })

        return stock_data_list

    def round_value(self, value):
        """Rounds stock values to the nearest 100th (2 decimal places), handling missing data."""
        try:
            return str(round(float(value), 2))  # Rounds to 2 decimal places
        except (ValueError, TypeError):
            return "N/A"  # Handles missing values

    def exit_app(self):
        """Exit the application properly."""
        self.tray_icon.hide()
        sys.exit(0)

    def run(self):
        """Run the application."""
        sys.exit(self.app.exec_())

if __name__ == "__main__":
    app = StockTrayApp()
    app.run()

PyQt5’s QMenu has a text length limit for menu items in the system tray. The exact limit depends on the operating system, but generally:

  • Windows: Around 128 characters per menu item
  • Linux/macOS: Varies but may be slightly higher or lower

How to Handle Long Text in the Right-Click Menu

If a stock data row exceeds the system limit, it may get truncated or fail to display correctly. Here’s how to work around it:

Split Long Text into Multiple Menu Items

Instead of:

stock_text = f"{symbol}  Open: {open_price}  High: {high_price}  Price: {current_price}"
action = QAction(stock_text)
menu.addAction(action)

Use multiple actions per stock:

menu.addAction(QAction(f"{symbol}"))
menu.addAction(QAction(f"Open: {open_price}"))
menu.addAction(QAction(f"High: {high_price}"))
menu.addAction(QAction(f"Price: {current_price}"))

Use Submenus for Each Stock

To avoid excessive main menu items:

stock_menu = QMenu(symbol, self.menu)
stock_menu.addAction(QAction(f"Open: {open_price}"))
stock_menu.addAction(QAction(f"High: {high_price}"))
stock_menu.addAction(QAction(f"Price: {current_price}"))
self.menu.addMenu(stock_menu)

Now each stock symbol expands into a submenu when clicked.

Use a StyleSheet as shown below if you want a black background and white text.

Code with Dummy Data and StyleSheet:

import sys
import time
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QFont, QCursor
from PyQt5.QtCore import QPoint

# Define multiple stock symbols
SYMBOLS = ["AAPL", "TSLA", "WMT"]
API_COOLDOWN = 1800  # 30 minute cooldown between updates

class StockTrayApp:
    def __init__(self):
        self.app = QApplication(sys.argv)

        # Apply dark theme using stylesheets
        self.app.setStyleSheet("""
            QMenu {
                background-color: black;
                color: white;
                border: 1px solid white;
            }
            QAction {
                color: white;
            }
            QAction:disabled {
                color: white;
            }
        """)

        # Store last data update time
        self.last_api_time = 0

        # Create the system tray icon
        self.tray_icon = QSystemTrayIcon(QIcon("icon.ico"), self.app)
        self.tray_icon.setToolTip("Right-click for stock prices")

        # Create the right-click menu
        self.menu = QMenu()

        # Define a monospaced font (forces perfect alignment)
        self.monospace_font = QFont("Courier New")
        self.monospace_font.setPointSize(10)

        # Load test stock data instead of making an API call
        self.stock_data = self.get_stock_data()

        # Attach click handler to manually show menu at a controlled position
        self.tray_icon.activated.connect(self.show_menu)

        # Show tray icon
        self.tray_icon.show()

    def show_menu(self, reason):
        """Manually show the right-click menu at a specific adjusted position."""
        if reason == QSystemTrayIcon.Context:  # Right-click detected
            self.menu.clear()  # Refresh menu on each right-click

            current_time = time.time()
            time_since_last_query = current_time - self.last_api_time

            if time_since_last_query >= API_COOLDOWN:
                # Enough time has passed, update stock data
                self.stock_data = self.get_stock_data()
                self.last_api_time = current_time
            else:
                # If right-click happens too soon, display wait message
                wait_time = API_COOLDOWN - int(time_since_last_query)
                self.stock_data = [
                    {"SYM": "WAIT", "OPEN": f"Wait {wait_time}s", "HIGH": "", "PRICE": ""}
                ]

            # Add a stock data header (static, non-clickable)
            header_text = "SYM     OPEN        HIGH      PRICE"
            header_action = QAction(header_text)
            header_action.setEnabled(False)
            header_action.setFont(self.monospace_font)
            self.menu.addAction(header_action)

            # Store actions in a list to prevent overwriting!
            self.stock_actions = []

            for stock in self.stock_data:
                stock_text = f"{stock['SYM']:<6} {stock['OPEN']:<10} {stock['HIGH']:<8} {stock['PRICE']:<8}"
                action = QAction(stock_text)
                action.setEnabled(False)  # Make it non-clickable
                action.setFont(self.monospace_font)  # Apply monospaced font
                self.stock_actions.append(action)
                self.menu.addAction(action)

            # Add an exit option at the end (Fixed)
            exit_action = QAction("Exit", triggered=self.exit_app)
            exit_action.setFont(self.monospace_font)
            self.menu.addAction(exit_action)

            # Get the current mouse position
            cursor_pos = QCursor.pos()

            # Move the menu left and up from click position
            adjusted_pos = QPoint(cursor_pos.x() - 344, cursor_pos.y() - 110)           
            self.menu.exec_(adjusted_pos)

    def get_stock_data(self):
        """Returns test stock data instead of making an API call."""
        return [
            {"SYM": "AAPL", "OPEN": "227.25", "HIGH": "233.13", "PRICE": "232.80"},
            {"SYM": "TSLA", "OPEN": "382.63", "HIGH": "394.00", "PRICE": "383.68"},
            {"SYM": "WMT", "OPEN": "99.97", "HIGH": "100.95", "PRICE": "100.77"},
        ]

    def exit_app(self):
        """Properly close the menu and exit the application."""
        self.menu.close()  # Close the menu
        self.tray_icon.hide()  # Hide the system tray icon
        sys.exit(0)  # Exit the application

    def run(self):
        """Run the application."""
        sys.exit(self.app.exec_())

# Run the application
if __name__ == "__main__":
    app = StockTrayApp()
    app.run()

Thank you for reading this article. I hope you found it helpful and informative. If you have any questions, or if you would like to suggest new Python code examples or topics for future tutorials, please feel free to reach out. Your feedback and suggestions are always welcome!

Happy coding!
Py-Core.com Python Programming

You can also find this article at Medium.com

Leave a Reply