From b37ae7e78d94b121417b2b3afd2f99a72f089cb2 Mon Sep 17 00:00:00 2001 From: Martin Fitzpatrick Date: Sat, 17 Feb 2018 12:18:42 +0100 Subject: [PATCH] currency: Add currency viewer application with fixer.io/pyqtgraph. --- .gitignore | 4 + README.md | 4 +- currency/currency.py | 346 ++++++++++++++++++++++++++++++++++++++++ translate/MainWindow.py | 2 +- translate/mainwindow.ui | 2 +- 5 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 currency/currency.py diff --git a/.gitignore b/.gitignore index 34613a1..e32205a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,7 @@ target/ Thumbs.db build/* dist + +# Database +*.db +*.sqlite diff --git a/README.md b/README.md index 6b8c84c..f739e58 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,12 @@ found [on my site](http://martinfitzpatrick.name/tag/pyqt). 1. [Post-it Notes](notes/) - "Brown Note" (QtDesigner) 1. [Paint](paint/) - "Piecasso" (QtDesigner) 1. [Unzip](unzip/) - "7Pez" (QtDesigner) -1. [Translator](translate/) - "Translataar" (QtDesigner) +1. [Translator](translate/) - "Translataarrr" (QtDesigner) 1. [Weather](weather/) - "Raindar" (QtDesigner) ## In progress -1. Currency converter (QtDesigner, PyQtGraph) +1. Currency converter - "Doughnut" (PyQtGraph) 1. Solitaire - "Ronery" # License diff --git a/currency/currency.py b/currency/currency.py new file mode 100644 index 0000000..cd301e5 --- /dev/null +++ b/currency/currency.py @@ -0,0 +1,346 @@ +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * + +import pyqtgraph as pg +import requests +import requests_cache + + +from collections import defaultdict +from datetime import datetime, timedelta, date +from itertools import cycle +import sys +import time +import traceback + +requests_cache.install_cache('fixerio_cache') + +# Base currency is used to retrieve rates from fixer.io. +# If we change currency we re-request, though it would +# be possible to calculate any rates *through* the base. +DEFAULT_BASE_CURRENCY = 'EUR' +DEFAULT_DISPLAY_CURRENCIES = ['CAD','CYP','AUD','USD', 'EUR', 'GBP', 'NZD', 'SGD'] +HISTORIC_DAYS_N = 180 + +# Colour sets. +BREWER12PAIRED = cycle(['#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99', '#e31a1c', '#fdbf6f', '#ff7f00', + '#cab2d6', '#6a3d9a', '#ffff99', '#b15928' ]) + +# Base PyQtGraph configuration +pg.setConfigOption('background', 'w') +pg.setConfigOption('foreground', 'k') + + +# Build progressive request order, for filling up data +# Uses an depth-first search pattern, filling more recent data +# to a higher resolution more quickly with a +DATE_REQUEST_OFFSETS = [0] +current = [(0, HISTORIC_DAYS_N)] +while current: + a, b = current.pop(0) + n = (a + b) // 2 + DATE_REQUEST_OFFSETS.append(n) + + if abs(a - n) > 1: + current.insert(0, (a, n)) + + if abs(b - n) > 1: + current.append((b, n)) + + + +class WorkerSignals(QObject): + ''' + Defines the signals available from a running worker thread. + ''' + finished = pyqtSignal() + error = pyqtSignal(tuple) + progress = pyqtSignal(int) + data = pyqtSignal(int, dict) + cancel = pyqtSignal() + + +class UpdateWorker(QRunnable): + ''' + Worker thread for unzipping. + ''' + signals = WorkerSignals() + is_interrupted = False + + def __init__(self, base_currency): + super(UpdateWorker, self).__init__() + self.base_currency = base_currency + self.signals.cancel.connect(self.cancel) + + @pyqtSlot() + def run(self): + try: + today = date.today() + total_requests = len(DATE_REQUEST_OFFSETS) + + for n, offset in enumerate(DATE_REQUEST_OFFSETS, 1): + when = today - timedelta(days=offset) + url = 'http://api.fixer.io/{}'.format(when.isoformat()) + r = requests.get(url, params={'base': self.base_currency}) + r.raise_for_status() + data = r.json() + rates = data['rates'] + rates[self.base_currency] = 1.0 + + self.signals.data.emit(offset ,rates) + self.signals.progress.emit(int(100 * n / total_requests)) + + if not r.from_cache: + time.sleep(1) # Don't be rude. + + if self.is_interrupted: + break + + + except Exception as e: + print(e) + exctype, value = sys.exc_info()[:2] + self.signals.error.emit((exctype, value, traceback.format_exc())) + return + + self.signals.finished.emit() + + def cancel(self): + self.is_interrupted = True + + + +class MainWindow(QMainWindow): + + def __init__(self, *args, **kwargs): + super(MainWindow, self).__init__(*args, **kwargs) + + layout = QHBoxLayout() + + self.ax = pg.PlotWidget() + self.ax.showGrid(True, True) + + self.line = pg.InfiniteLine( + pos=-20, + pen=pg.mkPen('k', width=3), + movable=False # We have our own code to handle dragless moving. + ) + + self.ax.addItem(self.line) + self.ax.setLimits(xMin=-HISTORIC_DAYS_N + 1, xMax=0) + self.ax.getPlotItem().scene().sigMouseMoved.connect(self.mouse_move_handler) + + self.base_currency = DEFAULT_BASE_CURRENCY + + # Store a reference to lines on the plot, and items in our + # data viewer we can update rather than redraw. + self._data_lines = dict() + self._data_items = dict() + self._data_colors = dict() + self._data_visible = DEFAULT_DISPLAY_CURRENCIES + + self._last_updated = None + + self.listView = QTableView() + self.model = QStandardItemModel() + self.model.setHorizontalHeaderLabels(["Currency", "Rate"]) + self.model.itemChanged.connect(self.check_check_state) + + self.listView.setModel(self.model) + + self.threadpool = QThreadPool() + self.worker = False + + layout.addWidget(self.ax) + layout.addWidget(self.listView) + + widget = QWidget() + widget.setLayout(layout) + self.setCentralWidget(widget) + self.listView.setFixedSize(226, 400) + self.setFixedSize(650, 400) + + toolbar = QToolBar("Main") + self.addToolBar(toolbar) + self.currencyList = QComboBox() + + toolbar.addWidget(self.currencyList) + self.update_currency_list(DEFAULT_DISPLAY_CURRENCIES) + self.currencyList.setCurrentText(self.base_currency) + self.currencyList.currentTextChanged.connect(self.change_base_currency) + + self.progress = QProgressBar() + self.progress.setRange(0, 100) + toolbar.addWidget(self.progress) + + self.refresh_historic_rates() + self.setWindowTitle("Doughnut") + self.show() + + def update_currency_list(self, currencies): + for currency in currencies: + if self.currencyList.findText(currency) == -1: + self.currencyList.addItem(currency) + + self.currencyList.model().sort(0) + + def check_check_state(self, i): + if not i.isCheckable(): # Skip data columns. + return + + currency = i.text() + checked = i.checkState() == Qt.Checked + + if currency in self._data_visible: + if not checked: + self._data_visible.remove(currency) + self.redraw() + else: + if checked: + self._data_visible.append(currency) + self.redraw() + + def get_currency_color(self, currency): + if currency not in self._data_colors: + self._data_colors[currency] = next(BREWER12PAIRED) + + return self._data_colors[currency] + + def add_data_row(self, currency): + citem = QStandardItem() + citem.setText(currency) + citem.setForeground(QBrush(QColor( + self.get_currency_color(currency) + ))) + citem.setColumnCount(2) + citem.setCheckable(True) + if currency in DEFAULT_DISPLAY_CURRENCIES: + citem.setCheckState(Qt.Checked) + + vitem = QStandardItem() + + vitem.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.model.setColumnCount(2) + self.model.appendRow([citem, vitem]) + self.model.sort(0) + return citem, vitem + + def get_or_create_data_row(self, currency): + if currency not in self._data_items: + self._data_items[currency] = self.add_data_row(currency) + return self._data_items[currency] + + def mouse_move_handler(self, pos): + pos = self.ax.getViewBox().mapSceneToView(pos) + self.line.setPos(pos.x()) + self.update_data_viewer(int(pos.x())) + + def update_data_row(self, currency, value): + citem, vitem = self.get_or_create_data_row(currency) + vitem.setText("%.4f" % value) + + def update_data_viewer(self, d): + try: + data = self.data[d] + except IndexError: # Skip update if out of bounds. + return + + if not data: # Skip update if we have no data. + return + + for k, v in data.items(): + self.update_data_row(k, v) + + def change_base_currency(self, currency): + self.base_currency = currency + self.refresh_historic_rates() + + def refresh_historic_rates(self): + if self.worker: + # If we have a current worker, send a kill signal + self.worker.signals.cancel.emit() + + # Prefill our data store with None ('no data') + self.data = [None] * HISTORIC_DAYS_N + + self.worker = UpdateWorker(self.base_currency) + # Handle callbacks with data and trigger refresh. + self.worker.signals.data.connect(self.result_data_callback) + self.worker.signals.finished.connect(self.refresh_finished) + self.worker.signals.progress.connect(self.progress_callback) + self.threadpool.start(self.worker) + + def result_data_callback(self, n, rates): + self.data[n] = rates + + # Refresh plot if we haven't for >1 second. + if (self._last_updated is None or + self._last_updated < datetime.now() - timedelta(seconds=1) + ): + self.redraw() + self._last_updated = datetime.now() + + def progress_callback(self, progress): + self.progress.setValue(progress) + + def refresh_finished(self): + self.worker = False + self.redraw() + # Ensure all currencies we know about are in the dropdown list now. + self.update_currency_list(self._data_items.keys()) + + def redraw(self): + """ + Process data from store and prefer to draw. + :return: + """ + today = date.today() + plotd = defaultdict(list) + x_ticks = [] + + tick_step_size = HISTORIC_DAYS_N / 6 + # Pre-process data into lists of x, y values + for n, data in enumerate(self.data): + if data: + for currency, v in data.items(): + plotd[currency].append((-n, v)) + + when = today - timedelta(days=n) + if (n-tick_step_size//2) % tick_step_size == 0: + x_ticks.append((-n, when.strftime('%d-%m'))) + + # Update the plot + keys = sorted(plotd.keys()) + y_min, y_max = sys.maxsize, 0 + + for currency in keys: + x, y = zip(*plotd[currency]) + + if currency in self._data_visible: + y_min = min(y_min, *y) + y_max = max(y_max, *y) + else: + x, y = [], [] + + if currency in self._data_lines: + self._data_lines[currency].setData(x, y) + else: + self._data_lines[currency] = self.ax.plot( + x, y, # Unpack a list of tuples into two lists, passed as individual args. + pen=pg.mkPen( + self.get_currency_color(currency), + width=2 + ) + ) + + + self.ax.setLimits(yMin=y_min * 0.9, yMax=y_max * 1.1) + self.ax.getAxis('bottom').setTicks([x_ticks,[]]) + + +if __name__ == '__main__': + + app = QApplication([]) + window = MainWindow() + app.exec_() \ No newline at end of file diff --git a/translate/MainWindow.py b/translate/MainWindow.py index 045fa22..4a4d8ce 100644 --- a/translate/MainWindow.py +++ b/translate/MainWindow.py @@ -55,6 +55,6 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "Translataaaar")) + MainWindow.setWindowTitle(_translate("MainWindow", "Translataarrr")) self.translateButton.setToolTip(_translate("MainWindow", "Translate")) diff --git a/translate/mainwindow.ui b/translate/mainwindow.ui index ad04383..9f72565 100644 --- a/translate/mainwindow.ui +++ b/translate/mainwindow.ui @@ -11,7 +11,7 @@ - Translataaaar + Translataarrr