DEV Community

Cover image for Building a Crypto Portfolio Tracker desktop app with PyQt6
Nik
Nik

Posted on • Edited on

Building a Crypto Portfolio Tracker desktop app with PyQt6

I’ve been experimenting with the PyQt6 library by building a small pet project: a lightweight Crypto Portfolio Tracker. The idea was to move one of my command-line scripts into a proper desktop app — load a CSV with my holdings, fetch live prices from Kraken, and show everything in a table.

It turned out to be a fun way to explore PyQt6’s layouts, threading, and event handling.

Project Overview

The app provides three main actions:

  1. Load Portfolio – import a CSV file of holdings.
  2. Refresh Prices – fetch current prices from the Kraken API.
  3. Export CSV – save the updated data back to disk.

Along the way, I added multithreading (so the UI stays responsive), a logging system, and a config file for mapping portfolio symbols to Kraken’s trading pairs.

The Core UI

The main window is made up of:

  • A QTableWidget for portfolio data

  • Buttons for Load, Refresh, and Export.

def setUI(self):
    self.setWindowTitle("CryptoTracker")

    self.portfolio = pd.DataFrame(columns=["symbol", "amount", "price", "total_value"])

    self.table = QTableWidget()
    self.btn_load = QPushButton("Load Portfolio CSV")
    self.btn_load.clicked.connect(self.load_portfolio)

    self.btn_refresh = QPushButton("Refresh Kraken Prices")
    self.btn_refresh.clicked.connect(self.refresh_prices)

    self.btn_export = QPushButton("Export to CSV")
    self.btn_export.clicked.connect(self.export_csv)

    layout = QVBoxLayout()
    layout.addWidget(self.table)
    layout.addWidget(self.btn_load)
    layout.addWidget(self.btn_refresh)
    layout.addWidget(self.btn_export)

    container = QWidget()
    container.setLayout(layout)
    self.setCentralWidget(container)
Enter fullscreen mode Exit fullscreen mode

Loading the Portfolio

A simple CSV file drives the app. Clicking Load Portfolio opens a file dialog, loads the data into a pandas.DataFrame, and displays it in the table.

def load_portfolio(self):
    file_name, _ = QFileDialog.getOpenFileName(self, "Open CSV", "", "CSV Files (*.csv)")
    if file_name:
        self.portfolio = pd.read_csv(file_name)
        self.logger.info(f"Loaded file {file_name}")
        self.update_table()
Enter fullscreen mode Exit fullscreen mode

Example portfolio.csv:

symbol,amount
BTC,0.05
ETH,0.7
DOGE,1000
Enter fullscreen mode Exit fullscreen mode

Fetching Prices (with Multithreading)

One of the most important lessons in GUI programming: never block the UI thread.
Since calling the Kraken API can take time, I used QThreadPool with a custom Worker class and WorkerSignals class - that's how I pass data from the custom thread to the main one, and update the UI from it while avoiding a Segmentation fault error:

class WorkerSignal(QObject):
    output = pyqtSignal(object)
    error = pyqtSignal(str)

    def __init__(self):
        super().__init__()

class Worker(QRunnable):
    def __init__(self, fn, *args, **kwargs):
        super(Worker, self).__init__()
        self.fn = fn
        self.args = args
        self.kwargs = kwargs
        self.signals = WorkerSignal()

    @pyqtSlot()
    def run(self):
        try:
            result = self.fn(*self.args, **self.kwargs) # Initialising the runner function with passed args, kwargs
            if result:
                self.signals.output.emit((result, *self.args)) # Sends normal result as object (result, kraken_pair, sym)
        except Exception as e:
            self.signals.error.emit(str(e)) # Sends exception to GUI

Enter fullscreen mode Exit fullscreen mode

When the user clicks Refresh Prices, each symbol is processed in a separate worker thread:

    def refresh_prices(self):
        self.logger.debug(f"Refreshing prices")

        if self.portfolio.empty:
            self.logger.error("No data found in portfolio")
            return

        for sym in self.portfolio["symbol"]:
            kraken_pair = self.config['KrakenSymbols'][sym.upper()]
            worker = Worker(self.get_info, kraken_pair, sym)
            worker.signals.output.connect(self.parse_resp)
            worker.signals.error.connect(lambda err: self.logger.error(f"Worker error: {err}")) # quick inline handler for error in worker
            self.threadPool.start(worker)
Enter fullscreen mode Exit fullscreen mode

Example config.ini with Kraken symbol mappings:

[KrakenSymbols]
BTC=XXBTZUSD
ETH=XETHZUSD
DOGE=XDGUSD
USDT=USDTZUSD
SOL=SOLUSD

Enter fullscreen mode Exit fullscreen mode

Updating the Table

After fetching prices, the app updates both the DataFrame and the visible table:

    def update_table(self):
        self.logger.debug(f"Update table {self.table}")

        # Setting up the table based on portfolio file
        self.table.setRowCount(len(self.portfolio))
        self.table.setColumnCount(len(self.portfolio.columns))
        self.table.setHorizontalHeaderLabels(self.portfolio.columns)

        # Updating table rows (used for initial setup and while updating)
        for i, row in self.portfolio.iterrows():
            for j, col in enumerate(self.portfolio.columns):
                self.table.setItem(i, j, QTableWidgetItem(str(row[col])))

Enter fullscreen mode Exit fullscreen mode

Each row then shows:

  • symbol (e.g., BTC)
  • amount (from CSV)
  • price (from Kraken)
  • total_value (amount × price)

Logging

To keep track of what’s happening, I added a simple logger that writes both to app.log and the console. This was especially helpful when debugging API responses and PyInstaller packaging.

Lessons Learned

  • Threading is crucial: without it, the UI froze while waiting for Kraken responses.
  • Thread-safe approach with PyQt means you need to use signals to get data out of a custom worker thread and pass it to the main thread, then update UI from the main thread.
  • PyQt6 layouts are flexible, but nested layouts take some trial and error.
  • PyInstaller works, but you need to handle bundled resources like config.ini carefully (sys._MEIPASS trick).
  • Combining pandas with a QTableWidget makes it easy to keep the GUI and data in sync.
  • To run the app anywhere as a docker container you can build a docker image from an image with Ubuntu + VNC (for example, accetto/ubuntu-vnc-xfce-g3) and put the app inside it.

Demo and the source code

Here you can find how it looks like: https://youtu.be/YUqXW-J0R_c
The source code could be found here: https://github.com/kolyaiks/crypto_tracker

Top comments (0)