├── src └── coin │ ├── __init__.py │ ├── resources │ ├── icon.png │ ├── loading.png │ ├── ca-ching.wav │ ├── icon_32px.png │ ├── icon_48px.png │ ├── icon_64px.png │ ├── logo_124px.png │ ├── logo_248px.png │ └── logo_124pxs.png │ ├── config.py │ ├── config.yaml │ ├── error.py │ ├── exchanges │ ├── okcoin.py │ ├── unocoin.py │ ├── cexio.py │ ├── bitstamp.py │ ├── gemini.py │ ├── poloniex.py │ ├── bitfinex.py │ ├── hitbtc.py │ ├── binance.py │ ├── bitkub.py │ ├── bittrex.py │ └── kraken.py │ ├── downloader.py │ ├── about.py │ ├── coingecko_client.py │ ├── plugin_selection.py │ ├── asset_selection.py │ ├── alarm.py │ ├── indicator.py │ ├── exchange.py │ └── coin.py ├── MANIFEST.in ├── img ├── gitcoin.png └── screenshot.png ├── .gitignore ├── pyproject.toml ├── coindicator.desktop ├── setup.py ├── requirements.txt ├── .pre-commit-config.yaml ├── LICENSE ├── setup.cfg ├── install.sh └── README.md /src/coin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/coin/resources/* 2 | -------------------------------------------------------------------------------- /img/gitcoin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/img/gitcoin.png -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/img/screenshot.png -------------------------------------------------------------------------------- /src/coin/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/icon.png -------------------------------------------------------------------------------- /src/coin/resources/loading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/loading.png -------------------------------------------------------------------------------- /src/coin/resources/ca-ching.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/ca-ching.wav -------------------------------------------------------------------------------- /src/coin/resources/icon_32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/icon_32px.png -------------------------------------------------------------------------------- /src/coin/resources/icon_48px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/icon_48px.png -------------------------------------------------------------------------------- /src/coin/resources/icon_64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/icon_64px.png -------------------------------------------------------------------------------- /src/coin/resources/logo_124px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/logo_124px.png -------------------------------------------------------------------------------- /src/coin/resources/logo_248px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/logo_248px.png -------------------------------------------------------------------------------- /src/coin/resources/logo_124pxs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluppfisk/coindicator/HEAD/src/coin/resources/logo_124pxs.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode 3 | *.cache 4 | env/ 5 | dist/ 6 | *egg-info/ 7 | coindicator.desktop 8 | build/ 9 | venv/ 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=46.1.0", "setuptools_scm[toml]>=5", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | # For smarter version schemes and other configuration options, 7 | # check out https://github.com/pypa/setuptools_scm 8 | version_scheme = "no-guess-dev" 9 | -------------------------------------------------------------------------------- /coindicator.desktop: -------------------------------------------------------------------------------- 1 | 2 | [Desktop Entry] 3 | Name=Coindicator 4 | GenericName=Cryptocoin price ticker 5 | Comment=Keep track of the cryptocoin prices on various exchanges 6 | Terminal=false 7 | Type=Application 8 | Categories=Utility;Network; 9 | Keywords=crypto;coin;ticker;price;exchange; 10 | StartupNotify=false 11 | Path=/opt/coindicator/ 12 | Exec=coin 13 | Icon=/home/sander/code/coinprice-indicator/src/coin/resources/logo_248px.png 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup_requirements = ["setuptools_scm[toml]>=5", "setuptools>=46.1.0"] 4 | 5 | if __name__ == "__main__": 6 | try: 7 | setup(use_scm_version={"version_scheme": "no-guess-dev"}) 8 | except: # noqa 9 | print( 10 | "\n\nAn error occurred while building the project, " 11 | "please ensure you have the most updated version of setuptools, " 12 | "setuptools_scm and wheel with:\n" 13 | " pip install -U setuptools setuptools_scm wheel\n\n" 14 | ) 15 | raise 16 | -------------------------------------------------------------------------------- /src/coin/config.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | 4 | def __call__(cls, *args, **kwargs): 5 | if cls not in cls._instances: 6 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 7 | 8 | return cls._instances[cls] 9 | 10 | 11 | class Config(metaclass=Singleton): 12 | def __init__(self, config: dict = None): 13 | self._config = config 14 | 15 | def __getitem__(self, item_name): 16 | return self.get(item_name) 17 | 18 | def __setitem__(self, item_name, value): 19 | self._config[item_name] = value 20 | 21 | def get(self, item_name, default=None): 22 | return self._config.get(item_name, default) 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.11 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | certifi==2023.7.22 8 | # via 9 | # coindicator (setup.py) 10 | # requests 11 | chardet==3.0.4 12 | # via coindicator (setup.py) 13 | charset-normalizer==3.1.0 14 | # via requests 15 | dbus-next==0.2.3 16 | # via desktop-notifier 17 | desktop-notifier==3.5.3 18 | # via coindicator (setup.py) 19 | idna==2.10 20 | # via 21 | # coindicator (setup.py) 22 | # requests 23 | packaging==23.1 24 | # via desktop-notifier 25 | pycairo==1.24.0 26 | # via pygobject 27 | pygame==2.1.3 28 | # via coindicator (setup.py) 29 | pygobject==3.44.1 30 | # via coindicator (setup.py) 31 | pyyaml==6.0 32 | # via coindicator (setup.py) 33 | requests==2.31.0 34 | # via coindicator (setup.py) 35 | urllib3==1.26.16 36 | # via 37 | # coindicator (setup.py) 38 | # requests 39 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^docs/conf.py' 2 | 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.3.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: check-added-large-files 9 | - id: check-ast 10 | - id: check-json 11 | - id: check-merge-conflict 12 | - id: check-xml 13 | - id: check-yaml 14 | args: ['--allow-multiple-documents'] 15 | - id: debug-statements 16 | - id: end-of-file-fixer 17 | - id: requirements-txt-fixer 18 | - id: mixed-line-ending 19 | args: ['--fix=auto'] # replace 'auto' with 'lf' to enforce Linux/Mac line endings or 'crlf' for Windows 20 | 21 | - repo: https://github.com/psf/black 22 | rev: 22.8.0 23 | hooks: 24 | - id: black 25 | language_version: python3 26 | 27 | - repo: https://github.com/PyCQA/flake8 28 | rev: 5.0.4 29 | hooks: 30 | - id: flake8 31 | args: [ 32 | '--max-line-length=88', 33 | '--ignore=E203,E266,W503,F403,F401,E402', 34 | '--max-complexity=18', 35 | '--select=B,C,E,F,W,T4,B9' 36 | ] 37 | exclude: docs/conf.py 38 | -------------------------------------------------------------------------------- /src/coin/config.yaml: -------------------------------------------------------------------------------- 1 | # Coin Price configuration 2 | 3 | app: 4 | name: Coindicator 5 | description: A cryptocurrency price ticker applet for Ubuntu 6 | url: https://github.com/bluppfisk/coindicator 7 | 8 | authors: 9 | - name: Nil Gradisnik 10 | email: nil.gradisnik@gmail.com 11 | - name: Sander Van de Moortel 12 | email: sander.vandemoortel@gmail.com 13 | 14 | contributors: 15 | - name: Nil Gradisnik 16 | email: nil.gradisnik@gmail.com 17 | - name: Sander Van de Moortel 18 | email: sander.vandemoortel@gmail.com 19 | - name: Rick Ramstetter 20 | email: rick@anteaterllc.com 21 | - name: Sir Paul 22 | email: wizzard94@github.com 23 | - name: Eliezer Aquino 24 | email: eliezer.aquino@gmail.com 25 | - name: Thepassith N. 26 | email: tutorgaming@gmail.com 27 | - name: Alessio Carrafa 28 | email: ruzzico@gmail.com 29 | - name: Lari Taskula 30 | email: lari@taskula.fi 31 | - name: Giorgos Karapiperidis 32 | email: georgekarapi@yahoo.gr 33 | - name: Rishabh Rawat 34 | email: rishabhrawat.rishu@gmail.com 35 | 36 | artist: 37 | name: Alicen Maniscalco 38 | email: alicenfm@gmail.com 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Nil Gradisnik and Sander Van de Moortel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/coin/error.py: -------------------------------------------------------------------------------- 1 | # Exchange error handling 2 | 3 | import logging 4 | 5 | from gi.repository import GLib 6 | 7 | MAX_ERRORS = 5 # maximum number of errors before chilling 8 | REFRESH_INTERVAL = 60 # chill refresh frequency in seconds 9 | 10 | 11 | class Error: 12 | def __init__(self, exchange): 13 | self.exchange = exchange 14 | 15 | self.count = 0 16 | self.chill = False 17 | 18 | def increment(self): 19 | self.count += 1 20 | 21 | def reset(self): 22 | self.count = 0 23 | 24 | def clear(self): 25 | self.reset() 26 | 27 | if self.chill: 28 | self.log("Restoring normal refresh frequency.") 29 | self.exchange.stop().start() 30 | self.chill = False 31 | 32 | def log(self, message): 33 | logging.warning("%s: %s" % (self.exchange.name, str(message))) 34 | 35 | def is_ok(self): 36 | max = self.count <= MAX_ERRORS 37 | 38 | if max is False: 39 | self.log( 40 | "Error limit reached. Cooling down for " 41 | + str(REFRESH_INTERVAL) 42 | + " seconds." 43 | ) 44 | self.exchange.stop() 45 | GLib.timeout_add_seconds(REFRESH_INTERVAL, self.exchange.restart) 46 | self.chill = True 47 | else: 48 | self.chill = False 49 | 50 | return max 51 | -------------------------------------------------------------------------------- /src/coin/exchanges/okcoin.py: -------------------------------------------------------------------------------- 1 | # OKCoin 2 | # https://cex.io/rest-api 3 | # By Sander Van de Moortel 4 | 5 | from coin.exchange import CURRENCY, Exchange 6 | 7 | 8 | class Okcoin(Exchange): 9 | name = "OKCoin" 10 | code = "okcoin" 11 | 12 | ticker = "https://www.okcoin.cn/api/v1/ticker.do" 13 | discovery = False # no discovery here 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return None 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + "?symbol=" + self.pair # base/quote 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | return [ 27 | {"pair": "btc_cny", "name": "BTC to CNY", "currency": CURRENCY["cny"]}, 28 | {"pair": "ltc_cny", "name": "LTC to CNY", "currency": CURRENCY["cny"]}, 29 | {"pair": "eth_cny", "name": "ETH to CNY", "currency": CURRENCY["cny"]}, 30 | ] 31 | 32 | def _parse_ticker(self, asset): 33 | asset = asset.get("ticker") 34 | 35 | cur = asset.get("last") 36 | bid = asset.get("buy") 37 | high = asset.get("high") 38 | low = asset.get("low") 39 | ask = asset.get("sell") 40 | vol = asset.get("volume") 41 | 42 | return { 43 | "cur": cur, 44 | "bid": bid, 45 | "high": high, 46 | "low": low, 47 | "ask": ask, 48 | "vol": vol, 49 | } 50 | -------------------------------------------------------------------------------- /src/coin/exchanges/unocoin.py: -------------------------------------------------------------------------------- 1 | # Unocoin 2 | # https://www.unocoin.com/how-it-works?info=tickerapi 3 | # By Sander Van de Moortel 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Unocoin(Exchange): 9 | name = "Unocoin" 10 | code = "unocoin" 11 | 12 | ticker = "https://api.unocoin.com/api/trades/{}/all" 13 | discovery = "https://api.unocoin.com/api/trades/all/all" 14 | 15 | default_label = "avg" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker.format(self.asset_pair.get("base").lower()) 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | for asset in result: 28 | base = asset 29 | quote = "INR" 30 | 31 | asset_pair = { 32 | "pair": base + quote, 33 | "base": base, 34 | "quote": quote, 35 | "name": base + " to " + quote, 36 | "currency": quote.lower(), 37 | "volumecurrency": base, 38 | } 39 | 40 | asset_pairs.append(asset_pair) 41 | 42 | return asset_pairs 43 | 44 | def _parse_ticker(self, asset): 45 | avg = asset.get("average_price") 46 | bid = asset.get("buying_price") 47 | ask = asset.get("selling_price") 48 | 49 | return {"avg": avg, "bid": bid, "ask": ask} 50 | -------------------------------------------------------------------------------- /src/coin/downloader.py: -------------------------------------------------------------------------------- 1 | # Classes to handle asynchronous downloads 2 | 3 | from threading import Thread 4 | 5 | from requests import exceptions, get 6 | 7 | 8 | class DownloadCommand: 9 | def __init__(self, url, callback, *args, **kwargs): 10 | self.callback = callback 11 | self.args = args 12 | self.kwargs = kwargs 13 | self.timeout = 5 14 | self.timestamp = None 15 | self.error = None 16 | self.url = url 17 | self.response = None 18 | 19 | 20 | class AsyncDownloadService: 21 | def execute(self, command, response_handler): 22 | def _callback_with_args(response, **kwargs): 23 | command.response = response 24 | response_handler(command) 25 | 26 | kwargs = {"command": command, "callback": _callback_with_args} 27 | 28 | thread = Thread(target=AsyncDownloadService.download, kwargs=kwargs) 29 | thread.start() 30 | 31 | @staticmethod 32 | def download(command, callback): 33 | kwargs = {"timeout": command.timeout, "hooks": {"response": callback}} 34 | 35 | try: 36 | get(command.url, **kwargs) 37 | except exceptions.RequestException as e: 38 | command.error = "Connection error " + str(e) 39 | callback(None) 40 | 41 | 42 | class DownloadService: 43 | def execute(self, command, response_handler): 44 | try: 45 | command.response = get(command.url, timeout=command.timeout) 46 | response_handler(command) 47 | except exceptions.RequestException as e: 48 | command.error = "Connection error " + str(e) 49 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = coindicator 3 | description = Coinprice Indicator 4 | author = Sander Van de Moortel 5 | author_email = sander.vandemoortel@gmail.com 6 | license = MIT 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown; charset=UTF-8 9 | url = https://github.com/bluppfisk/coindicator/ 10 | project_urls = 11 | Documentation = https://github.com/bluppfisk/coindicator/ 12 | platforms = Linux 13 | classifiers = 14 | Development Status :: 5 - Production/Stable 15 | Programming Language :: Python 16 | 17 | [easy_install] 18 | 19 | [options] 20 | zip_safe = False 21 | packages = find_namespace: 22 | include_package_data = True 23 | package_dir = 24 | =src 25 | 26 | install_requires= 27 | certifi>=2022.12.7 28 | chardet~=3.0.4 29 | idna~=2.7 30 | desktop-notifier~=3.5.3 31 | PyYAML>=4.2b1 32 | requests~=2.20 33 | urllib3~=1.26.5 34 | pygame~=2.1.2 35 | PyGObject~=3.44.1 36 | 37 | [options.packages.find] 38 | where = 39 | src 40 | 41 | [options.package_data] 42 | * = 43 | config.yaml 44 | resources/* 45 | 46 | [options.extras_require] 47 | develop = 48 | flake8 49 | black 50 | virtualenv 51 | build 52 | setuptools_scm 53 | 54 | [options.entry_points] 55 | console_scripts = 56 | coindicator = coin.coin:main 57 | 58 | [bdist_wheel] 59 | # Use this option if your package is pure-python 60 | # universal = 1 61 | 62 | [flake8] 63 | # Some sane defaults for the code style checker flake8 64 | max_line_length = 88 65 | extend_ignore = E203, W503 66 | # ^ Black-compatible 67 | # E203 and W503 have edge cases handled by black 68 | exclude = 69 | build 70 | dist 71 | .eggs 72 | -------------------------------------------------------------------------------- /src/coin/about.py: -------------------------------------------------------------------------------- 1 | from gi.repository import GdkPixbuf, Gtk 2 | from importlib.metadata import version 3 | 4 | 5 | class AboutWindow(Gtk.AboutDialog): 6 | def __init__(self, config): 7 | super().__init__() 8 | self.config = config 9 | 10 | logo_124px = GdkPixbuf.Pixbuf.new_from_file( 11 | str(self.config.get("project_root") / "resources/icon_32px.png") 12 | ) 13 | self.set_program_name(self.config.get("app").get("name")) 14 | self.set_comments(self.config.get("app").get("description")) 15 | self.set_version(version("coindicator")) 16 | self.set_website(self.config.get("app").get("url")) 17 | authors = [] 18 | for author in self.config.get("authors"): 19 | authors.append("{} <{}>".format(author.get("name"), author.get("email"))) 20 | 21 | self.set_authors(authors) 22 | contributors = [] 23 | for contributor in self.config.get("contributors"): 24 | contributors.append( 25 | "{} <{}>".format(contributor.get("name"), contributor.get("email")) 26 | ) 27 | self.add_credit_section("Exchange plugins", contributors) 28 | self.set_artists( 29 | [ 30 | "{} <{}>".format( 31 | self.config.get("artist").get("name"), 32 | self.config.get("artist").get("email"), 33 | ) 34 | ] 35 | ) 36 | self.set_license_type(Gtk.License.MIT_X11) 37 | self.set_logo(logo_124px) 38 | self.set_keep_above(True) 39 | 40 | def show(self): 41 | res = self.run() 42 | if res == -4 or -6: # close events 43 | self.destroy() 44 | -------------------------------------------------------------------------------- /src/coin/exchanges/cexio.py: -------------------------------------------------------------------------------- 1 | # CEX.io 2 | # https://cex.io/rest-api 3 | # By Sander Van de Moortel 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Cexio(Exchange): 9 | name = "CEX.io" 10 | code = "cexio" 11 | 12 | ticker = "https://cex.io/api/ticker" 13 | discovery = "https://cex.io/api/currency_limits" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + "/" + self.pair # base/quote 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | data = result.get("data") 28 | for asset in data.get("pairs"): 29 | base = asset.get("symbol1") 30 | quote = asset.get("symbol2") 31 | 32 | asset_pair = { 33 | "pair": base + "/" + quote, 34 | "base": base, 35 | "quote": quote, 36 | "name": base + " to " + quote, 37 | "currency": quote.lower(), 38 | "volumecurrency": base, 39 | } 40 | 41 | asset_pairs.append(asset_pair) 42 | 43 | return asset_pairs 44 | 45 | def _parse_ticker(self, asset): 46 | cur = asset.get("last") 47 | bid = asset.get("bid") 48 | high = asset.get("high") 49 | low = asset.get("low") 50 | ask = asset.get("ask") 51 | vol = asset.get("volume") 52 | 53 | return { 54 | "cur": cur, 55 | "bid": bid, 56 | "high": high, 57 | "low": low, 58 | "ask": ask, 59 | "vol": vol, 60 | } 61 | -------------------------------------------------------------------------------- /src/coin/exchanges/bitstamp.py: -------------------------------------------------------------------------------- 1 | # Bitstamp 2 | # https://www.bitstamp.net/api/ 3 | # By Nil Gradisnik 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Bitstamp(Exchange): 9 | name = "Bitstamp" 10 | code = "bitstamp" 11 | 12 | ticker = "https://www.bitstamp.net/api/v2/ticker/" 13 | discovery = "https://www.bitstamp.net/api/v2/trading-pairs-info/" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + self.pair 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | for asset in result: 28 | basequote = asset.get("name").split("/") 29 | base = basequote[0] 30 | quote = basequote[1] 31 | 32 | asset_pair = { 33 | "pair": asset.get("url_symbol"), 34 | "base": base, 35 | "quote": quote, 36 | "name": base + " to " + quote, 37 | "currency": quote.lower(), 38 | "volumecurrency": base, 39 | } 40 | 41 | asset_pairs.append(asset_pair) 42 | 43 | return asset_pairs 44 | 45 | def _parse_ticker(self, asset): 46 | cur = asset.get("last") 47 | bid = asset.get("bid") 48 | ask = asset.get("ask") 49 | vol = asset.get("volume") 50 | high = asset.get("high") 51 | low = asset.get("low") 52 | 53 | return { 54 | "cur": cur, 55 | "bid": bid, 56 | "high": high, 57 | "low": low, 58 | "ask": ask, 59 | "vol": vol, 60 | } 61 | -------------------------------------------------------------------------------- /src/coin/exchanges/gemini.py: -------------------------------------------------------------------------------- 1 | # Gemini 2 | # https://docs.gemini.com/rest-api/ 3 | # By Rick Ramstetter 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Gemini(Exchange): 9 | name = "Gemini" 10 | code = "gemini" 11 | 12 | ticker = "https://api.gemini.com/v1/pubticker/" 13 | discovery = "https://api.gemini.com/v1/symbols" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + self.pair 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | for asset in result: 28 | base = asset[0:3].upper() 29 | quote = asset[-3:].upper() 30 | 31 | asset_pair = { 32 | "pair": asset, 33 | "base": base, 34 | "quote": quote, 35 | "name": base + " to " + quote, 36 | "currency": quote.lower(), 37 | "volumecurrency": base, 38 | } 39 | 40 | asset_pairs.append(asset_pair) 41 | 42 | return asset_pairs 43 | 44 | def _parse_ticker(self, asset): 45 | volumelabel = [ 46 | item for item in self.config["asset_pairs"] if item["pair"] == self.pair 47 | ][0]["volumelabel"] 48 | cur = asset.get("last") 49 | bid = asset.get("bid") 50 | ask = asset.get("ask") 51 | vol = asset.get("volume").get(volumelabel) 52 | 53 | return { 54 | "cur": cur, 55 | "bid": bid, 56 | "high": None, 57 | "low": None, 58 | "ask": ask, 59 | "vol": vol, 60 | } 61 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Install system dependencies 3 | if [[ $# -eq 0 ]] 4 | then 5 | echo "Usage: ./install.sh install_dir" 6 | echo "E.g. ./install.sh /opt/coindicator" 7 | exit 1 8 | fi 9 | 10 | args=("$@") 11 | 12 | echo Installing to ${args[0]} 13 | 14 | sudo apt-get install python3-venv python3-setuptools-scm python3-wheel python3-gi gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 python3-pip patchelf -y 15 | 16 | # some users report requiring libgirepository1.0-dev libdbus-1-dev, libcairo2-dev, build-essential 17 | # some report having to install a newer version of cmake 18 | 19 | # sudo apt purge --auto-remove cmake 20 | # wget -O - https://apt.kitware.com/keys/kitware-archive-latest.asc 2>/dev/null | gpg --dearmor - | sudo tee /etc/apt/trusted.gpg.d/kitware.gpg >/dev/null 21 | # sudo apt-add-repository 'deb https://apt.kitware.com/ubuntu/ focal main' 22 | # sudo apt update 23 | # sudo apt install cmake 24 | 25 | # Install python packages 26 | sudo mkdir ${args[0]} 2>/dev/null 27 | sudo python3 -m venv ${args[0]}/venv 28 | source ${args[0]}/venv/bin/activate 29 | sudo -H -E env PATH=$PATH pip3 install -U coindicator 30 | 31 | # Install shortcut 32 | cat > /tmp/coindicator.desktop << EOL 33 | 34 | [Desktop Entry] 35 | Name=Coindicator 36 | GenericName=Cryptocoin price ticker 37 | Comment=Keep track of the cryptocoin prices on various exchanges 38 | Terminal=false 39 | Type=Application 40 | Categories=Utility; 41 | Keywords=crypto;coin;ticker;price;exchange; 42 | StartupNotify=false 43 | Path=${args[0]}/venv/bin 44 | Exec=coindicator 45 | Icon=/tmp/logo_248px.png 46 | X-GNOME-UsesNotifications=true 47 | EOL 48 | 49 | cp ./src/coin/resources/logo_248px.png /tmp 50 | 51 | 52 | desktop-file-install --dir=$HOME/.local/share/applications /tmp/coindicator.desktop 53 | -------------------------------------------------------------------------------- /src/coin/exchanges/poloniex.py: -------------------------------------------------------------------------------- 1 | # Poloniex 2 | # https://poloniex.com/public?command=returnTicker 3 | # By Sander Van de Moortel 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Poloniex(Exchange): 9 | name = "Poloniex" 10 | code = "poloniex" 11 | 12 | ticker = "https://poloniex.com/public?command=returnTicker" 13 | discovery = "https://poloniex.com/public?command=returnTicker" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | for asset in result: 28 | asset_data = asset.split("_") 29 | base = asset_data[0] 30 | quote = asset_data[1] 31 | 32 | asset_pair = { 33 | "pair": asset, 34 | "base": base, 35 | "quote": quote, 36 | "name": base + " to " + quote, 37 | "currency": quote.lower(), 38 | "volumecurrency": base, 39 | } 40 | 41 | asset_pairs.append(asset_pair) 42 | 43 | return asset_pairs 44 | 45 | def _parse_ticker(self, asset): 46 | asset = asset.get(self.pair) 47 | 48 | cur = asset.get("last") 49 | bid = asset.get("highestBid") 50 | high = asset.get("high24hr") 51 | low = asset.get("low24hr") 52 | ask = asset.get("lowestAsk") 53 | vol = asset.get("quoteVolume") 54 | 55 | return { 56 | "cur": cur, 57 | "bid": bid, 58 | "high": high, 59 | "low": low, 60 | "ask": ask, 61 | "vol": vol, 62 | } 63 | -------------------------------------------------------------------------------- /src/coin/exchanges/bitfinex.py: -------------------------------------------------------------------------------- 1 | # Bitfinex 2 | # https://bitfinex.readme.io/v2/docs 3 | # By Alessio Carrafa 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Bitfinex(Exchange): 9 | name = "Bitfinex" 10 | code = "bitfinex" 11 | 12 | ticker = "https://api.bitfinex.com/v2/ticker/" 13 | discovery = "https://api.bitfinex.com/v1/symbols" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + self.pair 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | for asset in result: 28 | base = asset[0:3].upper() 29 | quote = asset[-3:].upper() 30 | 31 | names = {"DSH": "DASH", "TRST": "TRUST", "XZC": "ZEC"} 32 | if base in names: 33 | base = names[base] 34 | 35 | if quote in names: 36 | quote = names[quote] 37 | 38 | asset_pair = { 39 | "pair": "t" + asset.upper(), 40 | "base": base, 41 | "quote": quote, 42 | "name": base + " to " + quote, 43 | "currency": quote.lower(), 44 | "volumecurrency": base, 45 | } 46 | 47 | asset_pairs.append(asset_pair) 48 | 49 | return asset_pairs 50 | 51 | def _parse_ticker(self, asset): 52 | 53 | cur = asset[6] 54 | bid = asset[0] 55 | ask = asset[2] 56 | vol = asset[7] 57 | high = asset[8] 58 | low = asset[9] 59 | 60 | return { 61 | "cur": cur, 62 | "bid": bid, 63 | "high": high, 64 | "low": low, 65 | "ask": ask, 66 | "vol": vol, 67 | } 68 | -------------------------------------------------------------------------------- /src/coin/exchanges/hitbtc.py: -------------------------------------------------------------------------------- 1 | # HitBTC 2 | # https://github.com/hitbtc-com/hitbtc-api/blob/master/APIv1.md 3 | # By Sander Van de Moortel 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Hitbtc(Exchange): 9 | name = "HitBTC" 10 | code = "hitbtc" 11 | 12 | ticker = "https://api.hitbtc.com/api/1/public/" 13 | discovery = "http://api.hitbtc.com/api/1/public/symbols" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + self.pair + "/ticker" 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | assets = result.get("symbols") 28 | for asset in assets: 29 | base = asset.get("commodity") 30 | quote = asset.get("currency") 31 | 32 | names = {"IOTA": "IOT", "MAN": "MANA"} 33 | if base in names: 34 | base = names[base] 35 | 36 | if quote in names: 37 | quote = names[quote] 38 | 39 | asset_pair = { 40 | "pair": asset.get("symbol"), 41 | "base": base, 42 | "quote": quote, 43 | "name": base + " to " + quote, 44 | "currency": quote.lower(), 45 | "volumecurrency": base, 46 | } 47 | 48 | asset_pairs.append(asset_pair) 49 | 50 | return asset_pairs 51 | 52 | def _parse_ticker(self, asset): 53 | cur = asset.get("last") 54 | bid = asset.get("bid") 55 | high = asset.get("high") 56 | low = asset.get("low") 57 | ask = asset.get("ask") 58 | vol = asset.get("volume") 59 | 60 | return { 61 | "cur": cur, 62 | "bid": bid, 63 | "high": high, 64 | "low": low, 65 | "ask": ask, 66 | "vol": vol, 67 | } 68 | -------------------------------------------------------------------------------- /src/coin/coingecko_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | 4 | import requests 5 | 6 | from coin.config import Config 7 | 8 | 9 | class CoinGeckoClient: 10 | def __init__(self): 11 | self.url = "https://api.coingecko.com/api/v3/coins/" 12 | self.coingecko_list = [] 13 | self.config = Config() 14 | 15 | def load_list(self): 16 | url = self.url + "list" 17 | data = requests.get(url, timeout=10) 18 | if data.status_code == 200: 19 | self.coingecko_list = [ 20 | {"id": item["id"], "symbol": item["symbol"]} for item in data.json() 21 | ] 22 | else: 23 | logging.warning( 24 | "CoinGecko API Error <%d>: %s" % (data.status_code, data.text) 25 | ) 26 | 27 | # Fetch icon from CoinGecko 28 | def get_icon(self, asset): 29 | if len(self.coingecko_list) == 0: 30 | self.load_list() 31 | 32 | url = "" 33 | for coin in self.coingecko_list: 34 | if asset == coin.get("symbol"): 35 | url = self.url + coin.get("id") 36 | break 37 | 38 | if url == "": 39 | return 40 | 41 | data = requests.get(url, timeout=5) 42 | if data.status_code != 200: 43 | logging.error( 44 | "Coingecko returned %d while fetching symbol details" % data.status_code 45 | ) 46 | return 47 | 48 | img_url = data.json().get("image").get("small") 49 | img = requests.get(img_url, stream=True, timeout=5) 50 | 51 | if img.status_code != 200: 52 | logging.error( 53 | "Coingecko returned %d while fetching symbol icon" % img.status_code 54 | ) 55 | return 56 | 57 | img_file = self.config["icon_dir"] / f"{asset}.png" 58 | with open(img_file, "wb") as f: 59 | img.raw.decode_content = True 60 | shutil.copyfileobj(img.raw, f) 61 | 62 | return img_file 63 | -------------------------------------------------------------------------------- /src/coin/exchanges/binance.py: -------------------------------------------------------------------------------- 1 | # Binance 2 | # https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md 3 | # By Lari Taskula 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Binance(Exchange): 9 | name = "Binance" 10 | code = "binance" 11 | 12 | ticker = "https://www.binance.com/api/v1/ticker/24hr" 13 | discovery = "https://www.binance.com/api/v1/exchangeInfo" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + "?symbol=" + self.pair 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | assets = result.get("symbols") 28 | for asset in assets: 29 | base = asset.get("baseAsset") 30 | quote = asset.get("quoteAsset") 31 | 32 | names = {"XZC": "ZEC", "BCC": "BCH", "IOTA": "IOT"} 33 | if base in names: 34 | base = names[base] 35 | 36 | if quote in names: 37 | quote = names[quote] 38 | 39 | asset_pair = { 40 | "pair": asset.get("symbol"), 41 | "base": base, 42 | "quote": quote, 43 | "name": base + " to " + quote, 44 | "currency": quote.lower(), 45 | "volumecurrency": base, 46 | } 47 | 48 | asset_pairs.append(asset_pair) 49 | 50 | return asset_pairs 51 | 52 | def _parse_ticker(self, asset): 53 | cur = asset.get("lastPrice") 54 | bid = asset.get("bidPrice") 55 | high = asset.get("highPrice") 56 | low = asset.get("lowPrice") 57 | ask = asset.get("askPrice") 58 | vol = asset.get("volume") 59 | 60 | return { 61 | "cur": cur, 62 | "bid": bid, 63 | "high": high, 64 | "low": low, 65 | "ask": ask, 66 | "vol": vol, 67 | } 68 | -------------------------------------------------------------------------------- /src/coin/exchanges/bitkub.py: -------------------------------------------------------------------------------- 1 | # Bitkub 2 | # https://github.com/bitkub/bitkub-official-api-docs/blob/master/restful-api.md 3 | # By Theppasith N. 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Bitkub(Exchange): 9 | name = "Bitkub" 10 | code = "bitkub" 11 | 12 | ticker = "https://api.bitkub.com/api/market/ticker" 13 | discovery = "https://api.bitkub.com/api/market/symbols" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + "?sym=" + self.pair 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | # Bitkub provide fetching result in error field 28 | success = result.get("error") == 0 29 | if not success: 30 | return [] 31 | assets = result.get("result") 32 | 33 | # Iterate through all symbols 34 | for asset in assets: 35 | symbol = asset.get("symbol") 36 | split_sym = str(symbol).strip().split("_") 37 | base = split_sym[1] 38 | quote = split_sym[0] 39 | 40 | asset_pair = { 41 | "pair": symbol, 42 | "base": base, 43 | "quote": quote, 44 | "name": asset.get("info"), 45 | "currency": quote.lower(), 46 | "volumecurrency": base, 47 | } 48 | asset_pairs.append(asset_pair) 49 | 50 | return asset_pairs 51 | 52 | def _parse_ticker(self, asset): 53 | data = asset[next(iter(asset))] 54 | cur = data.get("last") 55 | bid = data.get("highestBid") 56 | high = data.get("high24hr") 57 | low = data.get("low24hr") 58 | ask = data.get("lowestAsk") 59 | vol = data.get("baseVolume") 60 | 61 | return { 62 | "cur": cur, 63 | "bid": bid, 64 | "high": high, 65 | "low": low, 66 | "ask": ask, 67 | "vol": vol, 68 | } 69 | -------------------------------------------------------------------------------- /src/coin/exchanges/bittrex.py: -------------------------------------------------------------------------------- 1 | # Bittrex 2 | # https://bittrex.com/Home/Api 3 | # By "Sir Paul" 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Bittrex(Exchange): 9 | name = "Bittrex" 10 | code = "bittrex" 11 | 12 | ticker = "https://api.bittrex.com/v3/markets/{}/ticker" 13 | discovery = "https://api.bittrex.com/v3/markets" 14 | 15 | default_label = "ask" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker.format(self.pair) 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | for asset in result: 28 | base = asset.get("baseCurrencySymbol") 29 | quote = asset.get("quoteCurrencySymbol") 30 | market = asset.get("symbol") 31 | 32 | names = { 33 | "SWIFT": "SWFTC", 34 | "DSH": "DASH", 35 | "TRST": "TRUST", 36 | "XZC": "ZEC", 37 | "GAM": "GAME", 38 | "BCC": "BCH", 39 | } 40 | if base in names: 41 | base = names[base] 42 | 43 | if quote in names: 44 | quote = names[quote] 45 | 46 | asset_pair = { 47 | "pair": market, 48 | "base": base, 49 | "quote": quote, 50 | "name": base + " to " + quote, 51 | "currency": quote.lower(), 52 | "volumecurrency": base, 53 | } 54 | 55 | asset_pairs.append(asset_pair) 56 | 57 | return asset_pairs 58 | 59 | def _parse_ticker(self, asset): 60 | # Bittrex moved last, high, low and volume to a separate API 61 | # endpoint in v3. Coindicator currently does not support 62 | # aggregating data from multiple endpoints 63 | 64 | # cur = asset.get("Last") 65 | bid = asset.get("bidRate") 66 | # high = asset.get("High") 67 | # low = asset.get("Low") 68 | ask = asset.get("askRate") 69 | # vol = None 70 | 71 | return { 72 | # "cur": cur, 73 | "bid": bid, 74 | # "high": high, 75 | # "low": low, 76 | "ask": ask, 77 | # "vol": vol, 78 | } 79 | -------------------------------------------------------------------------------- /src/coin/exchanges/kraken.py: -------------------------------------------------------------------------------- 1 | # Kraken 2 | # https://www.kraken.com/help/api#public-market-data 3 | # By Nil Gradisnik 4 | 5 | from coin.exchange import Exchange 6 | 7 | 8 | class Kraken(Exchange): 9 | name = "Kraken" 10 | code = "kraken" 11 | 12 | ticker = "https://api.kraken.com/0/public/Ticker" 13 | discovery = "https://api.kraken.com/0/public/AssetPairs" 14 | 15 | default_label = "cur" 16 | 17 | @classmethod 18 | def _get_discovery_url(cls): 19 | return cls.discovery 20 | 21 | def _get_ticker_url(self): 22 | return self.ticker + "?pair=" + self.pair 23 | 24 | @staticmethod 25 | def _parse_discovery(result): 26 | asset_pairs = [] 27 | assets = result.get("result") 28 | for asset in assets: 29 | # strange double assets in Kraken results, ignore ba 30 | if asset[-2:] == ".d": 31 | continue 32 | 33 | asset_data = assets.get(asset) 34 | # new kraken api data contains a 'wsname' property 35 | # which names assets a lot more consistently 36 | names = asset_data.get("wsname").split("/") 37 | base = names[0] 38 | quote = names[1] 39 | 40 | kraken_names = {"XBT": "BTC", "XZC": "ZEC"} 41 | if base in kraken_names: 42 | base = kraken_names[base] 43 | 44 | if quote in kraken_names: 45 | quote = kraken_names[quote] 46 | 47 | asset_pair = { 48 | "pair": asset, 49 | "base": base, 50 | "quote": quote, 51 | "name": base + " to " + quote, 52 | "currency": quote.lower(), 53 | "volumecurrency": base, 54 | } 55 | 56 | asset_pairs.append(asset_pair) 57 | 58 | return asset_pairs 59 | 60 | def _parse_ticker(self, asset): 61 | asset = asset.get("result").get(self.pair) 62 | 63 | cur = asset.get("c")[0] 64 | bid = asset.get("b")[0] 65 | high = asset.get("h")[1] 66 | low = asset.get("l")[1] 67 | ask = asset.get("a")[0] 68 | vol = asset.get("v")[1] 69 | 70 | return { 71 | "cur": cur, 72 | "bid": bid, 73 | "high": high, 74 | "low": low, 75 | "ask": ask, 76 | "vol": vol, 77 | } 78 | -------------------------------------------------------------------------------- /src/coin/plugin_selection.py: -------------------------------------------------------------------------------- 1 | # Plugin selection window 2 | 3 | from gi.repository import Gdk, Gtk 4 | 5 | 6 | class PluginSelectionWindow(Gtk.Window): 7 | def __init__(self, parent): 8 | Gtk.Window.__init__(self, title="Plugins") 9 | 10 | self.parent = parent 11 | self.set_keep_above(True) 12 | self.set_border_width(5) 13 | self.set_position(Gtk.WindowPosition.MOUSE) 14 | self.connect("key-release-event", self._on_key_release) 15 | 16 | grid = Gtk.Grid() 17 | grid.set_column_homogeneous(True) 18 | grid.set_row_homogeneous(True) 19 | self.add(grid) 20 | 21 | self.plugin_store = Gtk.ListStore(bool, str, object) 22 | for item in self.parent.exchanges.values(): 23 | self.plugin_store.append([item.active, item.name, item]) 24 | 25 | self.plugin_store.set_sort_column_id(1, Gtk.SortType.ASCENDING) 26 | 27 | self.view_plugins = Gtk.TreeView(self.plugin_store) 28 | 29 | rend_checkbox = Gtk.CellRendererToggle() 30 | rend_checkbox.connect("toggled", self._toggle) 31 | rend_plugin = Gtk.CellRendererText() 32 | 33 | col_chk = Gtk.TreeViewColumn("", rend_checkbox, active=0) 34 | col_plugin = Gtk.TreeViewColumn("Plugin", rend_plugin, text=1) 35 | 36 | self.view_plugins.append_column(col_chk) 37 | self.view_plugins.append_column(col_plugin) 38 | 39 | self.set_focus_child(self.view_plugins) 40 | 41 | sw = Gtk.ScrolledWindow() 42 | sw.set_vexpand(True) 43 | sw.add(self.view_plugins) 44 | grid.attach(sw, 0, 0, 100, 220) 45 | 46 | buttonbox = Gtk.Box(spacing=2) 47 | 48 | button_set = Gtk.Button("Select") 49 | button_set.connect("clicked", self._select_plugins) 50 | button_set.set_can_default(True) 51 | button_set.get_style_context().add_class(Gtk.STYLE_CLASS_SUGGESTED_ACTION) 52 | 53 | button_cancel = Gtk.Button("Close") 54 | button_cancel.connect("clicked", self._close) 55 | 56 | buttonbox.pack_start(button_set, True, True, 0) 57 | buttonbox.pack_start(button_cancel, True, True, 0) 58 | 59 | grid.attach(buttonbox, 0, 245, 100, 50) 60 | 61 | self.show_all() 62 | self.present() 63 | 64 | def _toggle(self, _renderer, path): 65 | iter = self.plugin_store.get_iter(path) 66 | self.plugin_store[iter][0] = not self.plugin_store[iter][0] 67 | 68 | def _select_plugins(self, _widget=None): 69 | for item in self.plugin_store: 70 | item[2].active = item[0] 71 | 72 | self.parent.plugins_updated() 73 | self._close() 74 | 75 | def _on_key_release(self, _widget, ev, _data=None): 76 | if ev.keyval == Gdk.KEY_Escape: 77 | self._close() 78 | 79 | def _close(self, _widget=None): 80 | self.destroy() 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coindicator 2 | 3 | ![Coin Price logo](src/coin/resources/logo_124px.png) 4 | 5 | Coindicator is a cryptocurrency price indicator applet for Linux. 6 | 7 | [![PyPI version](https://badge.fury.io/py/coindicator.svg)](https://badge.fury.io/py/coindicator) 8 | 9 | ## Features 10 | 11 | * Multiple price tickers in the status bar 12 | * Automatic trade pair discovery on supported exchanges 13 | * Additional price points in the dropdown menu 14 | * Audiovisual price alerts 15 | * Adjust the refresh rate 16 | * Thousands of cryptocurrency pairs from the following exchanges: 17 | 18 | * [Kraken](https://www.kraken.com) 19 | * [Bitstamp](https://www.bitstamp.net) 20 | * [Gemini](https://www.gemini.com) 21 | * [Binance](https://www.binance.com) 22 | * [Bittrex](https://bittrex.com) 23 | * [Bitfinex](https://www.bitfinex.com/) 24 | * [Poloniex](https://poloniex.com) 25 | * [HitBTC](https://hitbtc.com/) 26 | * [CEX.io](https://cex.io/) 27 | * [OKCoin](https://www.okcoin.cn/) 28 | * [Unocoin](https://www.unocoin.com/) 29 | * Add your own easily (See **Extending (Plugins)** below) 30 | 31 | ![Screenshot](img/screenshot.png) 32 | 33 | ## Installing 34 | 35 | You will need Git and Python 3.5 or higher, as well as some system dependencies. 36 | 37 | For your convenience, I've included a small install script that will install (or upgrade) 38 | coindicator and its dependencies, as well as create a desktop icon. It will ask to elevate 39 | permissions to install dependencies. It takes the install location as an argument. 40 | 41 | ```bash 42 | git clone https://github.com/bluppfisk/coindicator.git && cd coindicator 43 | ./install.sh /opt/coindicator # or wherever you want it installed. 44 | ``` 45 | 46 | ## Upgrading from 1.x 47 | 48 | User data has moved to your home folder. To keep your settings, move the user.conf file to: **~/.config/coindicator/**. 49 | 50 | ## Running 51 | 52 | * A launcher icon "Coindicator" should have been installed that can be used to start the app 53 | * Alternatively, go to the install folder, activate the environment `source venv/bin/activate` and run the app with `coindicator`. Add ` &` to run it in the background. 54 | 55 | ## Configuring 56 | 57 | Use the GUI to add and remove indicators (find the piggy icon), to pick assets, to set refresh frequency and to set alarms. Alternatively, edit the **~/.config/coindicator/user.conf** YAML file. 58 | 59 | `max_decimals`: default 8. Lower if you want fewer decimals (takes priority over `significant_digits`) 60 | `significant_digits`: default 3. Set to higher if you want more significant digits. 61 | 62 | ## Extending (Plug-ins) 63 | 64 | Adding your own exchange plug-in is easy. Just create class file with methods for returning a ticker URL, a discovery URL, and parsing the responses from the ticker and discovery APIs. Then add the file to the `exchanges` folder. 65 | 66 | Have a peek at the existing plug-ins (e.g. **kraken.py**) for an example and don't forget to contribute your plug-ins here on GitHub! 67 | 68 | ## Building 69 | 70 | - Create and activate environment 71 | - run `pip install -e .[develop]` to install required tools 72 | - run `python3 setup.py build sdist` 73 | - run `twine upload dist/{version}` if you want to upload to PyPi (will need credentials) 74 | 75 | ## Troubleshooting 76 | 77 | This software was tested and found working on the following configurations: 78 | * Ubuntu Linux 16.04 (Xenial Xurus) with Unity 7 79 | * Ubuntu Linux 17.10 (Artful Aardvark) with GNOME 3 and Unity 7 80 | * Ubuntu Linux 18.04 (Bionic Beaver) with GNOME 3 and Unity 7 81 | * Ubuntu Linux 19.04 (Disco Dingo) with GNOME 3 and Unity 7 82 | * Ubuntu Linux 19.10 (Eoan Ermine) with GNOME 3 and Unity 7 83 | * Ubuntu Linux 20.04 (Focal Fossa) with GNOME 3 84 | * Ubuntu Linux 20.10 (Groovy Gorilla) with GNOME 3 85 | * Ubuntu Linux 21.04 (Hirsute Hippo) with GNOME 3 86 | * Ubuntu Linux 21.10 (Impish Indri) with GNOME 40 87 | * Ubuntu Linux 22.04 (Jammy Jellyfish) with GNOME 42 88 | * Ubuntu Linux 22.10 (Kinetic Kudu) with GNOME 43 89 | * Ubuntu Linux 23.04 (Lunar Lobster) with GNOME 44 90 | 91 | For other systems, you may need to install LibAppIndicator support. 92 | 93 | Before reporting bugs or issues, please try removing/renaming the **~/.config/coindicator** folder first. 94 | -------------------------------------------------------------------------------- /src/coin/asset_selection.py: -------------------------------------------------------------------------------- 1 | # Asset selection window 2 | 3 | from gi.repository import Gdk, Gtk 4 | 5 | 6 | class AssetSelectionWindow(Gtk.Window): 7 | def __init__(self, parent): 8 | Gtk.Window.__init__(self, title="Select Asset") 9 | 10 | self.parent = parent 11 | self.set_keep_above(True) 12 | self.set_border_width(5) 13 | self.set_position(Gtk.WindowPosition.MOUSE) 14 | self.connect("key-release-event", self._on_key_release) 15 | # self.set_modal(True) 16 | 17 | grid = Gtk.Grid() 18 | grid.set_column_homogeneous(True) 19 | grid.set_row_homogeneous(True) 20 | self.add(grid) 21 | 22 | self.base_store = Gtk.ListStore(str) 23 | for item in self.parent.coin.bases: 24 | self.base_store.append([item]) 25 | 26 | self.base_store.set_sort_column_id(0, Gtk.SortType.ASCENDING) 27 | self.quote_store = Gtk.ListStore(str) 28 | self.ex_store = Gtk.ListStore(str, str) 29 | 30 | self.view_bases = Gtk.TreeView(self.base_store) 31 | self.view_quotes = Gtk.TreeView(self.quote_store) 32 | self.view_exchanges = Gtk.TreeView(self.ex_store) 33 | 34 | self.view_bases.get_selection().connect("changed", self._base_changed) 35 | self.view_quotes.get_selection().connect("changed", self._quote_changed) 36 | self.view_exchanges.get_selection().connect("changed", self._exchange_changed) 37 | 38 | rend_base = Gtk.CellRendererText() 39 | rend_quote = Gtk.CellRendererText() 40 | rend_exchange = Gtk.CellRendererText() 41 | 42 | col_base = Gtk.TreeViewColumn("Base", rend_base, text=0) 43 | col_base.set_sort_column_id(0) 44 | col_quote = Gtk.TreeViewColumn("Quote", rend_quote, text=0) 45 | col_quote.set_sort_column_id(0) 46 | col_exchange = Gtk.TreeViewColumn("Exchange", rend_exchange, text=0) 47 | col_exchange.set_sort_column_id(0) 48 | 49 | self.view_bases.append_column(col_base) 50 | self.view_quotes.append_column(col_quote) 51 | self.view_exchanges.append_column(col_exchange) 52 | self.view_exchanges.connect("row-activated", self._update_indicator) 53 | 54 | self.set_focus_child(self.view_bases) 55 | 56 | sw = Gtk.ScrolledWindow() 57 | sw.set_vexpand(True) 58 | sw.add(self.view_bases) 59 | grid.attach(sw, 0, 0, 200, 400) 60 | 61 | sw2 = Gtk.ScrolledWindow() 62 | sw2.set_vexpand(True) 63 | sw2.add(self.view_quotes) 64 | grid.attach(sw2, 200, 0, 200, 400) 65 | 66 | sw3 = Gtk.ScrolledWindow() 67 | sw3.set_vexpand(True) 68 | sw3.add(self.view_exchanges) 69 | grid.attach(sw3, 400, 0, 200, 400) 70 | 71 | lbl_hint = Gtk.Label("Hint: Start typing in a list to search.") 72 | grid.attach(lbl_hint, 100, 400, 400, 25) 73 | 74 | buttonbox = Gtk.Box(spacing=2) 75 | 76 | button_set_close = Gtk.Button("Set and Close") 77 | button_set_close.connect("clicked", self._update_indicator_close) 78 | 79 | button_set = Gtk.Button("Set") 80 | button_set.connect("clicked", self._update_indicator) 81 | button_set.set_can_default(True) 82 | button_set.get_style_context().add_class(Gtk.STYLE_CLASS_SUGGESTED_ACTION) 83 | 84 | button_cancel = Gtk.Button("Close") 85 | button_cancel.connect("clicked", self._close) 86 | 87 | buttonbox.pack_start(button_set_close, True, True, 0) 88 | buttonbox.pack_start(button_set, True, True, 0) 89 | buttonbox.pack_start(button_cancel, True, True, 0) 90 | 91 | grid.attach(buttonbox, 0, 425, 600, 50) 92 | 93 | self._select_currents() 94 | self.show_all() 95 | self.present() 96 | 97 | def _base_changed(self, selection): 98 | (model, iter) = selection.get_selected() 99 | if iter is None: 100 | return 101 | 102 | self.quote_store.clear() 103 | self.current_base = model[iter][0] 104 | for quote in self.parent.coin.bases[self.current_base]: 105 | self.quote_store.append([quote]) 106 | 107 | self.quote_store.set_sort_column_id(0, Gtk.SortType.ASCENDING) 108 | self.view_quotes.set_cursor(0) 109 | 110 | def _quote_changed(self, selection): 111 | (model, iter) = selection.get_selected() 112 | if iter is None: 113 | return 114 | 115 | self.ex_store.clear() 116 | self.current_quote = model[iter][0] 117 | for exchange in self.parent.coin.bases[self.current_base][self.current_quote]: 118 | if exchange.active: 119 | self.ex_store.append([exchange.name, exchange.code]) 120 | 121 | self.ex_store.set_sort_column_id(0, Gtk.SortType.ASCENDING) 122 | self.view_exchanges.set_cursor(0) 123 | 124 | def _exchange_changed(self, selection): 125 | (model, iter) = selection.get_selected() 126 | if iter is None: 127 | return 128 | 129 | self.current_exchange = model[iter][1] 130 | 131 | ## 132 | # Select the currently active values and scroll them into view 133 | # 134 | def _select_currents(self): 135 | def _select_and_scroll(store, view, current_value): 136 | for row in store: 137 | if row[0] == current_value: 138 | view.set_cursor(row.path) 139 | view.scroll_to_cell(row.path) 140 | break 141 | 142 | _select_and_scroll( 143 | self.base_store, 144 | self.view_bases, 145 | self.parent.exchange.asset_pair.get("base"), 146 | ) 147 | _select_and_scroll( 148 | self.quote_store, 149 | self.view_quotes, 150 | self.parent.exchange.asset_pair.get("quote"), 151 | ) 152 | _select_and_scroll( 153 | self.ex_store, self.view_exchanges, self.parent.exchange.name 154 | ) 155 | 156 | def _update_indicator_close(self, widget): 157 | self._update_indicator(widget) 158 | self._close(widget) 159 | 160 | def _update_indicator(self, widget, *args): 161 | exchange = self.parent.coin.exchanges[self.current_exchange] 162 | self.parent.change_assets(self.current_base, self.current_quote, exchange) 163 | 164 | def _on_key_release(self, widget, ev, data=None): 165 | if ev.keyval == Gdk.KEY_Escape: 166 | self._close() 167 | 168 | def _close(self, widget=None): 169 | self.destroy() 170 | -------------------------------------------------------------------------------- /src/coin/alarm.py: -------------------------------------------------------------------------------- 1 | # Lets the user set an alert for a certain price point 2 | 3 | import desktop_notifier 4 | import pygame 5 | 6 | from coin.config import Config 7 | 8 | from gi.repository import Gdk, GdkPixbuf, Gtk 9 | from gi.repository.Gdk import Color 10 | 11 | 12 | class Alarm(object): 13 | def __init__(self, parent, ceil=None, floor=None): 14 | self.parent = parent 15 | self.app_name = parent.coin.config.get("app").get("name") 16 | self.ceil = ceil 17 | self.floor = floor 18 | self.active = False 19 | self.config = Config() 20 | 21 | def set_ceil(self, price): 22 | self.ceil = price 23 | self.active = True 24 | 25 | def set_floor(self, price): 26 | self.floor = price 27 | self.active = True 28 | 29 | def deactivate(self): 30 | self.ceil = None 31 | self.floor = None 32 | self.active = False 33 | 34 | ## 35 | # Checks the threshold property against a given price and 36 | # calls the notification function 37 | # 38 | def check(self, price): 39 | if self.ceil: 40 | if price > self.ceil: 41 | self.__notify(price, "rose above", self.ceil) 42 | return True 43 | 44 | if self.floor: 45 | if price < self.floor: 46 | self.__notify(price, "fell below", self.floor) 47 | return True 48 | 49 | return False 50 | 51 | ## 52 | # Creates a system notification. On Ubuntu 16.04 with Unity 7, this is a 53 | # translucent bubble, of which only one can be shown at the same time. 54 | # 55 | def __notify(self, price, direction, threshold): 56 | exchange_name = self.parent.exchange.name 57 | asset_name = self.parent.exchange.asset_pair.get("base") 58 | 59 | title = asset_name + " price alert: " + self.parent.symbol + str(price) 60 | message = ( 61 | "Price on " 62 | + exchange_name 63 | + " " 64 | + direction 65 | + " " 66 | + self.parent.symbol 67 | + str(threshold) 68 | ) 69 | 70 | if pygame.init(): 71 | pygame.mixer.music.load( 72 | self.config["project_root"] / "resources/ca-ching.wav" 73 | ) 74 | pygame.mixer.music.play() 75 | 76 | notifier = desktop_notifier.DesktopNotifier(app_name=self.config["app"]["name"]) 77 | notifier.send_sync( 78 | title, 79 | message, 80 | urgency=desktop_notifier.Urgency.Critical, 81 | icon=str(self.config["project_root"] / "resources/icon_32px.png"), 82 | ) 83 | 84 | 85 | class AlarmSettingsWindow(Gtk.Window): 86 | def __init__(self, parent, price): 87 | Gtk.Window.__init__(self, title="Set price alert") 88 | 89 | self.parent = parent 90 | self.set_keep_above(True) 91 | self.set_border_width(5) 92 | self.set_position(Gtk.WindowPosition.MOUSE) 93 | self.connect("key-release-event", self._on_key_release) 94 | 95 | self.grid = Gtk.Grid() 96 | self.grid.set_column_homogeneous(True) 97 | self.grid.set_row_homogeneous(True) 98 | 99 | label = Gtk.Label("Alert if the active price is") 100 | 101 | hbox = Gtk.Box(spacing=2) 102 | radio_over = Gtk.RadioButton.new_with_label(None, "above") 103 | radio_under = Gtk.RadioButton.new_with_label_from_widget(radio_over, "below") 104 | 105 | # Get existing alarm settings 106 | if self.parent.alarm.active: 107 | if self.parent.alarm.ceil: 108 | price = self.parent.alarm.ceil 109 | radio_over.set_active(True) 110 | elif self.parent.alarm.floor: 111 | price = self.parent.alarm.floor 112 | radio_under.set_active(True) 113 | 114 | entry_price = Gtk.Entry() 115 | entry_price.set_text(str(price)) 116 | entry_price.connect("activate", self._set_alarm, radio_over, entry_price) 117 | entry_price.connect("changed", self._strip_text) 118 | self.set_focus_child(entry_price) 119 | 120 | # Pack horizontally 121 | hbox.pack_start(label, False, False, 0) 122 | hbox.pack_start(radio_over, False, False, 0) 123 | hbox.pack_start(radio_under, False, False, 0) 124 | hbox.pack_start(entry_price, True, True, 0) 125 | 126 | # Set and Cancel buttons 127 | buttonbox = Gtk.Box(spacing=2) 128 | button_set = Gtk.Button("Set Alert") 129 | button_clear = Gtk.Button("Delete Alert") 130 | button_cancel = Gtk.Button("Close") 131 | button_set.connect("clicked", self._set_alarm, radio_over, entry_price) 132 | button_set.set_can_default(True) 133 | button_set.get_style_context().add_class(Gtk.STYLE_CLASS_SUGGESTED_ACTION) 134 | button_clear.connect("clicked", self._clear_alarm) 135 | button_cancel.connect("clicked", self._close) 136 | buttonbox.pack_start(button_set, True, True, 0) 137 | buttonbox.pack_start(button_clear, True, True, 0) 138 | buttonbox.pack_start(button_cancel, True, True, 0) 139 | 140 | # Display in content area 141 | self.grid.attach(hbox, 0, 0, 50, 50) 142 | self.grid.attach(buttonbox, 0, 50, 50, 50) 143 | self.add(self.grid) 144 | 145 | self.set_accept_focus(True) 146 | self.show_all() 147 | self.present() 148 | entry_price.grab_focus() # focus on entry field 149 | 150 | ## 151 | # This function strips all but numbers and decimal points from 152 | # the entry field. If the value cannot be converted to a float, 153 | # the text colour will turn red. 154 | # 155 | def _strip_text(self, widget): 156 | widget.modify_fg(Gtk.StateFlags.NORMAL, None) 157 | text = widget.get_text().strip() 158 | filtered_text = "".join([i for i in text if i in "0123456789."]) 159 | widget.set_text(filtered_text) 160 | try: 161 | float(filtered_text) 162 | except ValueError: 163 | widget.modify_fg(Gtk.StateFlags.NORMAL, Color(50000, 0, 0)) 164 | 165 | ## 166 | # Sets the alarm threshold 167 | # 168 | def _set_alarm(self, widget, radio_over, entry_price): 169 | above = radio_over.get_active() # if False, then 'under' must be True 170 | try: 171 | price = float(entry_price.get_text()) 172 | if above: 173 | self.parent.alarm.set_ceil(price) 174 | self.parent.alarm.set_floor(None) 175 | else: 176 | self.parent.alarm.set_floor(price) 177 | self.parent.alarm.set_ceil(None) 178 | 179 | self.destroy() 180 | 181 | # if user attempts to set an incorrect value, the dialog box stays 182 | # and the field is emptied 183 | except ValueError: 184 | entry_price.set_text("") 185 | entry_price.grab_focus() 186 | 187 | def _on_key_release(self, widget, ev, data=None): 188 | if ev.keyval == Gdk.KEY_Escape: 189 | self._close() 190 | 191 | def _clear_alarm(self, widget=None): 192 | self.parent.alarm.deactivate() 193 | self.destroy() 194 | 195 | def _close(self, widget=None): 196 | self.destroy() 197 | -------------------------------------------------------------------------------- /src/coin/indicator.py: -------------------------------------------------------------------------------- 1 | # The ticker AppIndicator item that sits in the tray 2 | # https://unity.ubuntu.com/projects/appindicators/ 3 | 4 | import logging 5 | from math import floor 6 | 7 | from gi.repository import GLib, Gtk 8 | 9 | from uuid import uuid1 10 | 11 | from coin.alarm import Alarm, AlarmSettingsWindow 12 | from coin.config import Config 13 | from coin.asset_selection import AssetSelectionWindow 14 | 15 | try: 16 | from gi.repository import AppIndicator3 as AppIndicator 17 | except ImportError: 18 | from gi.repository import AppIndicator 19 | 20 | 21 | REFRESH_TIMES = [3, 5, 10, 30, 60] # seconds 22 | 23 | CATEGORIES = [ 24 | ("cur", "Now"), 25 | ("bid", "Bid"), 26 | ("ask", "Ask"), 27 | ("high", "High"), 28 | ("low", "Low"), 29 | ("avg", "Avg"), 30 | ] 31 | 32 | 33 | class Indicator(object): 34 | def __init__(self, coin, exchange, asset_pair, refresh, default_label): 35 | self.config = Config() 36 | self.coin = coin # reference to main program 37 | self.alarm = Alarm(self) 38 | self.exchange = self.coin.exchanges[exchange](self) 39 | self.exchange.set_asset_pair_from_code(asset_pair) 40 | self.refresh_frequency = refresh 41 | self.default_label = default_label 42 | self.asset_selection_window = None 43 | self.alarm_settings_window = None 44 | 45 | self.prices = {} 46 | self.latest_response = 0 # helps with discarding outdated responses 47 | 48 | # initialisation and start of indicator and exchanges 49 | def start(self): 50 | self.indicator_widget = AppIndicator.Indicator.new( 51 | "Coindicator_" + str(uuid1()), 52 | str(self.exchange.icon), 53 | AppIndicator.IndicatorCategory.APPLICATION_STATUS, 54 | ) 55 | self.indicator_widget.set_status(AppIndicator.IndicatorStatus.ACTIVE) 56 | self.indicator_widget.set_ordering_index(0) 57 | self.indicator_widget.set_menu(self._menu()) 58 | if self.exchange.active: 59 | self._start_exchange() 60 | else: 61 | self._stop_exchange() 62 | 63 | # updates GUI menus with data stored in the object 64 | def update_gui(self): 65 | logging.debug("Updating GUI, last response was: " + str(self.latest_response)) 66 | 67 | self.symbol = self.exchange.symbol 68 | self.volumecurrency = self.exchange.volume_currency 69 | 70 | if self.prices.get(self.default_label): 71 | label = self.symbol + self.prices.get(self.default_label) 72 | else: 73 | label = "select default label" 74 | 75 | self.indicator_widget.set_label(label, label) 76 | 77 | for item, name in CATEGORIES: 78 | price_menu_item = self.price_menu_items.get(item) # get menu item 79 | 80 | # assigns prices to the corresponding menu items 81 | # if such a price value is returned from the exchange 82 | if self.prices.get(item): 83 | if item == self.default_label: 84 | price_menu_item.set_active(True) 85 | if self.alarm.active: 86 | if self.alarm.check(float(self.prices.get(item))): 87 | self.alarm.deactivate() 88 | 89 | price_menu_item.set_label( 90 | name + ":\t\t" + self.symbol + " " + self.prices.get(item) 91 | ) 92 | price_menu_item.show() 93 | # if no such price value is returned, hide the menu item 94 | else: 95 | price_menu_item.hide() 96 | 97 | # slightly different behaviour for volume menu item 98 | if self.prices.get("vol"): 99 | self.volume_item.set_label( 100 | "Vol:\t\t" + self.prices.get("vol") + " " + self.volumecurrency 101 | ) 102 | self.volume_item.show() 103 | else: 104 | self.volume_item.hide() 105 | 106 | # (re)starts the exchange logic and its timer 107 | def _start_exchange(self): 108 | state_string = ( 109 | self.exchange.name[0:8] 110 | + ":\t" 111 | + self.exchange.asset_pair.get("base") 112 | + " - " 113 | + self.exchange.asset_pair.get("quote") 114 | ) 115 | logging.debug( 116 | "Loading " + state_string + " (" + str(self.refresh_frequency) + "s)" 117 | ) 118 | 119 | # don't show any data until first response is in 120 | GLib.idle_add(self.indicator_widget.set_label, "loading", "loading") 121 | GLib.idle_add(self.state_item.set_label, state_string) 122 | for item in self.price_group: 123 | GLib.idle_add(item.set_active, False) 124 | GLib.idle_add(item.set_label, "loading" + "\u2026") 125 | 126 | self.volume_item.set_label("loading" + "\u2026") 127 | 128 | self._make_default_label(self.default_label) 129 | 130 | # start the timers and logic 131 | self.exchange.start() 132 | 133 | def _stop_exchange(self): 134 | GLib.idle_add(self.indicator_widget.set_label, "stopped", "stopped") 135 | self.exchange.stop() 136 | 137 | # promotes a price value to the main label position 138 | def _menu_make_label(self, widget, label): 139 | if widget.get_active(): 140 | self._make_default_label(label) 141 | self.coin.save_settings() 142 | 143 | def _make_default_label(self, label): 144 | self.default_label = label 145 | if self.price_menu_items.get(self.default_label): 146 | new_label = self.prices.get(label) 147 | if new_label: 148 | self.indicator_widget.set_label(self.symbol + new_label, new_label) 149 | 150 | def _menu(self): 151 | menu = Gtk.Menu() 152 | self.state_item = Gtk.MenuItem("loading" + "\u2026") 153 | menu.append(self.state_item) 154 | 155 | menu.append(Gtk.SeparatorMenuItem()) 156 | 157 | self.price_group = [] # so that a radio button can be set on the active one 158 | 159 | # hacky way to get every price item on the menu and filled 160 | self.price_menu_items = {} 161 | for price_type, name in CATEGORIES: 162 | self.price_menu_items[price_type] = Gtk.RadioMenuItem.new_with_label( 163 | self.price_group, "loading" + "\u2026" 164 | ) 165 | self.price_menu_items[price_type].connect( 166 | "toggled", self._menu_make_label, price_type 167 | ) 168 | self.price_group.append(self.price_menu_items.get(price_type)) 169 | menu.append(self.price_menu_items.get(price_type)) 170 | 171 | # trading volume display 172 | self.volume_item = Gtk.MenuItem("loading" + "\u2026") 173 | menu.append(self.volume_item) 174 | 175 | menu.append(Gtk.SeparatorMenuItem()) 176 | 177 | # settings menu 178 | self.config_menu = Gtk.MenuItem("Change Asset" + "\u2026") 179 | self.config_menu.connect("activate", self._settings) 180 | menu.append(self.config_menu) 181 | 182 | # recents menu 183 | self.recents_menu = Gtk.MenuItem("Recents") 184 | self.recents_menu.set_submenu(self._menu_recents()) 185 | menu.append(self.recents_menu) 186 | 187 | # refresh rate choice menu 188 | self.refresh_menu = Gtk.MenuItem("Refresh") 189 | self.refresh_menu.set_submenu(self._menu_refresh()) 190 | menu.append(self.refresh_menu) 191 | 192 | # alert menu 193 | self.alarm_menu = Gtk.MenuItem("Set Alert" + "\u2026") 194 | self.alarm_menu.connect("activate", self._alarm_settings) 195 | menu.append(self.alarm_menu) 196 | 197 | menu.append(Gtk.SeparatorMenuItem()) 198 | 199 | # menu to remove current indicator from app 200 | remove_item = Gtk.MenuItem("Remove Ticker") 201 | remove_item.connect("activate", self._remove) 202 | menu.append(remove_item) 203 | 204 | menu.show_all() 205 | 206 | return menu 207 | 208 | def _menu_recents(self): 209 | recent_menu = Gtk.Menu() 210 | 211 | if len(self.config["settings"].get("recent")) == 0: 212 | return 213 | 214 | for recent in self.config["settings"].get("recent"): 215 | exchange = self.coin.exchanges.get(recent.get("exchange")) 216 | if exchange is None: 217 | continue 218 | asset_pair = exchange.find_asset_pair_by_code( 219 | recent.get("asset_pair", "None") 220 | ) 221 | base = asset_pair.get("base", "None") 222 | quote = asset_pair.get("quote", "None") 223 | tabs = "\t" * ( 224 | floor(abs((len(exchange.name) - 8)) / 4) + 1 225 | ) # 1 tab for every 4 chars less than 8 226 | recent_string = exchange.name[0:8] + ":" + tabs + base + " - " + quote 227 | recent_item = Gtk.MenuItem(recent_string) 228 | recent_item.connect("activate", self._recent_change, base, quote, exchange) 229 | recent_menu.append(recent_item) 230 | 231 | recent_menu.show_all() 232 | return recent_menu 233 | 234 | def _recent_change(self, _widget, base, quote, exchange): 235 | self.change_assets(base, quote, exchange) 236 | 237 | def rebuild_recents_menu(self): 238 | if self.recents_menu.get_submenu(): 239 | self.recents_menu.get_submenu().destroy() 240 | self.recents_menu.set_submenu(self._menu_recents()) 241 | 242 | def _menu_refresh(self): 243 | refresh_menu = Gtk.Menu() 244 | 245 | group = [] 246 | for ri in REFRESH_TIMES: 247 | item = Gtk.RadioMenuItem.new_with_label(group, str(ri) + " sec") 248 | group.append(item) 249 | refresh_menu.append(item) 250 | 251 | if self.refresh_frequency == ri: 252 | item.set_active(True) 253 | item.connect("activate", self._menu_refresh_change, ri) 254 | 255 | return refresh_menu 256 | 257 | def _menu_refresh_change(self, widget, ri): 258 | if widget.get_active(): 259 | self.refresh_frequency = ri 260 | self.coin.save_settings() 261 | self.exchange.stop().start() 262 | 263 | def change_assets(self, base, quote, exchange): 264 | self.exchange.stop() 265 | 266 | if self.exchange is not exchange: 267 | self.exchange = exchange(self) 268 | 269 | self.exchange.set_asset_pair(base, quote) 270 | 271 | accessible_icon_string = self.exchange.name[0:8] + ":" + base + " to " + quote 272 | 273 | self.indicator_widget.set_icon_full( 274 | str(self.exchange.icon), accessible_icon_string 275 | ) 276 | 277 | self.coin.add_new_recent( 278 | self.exchange.asset_pair.get("pair"), self.exchange.code 279 | ) 280 | 281 | self.coin.save_settings() 282 | self._start_exchange() 283 | self.prices = {} # labels and prices may be different 284 | self.default_label = self.exchange.default_label 285 | 286 | def _remove(self, _widget): 287 | self.coin.remove_ticker(self) 288 | 289 | def _alarm_settings(self, _widget): 290 | self.alarm_settings_window = AlarmSettingsWindow( 291 | self, self.prices.get(self.default_label) 292 | ) 293 | 294 | def _settings(self, _widget): 295 | for indicator in self.coin.instances: 296 | if indicator.asset_selection_window: 297 | indicator.asset_selection_window.destroy() 298 | 299 | self.asset_selection_window = AssetSelectionWindow(self) 300 | -------------------------------------------------------------------------------- /src/coin/exchange.py: -------------------------------------------------------------------------------- 1 | # Abstract class that provides functionality for the various exchange classes 2 | import abc 3 | import logging 4 | import pickle 5 | import time 6 | 7 | from gi.repository import GLib 8 | from coin.coingecko_client import CoinGeckoClient 9 | 10 | from coin.config import Config 11 | from coin.downloader import DownloadCommand 12 | from coin.error import Error 13 | 14 | CURRENCY = { 15 | "usd": "$", 16 | "eur": "€", 17 | "btc": "B", 18 | "thb": "฿", 19 | "gbp": "£", 20 | "eth": "Ξ", 21 | "cad": "$", 22 | "jpy": "¥", 23 | "cny": "元", 24 | "inr": "₹", 25 | } 26 | 27 | CATEGORY = { 28 | "cur": "Now", 29 | "bid": "Bid", 30 | "high": "High", 31 | "low": "Low", 32 | "ask": "Ask", 33 | "vol": "Vol", 34 | "first": "First", 35 | "avg": "Avg", 36 | } 37 | 38 | 39 | class classproperty: 40 | def __init__(self, func): 41 | self.fget = func 42 | 43 | def __get__(self, _instance, owner): 44 | return self.fget(owner) 45 | 46 | 47 | class Exchange(abc.ABC): 48 | active = True 49 | name = "Must be overwritten" 50 | code = "Must be overwritten" 51 | default_label = "Must be overwritten" 52 | 53 | def __init__(self, indicator=None): 54 | self.indicator = indicator 55 | self.downloader = indicator.coin.downloader 56 | self.timeout_id = None 57 | self.error = Error(self) 58 | self.started = False 59 | self.asset_pair = {} 60 | self.config = Config() 61 | 62 | ## 63 | # Abstract methods to be overwritten by the child classes 64 | # 65 | @classmethod 66 | @abc.abstractmethod 67 | def _get_discovery_url(cls): 68 | pass 69 | 70 | @classmethod 71 | @abc.abstractmethod 72 | def _parse_discovery(cls, data): 73 | pass 74 | 75 | @abc.abstractmethod 76 | def _get_ticker_url(self): 77 | pass 78 | 79 | @abc.abstractmethod 80 | def _parse_ticker(self, data): 81 | pass 82 | 83 | @property 84 | def currency(self): 85 | return self.asset_pair.get("quote").lower() 86 | 87 | @property 88 | def symbol(self): 89 | return CURRENCY.get(self.currency, self.currency.upper()) 90 | 91 | @property 92 | def icon(self) -> str: 93 | # set icon for asset if it exists 94 | asset = self.asset_pair.get("base", "").lower() 95 | asset_dir = Config()["icon_dir"] 96 | if (asset_dir / f"{asset}.png").exists(): 97 | return asset_dir / f"{asset}.png" 98 | else: 99 | fetched = CoinGeckoClient().get_icon(asset) 100 | if fetched is not None: 101 | return fetched 102 | 103 | return asset_dir / "unknown-coin.png" 104 | 105 | @property 106 | def volume_currency(self): 107 | return self.asset_pair.get("volumecurrency", self.asset_pair.get("base")) 108 | 109 | def set_asset_pair(self, base, quote): 110 | for ap in self.asset_pairs: 111 | if ( 112 | ap.get("base").upper() == base.upper() 113 | and ap.get("quote").upper() == quote.upper() 114 | ): 115 | self.asset_pair = ap 116 | break 117 | 118 | if not self.asset_pair: 119 | logging.warning( 120 | "User.conf specifies unavailable asset pair, trying default. \ 121 | Run Asset Discovery again." 122 | ) 123 | self.asset_pair = ap 124 | 125 | def set_asset_pair_from_code(self, code): 126 | for ap in self.asset_pairs: 127 | if ap.get("pair").upper() == code.upper(): 128 | self.asset_pair = ap 129 | break 130 | 131 | if not self.asset_pair: 132 | logging.warning( 133 | "User.conf specifies unavailable asset pair, trying default. \ 134 | Run Asset Discovery again." 135 | ) 136 | self.asset_pair = {} 137 | 138 | @classmethod 139 | def find_asset_pair_by_code(cls, code): 140 | for ap in cls.asset_pairs: 141 | if ap.get("pair") == code: 142 | return ap 143 | 144 | return {} 145 | 146 | @classmethod 147 | def find_asset_pair(cls, quote, base): 148 | for ap in cls.asset_pairs: 149 | if ap.get("quote") == quote and ap.get("base") == base: 150 | return ap 151 | 152 | return {} 153 | 154 | @classproperty 155 | def datafile(cls): 156 | config = Config() 157 | return config["user_data_dir"] / f"cache/{cls.code}.cache" 158 | 159 | ## 160 | # Loads asset pairs from the config files or, 161 | # failing that, from the hard-coded lines 162 | # 163 | @classproperty 164 | def asset_pairs(cls): 165 | try: 166 | with open(cls.datafile, "rb") as stream: 167 | asset_pairs = pickle.load(stream) 168 | return asset_pairs if asset_pairs else [] 169 | 170 | except IOError: 171 | # Faulty data file, return empty array 172 | return [] 173 | 174 | ## 175 | # Saves asset pairs to disk 176 | # 177 | @classmethod 178 | def store_asset_pairs(cls, asset_pairs): 179 | try: 180 | with open(cls.datafile, "wb") as stream: 181 | pickle.dump(asset_pairs, stream) 182 | except IOError: 183 | logging.error("Could not write to data file %s" % cls.datafile) 184 | 185 | ## 186 | # Discovers assets from the exchange's API url retrieved 187 | # through the instance-specific method _get_discovery_url() 188 | # 189 | @classmethod 190 | def discover_assets(cls, downloader, callback): 191 | if cls._get_discovery_url() is None: 192 | cls.store_asset_pairs(cls._parse_discovery(None)) 193 | else: 194 | command = DownloadCommand(cls._get_discovery_url(), callback) 195 | downloader.execute(command, cls._handle_discovery_result) 196 | 197 | ## 198 | # Deals with the result from the discovery HTTP request 199 | # Should probably be merged with _handle_result() later 200 | # 201 | @classmethod 202 | def _handle_discovery_result(cls, command): 203 | logging.debug("Response from %s: %s" % (command.url, command.error)) 204 | 205 | if command.error: 206 | cls._handle_discovery_error( 207 | f"{cls.name}: API server {command.url}\ 208 | returned an error: {command.error}" 209 | ) 210 | 211 | if command.response: 212 | data = command.response 213 | 214 | if data.status_code in [301, 302]: 215 | # hooks will be called even when requests is following a redirect 216 | # but we don't want to print any error messages here 217 | return 218 | 219 | if data.status_code != 200: 220 | cls._handle_discovery_error( 221 | f"API server {command.url} returned \ 222 | an error: {str(data.status_code)}" 223 | ) 224 | 225 | try: 226 | result = data.json() 227 | asset_pairs = cls._parse_discovery(result) 228 | cls.store_asset_pairs(asset_pairs) 229 | except Exception as e: 230 | cls._handle_discovery_error(str(e)) 231 | 232 | command.callback() # update the asset menus of all instances 233 | 234 | @classmethod 235 | def _handle_discovery_error(cls, msg): 236 | logging.warn("Asset Discovery: %s" % msg) 237 | 238 | ## 239 | # Start exchange 240 | # 241 | def start(self, error_refresh=None): 242 | if not self.started: 243 | self._check_price() 244 | 245 | self.started = True 246 | refresh = error_refresh if error_refresh else self.indicator.refresh_frequency 247 | self.timeout_id = GLib.timeout_add_seconds(refresh, self._check_price) 248 | 249 | return self 250 | 251 | ## 252 | # Stop exchange, reset errors 253 | # 254 | def stop(self): 255 | if self.timeout_id: 256 | GLib.source_remove(self.timeout_id) 257 | 258 | self.started = False 259 | self.indicator.alarm.deactivate() 260 | self.error.reset() 261 | 262 | return self 263 | 264 | ## 265 | # Restarts the exchange. This is necessary for restoring normal frequency as 266 | # False must be returned for the restart operation to be done only once 267 | # 268 | def restart(self): 269 | self.start() 270 | return False 271 | 272 | ## 273 | # This function is called frequently to get price updates from the API 274 | # 275 | def _check_price(self): 276 | self.pair = self.asset_pair.get("pair") 277 | timestamp = time.time() 278 | command = DownloadCommand(self._get_ticker_url(), self.indicator.update_gui) 279 | command.timestamp = timestamp 280 | command.error = self._handle_error 281 | command.validation = self.asset_pair 282 | self.downloader.execute(command, self._handle_result) 283 | 284 | logging.debug("Request with TS: " + str(timestamp)) 285 | if not self.error.is_ok(): 286 | self.timeout_id = None 287 | 288 | return self.error.is_ok() # continues the timer if there are no errors 289 | 290 | def _handle_error(self, error): 291 | self.error.log(str(error)) 292 | self.error.increment() 293 | 294 | # def _handle_result(self, data, validation, timestamp): 295 | def _handle_result(self, command): 296 | if not command.response: 297 | logging.warning("No response from API server") 298 | return 299 | data = command.response 300 | # Check to see if the returning response is still valid 301 | # (user may have changed exchanges before the request finished) 302 | if not self.started: 303 | logging.warning("Discarding packet for inactive exchange") 304 | return 305 | 306 | if command.validation is not self.asset_pair: # we've already moved on. 307 | logging.warning("Discarding packet for wrong asset pair or exchange") 308 | return 309 | 310 | # also check if a newer response hasn't already been returned 311 | if ( 312 | command.timestamp < self.indicator.latest_response 313 | ): # this is an older request 314 | logging.warning("Discarding outdated packet") 315 | return 316 | 317 | if data.status_code != 200: 318 | self._handle_error("API server returned an error: " + str(data.status_code)) 319 | return 320 | 321 | try: 322 | asset = data.json() 323 | except Exception: 324 | # Before, a KeyError happened when an asynchronous response comes in 325 | # for a previously selected asset pair (see upstream issue #27) 326 | self._handle_error("Invalid response for " + str(self.pair)) 327 | return 328 | 329 | results = self._parse_ticker(asset) 330 | self.indicator.latest_response = command.timestamp 331 | logging.debug( 332 | "Response comes in with timestamp %s, last response at %s" 333 | % (str(command.timestamp), str(self.indicator.latest_response)) 334 | ) 335 | 336 | for item in CATEGORY: 337 | if results.get(item): 338 | self.indicator.prices[item] = self._decimal_auto(results.get(item)) 339 | 340 | self.error.reset() 341 | 342 | GLib.idle_add(command.callback) 343 | 344 | ## 345 | # Rounds a number to a meaningful number of decimal places 346 | # and returns it as a string 347 | # 348 | def _decimal_auto(self, number): 349 | number = float(number) 350 | max_decimals = self.config["settings"].get("max_decimals", 8) 351 | significant_digits = self.config["settings"].get("significant_digits", 3) 352 | 353 | for decimals in range(0, max_decimals + 1): 354 | if number * (10**decimals) >= 10 ** (significant_digits - 1): 355 | break 356 | 357 | return ("{0:." + str(decimals) + "f}").format(number) 358 | -------------------------------------------------------------------------------- /src/coin/coin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Coin Price Indicator 3 | # 4 | # Nil Gradisnik 5 | # Sander Van de Moortel 6 | # 7 | 8 | import os 9 | 10 | import gi 11 | 12 | gi.require_version("Gtk", "3.0") 13 | gi.require_version("Gdk", "3.0") 14 | gi.require_version("GdkPixbuf", "2.0") 15 | gi.require_version("AppIndicator3", "0.1") 16 | os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" 17 | 18 | import importlib 19 | import logging 20 | import signal 21 | from pathlib import Path 22 | 23 | import desktop_notifier 24 | import yaml 25 | from gi.repository import Gtk 26 | 27 | from coin.about import AboutWindow 28 | from coin.downloader import AsyncDownloadService, DownloadService 29 | from coin.indicator import Indicator 30 | from coin.plugin_selection import PluginSelectionWindow 31 | from coin.coingecko_client import CoinGeckoClient 32 | from coin.config import Config 33 | 34 | try: 35 | from gi.repository import AppIndicator3 as AppIndicator 36 | except ImportError: 37 | from gi.repository import AppIndicator 38 | 39 | 40 | log_level = getattr(logging, os.environ.get("COIN_LOGLEVEL", "ERROR")) 41 | logging.basicConfig( 42 | datefmt="%H:%M:%S", 43 | level=log_level, 44 | format="[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s", 45 | ) 46 | logging.getLogger("urllib3").setLevel(logging.ERROR) 47 | 48 | 49 | class Coin: 50 | def __init__(self, config: Config): 51 | self.config = config 52 | self.downloader = AsyncDownloadService() 53 | self.assets = {} 54 | self.coingecko_client = CoinGeckoClient() 55 | self._load_exchanges() 56 | self._load_assets() 57 | self._load_settings() 58 | self._start_main() 59 | 60 | self.instances = [] 61 | self.discoveries = 0 62 | self._add_many_indicators(self.config["settings"].get("tickers")) 63 | self._start_gui() 64 | 65 | # Load exchange 'plug-ins' from exchanges dir 66 | def _load_exchanges(self): 67 | dirfiles = (Path(__file__).parent / "exchanges").glob("*.py") 68 | plugins = [ 69 | f.name[:-3] for f in dirfiles if f.exists() and f.name != "__init__.py" 70 | ] 71 | plugins.sort() 72 | 73 | self.exchanges = {} 74 | for plugin in plugins: 75 | class_name = plugin.capitalize() 76 | exchange_class = getattr( 77 | importlib.import_module("coin.exchanges." + plugin), class_name 78 | ) 79 | self.exchanges[exchange_class.code] = exchange_class 80 | 81 | # Creates a structure of available assets (from_currency > to_currency > exchange) 82 | def _load_assets(self): 83 | self.assets = {} 84 | 85 | for exchange in self.exchanges.values(): 86 | if exchange.active: 87 | if not exchange.asset_pairs: 88 | exchange.discover_assets(DownloadService(), lambda *args: None) 89 | self.assets[exchange.code] = exchange.asset_pairs 90 | 91 | # inverse the hierarchy for easier asset selection 92 | bases = {} 93 | for exchange in self.assets.keys(): 94 | for asset_pair in self.assets.get(exchange): 95 | base = asset_pair.get("base") 96 | quote = asset_pair.get("quote") 97 | 98 | if base not in bases: 99 | bases[base] = {} 100 | 101 | if quote not in bases[base]: 102 | bases[base][quote] = [] 103 | 104 | bases[base][quote].append(self.exchanges[exchange]) 105 | 106 | self.bases = bases 107 | 108 | # load instances 109 | def _load_settings(self): 110 | for plugin in self.config["settings"].get("plugins", {}): 111 | for code, active in plugin.items(): 112 | exchange = self.exchanges.get(code) 113 | if exchange is not None: 114 | exchange.active = active 115 | 116 | # set defaults if settings not defined 117 | if not self.config["settings"].get("tickers"): 118 | first_exchange = next(iter(self.exchanges.values())) 119 | first_code = first_exchange.code 120 | 121 | # TODO work without defining a default 122 | self.config["settings"]["tickers"] = [ 123 | { 124 | "exchange": first_code, 125 | "asset_pair": self.assets[first_code][0].get("pair"), 126 | "refresh": 3, 127 | "default_label": first_exchange.default_label, 128 | } 129 | ] 130 | 131 | if not self.config["settings"].get("recent"): 132 | self.config["settings"]["recent"] = [] 133 | 134 | # saves settings for each ticker 135 | def save_settings(self): 136 | tickers = [] 137 | for instance in self.instances: 138 | ticker = { 139 | "exchange": instance.exchange.code, 140 | "asset_pair": instance.exchange.asset_pair.get("pair"), 141 | "refresh": instance.refresh_frequency, 142 | "default_label": instance.default_label, 143 | } 144 | tickers.append(ticker) 145 | self.config["settings"]["tickers"] = tickers 146 | 147 | plugins = [] 148 | for exchange in self.exchanges.values(): 149 | plugin = {exchange.code: exchange.active} 150 | plugins.append(plugin) 151 | 152 | self.config["settings"]["plugins"] = plugins 153 | 154 | try: 155 | with open(self.config["user_settings_file"], "w") as handle: 156 | yaml.dump(self.config["settings"], handle, default_flow_style=False) 157 | except IOError: 158 | logging.error("Settings file not writable") 159 | 160 | # Add a new base to the recents settings, and push the last one off the edge 161 | def add_new_recent(self, asset_pair, exchange_code): 162 | for recent in self.config["settings"]["recent"]: 163 | if ( 164 | recent.get("asset_pair") == asset_pair 165 | and recent.get("exchange") == exchange_code 166 | ): 167 | self.config["settings"]["recent"].remove(recent) 168 | 169 | self.config["settings"]["recent"] = self.config["settings"]["recent"][0:4] 170 | 171 | new_recent = {"asset_pair": asset_pair, "exchange": exchange_code} 172 | 173 | self.config["settings"]["recent"].insert(0, new_recent) 174 | 175 | for instance in self.instances: 176 | instance.rebuild_recents_menu() 177 | 178 | # Start the main indicator icon and its menu 179 | def _start_main(self): 180 | logging.info( 181 | "%s v%s running!" 182 | % ( 183 | self.config.get("app").get("name"), 184 | importlib.metadata.version("coindicator"), 185 | ) 186 | ) 187 | 188 | self.icon = self.config["project_root"] / "resources/icon_32px.png" 189 | self.main_item = AppIndicator.Indicator.new( 190 | self.config.get("app").get("name"), 191 | str(self.icon), 192 | AppIndicator.IndicatorCategory.APPLICATION_STATUS, 193 | ) 194 | self.main_item.set_status(AppIndicator.IndicatorStatus.ACTIVE) 195 | self.main_item.set_ordering_index(0) 196 | self.main_item.set_menu(self._menu()) 197 | 198 | def _start_gui(self): 199 | signal.signal(signal.SIGINT, Gtk.main_quit) # ctrl+c exit 200 | Gtk.main() 201 | 202 | # Program main menu 203 | def _menu(self): 204 | menu = Gtk.Menu() 205 | 206 | self.add_item = Gtk.MenuItem.new_with_label("Add Ticker") 207 | self.discover_item = Gtk.MenuItem.new_with_label("Discover Assets") 208 | self.plugin_item = Gtk.MenuItem.new_with_label("Plugins" + "\u2026") 209 | self.about_item = Gtk.MenuItem.new_with_label("About") 210 | self.quit_item = Gtk.MenuItem.new_with_label("Quit") 211 | 212 | self.add_item.connect("activate", self._add_ticker) 213 | self.discover_item.connect("activate", self._discover_assets) 214 | self.plugin_item.connect("activate", self._select_plugins) 215 | self.about_item.connect("activate", self._about) 216 | self.quit_item.connect("activate", self._quit_all) 217 | 218 | menu.append(self.add_item) 219 | menu.append(self.discover_item) 220 | menu.append(self.plugin_item) 221 | menu.append(self.about_item) 222 | menu.append(Gtk.SeparatorMenuItem()) 223 | menu.append(self.quit_item) 224 | menu.show_all() 225 | 226 | return menu 227 | 228 | # Adds a ticker and starts it 229 | def _add_indicator(self, settings): 230 | exchange = settings.get("exchange") 231 | refresh = settings.get("refresh") 232 | asset_pair = settings.get("asset_pair") 233 | default_label = settings.get("default_label") 234 | indicator = Indicator(self, exchange, asset_pair, refresh, default_label) 235 | self.instances.append(indicator) 236 | indicator.start() 237 | return indicator 238 | 239 | # adds many tickers 240 | def _add_many_indicators(self, tickers): 241 | for ticker in tickers: 242 | self._add_indicator(ticker) 243 | 244 | # Menu item to add a ticker 245 | def _add_ticker(self, widget): 246 | i = self._add_indicator( 247 | self.config["settings"].get("tickers")[ 248 | len(self.config["settings"].get("tickers")) - 1 249 | ] 250 | ) 251 | i._settings(widget) 252 | self.save_settings() 253 | 254 | # Remove ticker 255 | def remove_ticker(self, indicator): 256 | if len(self.instances) == 1: # is it the last ticker? 257 | Gtk.main_quit() # then quit entirely 258 | else: # otherwise just remove this one 259 | indicator.exchange.stop() 260 | indicator.indicator_widget.set_status(AppIndicator.IndicatorStatus.PASSIVE) 261 | self.instances.remove(indicator) 262 | self.save_settings() 263 | 264 | # Menu item to download any new assets from the exchanges 265 | def _discover_assets(self, _widget): 266 | # Don't do anything if there are no active exchanges with discovery 267 | if ( 268 | len([ex for ex in self.exchanges.values() if ex.active and ex.discovery]) 269 | == 0 270 | ): 271 | return 272 | 273 | self.main_item.set_icon_full( 274 | str(self.config.get("project_root") / "resources/loading.png"), 275 | "Discovering assets", 276 | ) 277 | 278 | for indicator in self.instances: 279 | if indicator.asset_selection_window: 280 | indicator.asset_selection_window.destroy() 281 | 282 | for exchange in self.exchanges.values(): 283 | if exchange.active and exchange.discovery: 284 | exchange.discover_assets(self.downloader, self.update_assets) 285 | 286 | # When discovery completes, reload currencies and rebuild menus of all instances 287 | def update_assets(self): 288 | self.discoveries += 1 289 | if self.discoveries < len( 290 | [ex for ex in self.exchanges.values() if ex.active and ex.discovery] 291 | ): 292 | return # wait until all active exchanges with discovery finish discovery 293 | 294 | self.discoveries = 0 295 | self._load_assets() 296 | 297 | notifier = desktop_notifier.DesktopNotifier(app_name=self.config["app"]["name"]) 298 | notifier.send_sync( 299 | self.config.get("app").get("name"), 300 | "Finished discovering new assets", 301 | urgency=desktop_notifier.Urgency.Normal, 302 | timeout=2000, 303 | icon=self.icon, 304 | ) 305 | 306 | self.main_item.set_icon_full(str(self.icon), "App icon") 307 | 308 | def _select_plugins(self, _widget): 309 | PluginSelectionWindow(self) 310 | 311 | # Menu item to remove all tickers and quits the application 312 | def _quit_all(self, _widget): 313 | Gtk.main_quit() 314 | 315 | def plugins_updated(self): 316 | self._load_assets() 317 | for instance in self.instances: 318 | instance.start() # will stop exchange if inactive 319 | 320 | self.save_settings() 321 | 322 | def _about(self, _widget): 323 | AboutWindow(self.config).show() 324 | 325 | 326 | def main(): 327 | project_root = Path(__file__).parent 328 | user_data_dir = Path(os.environ["HOME"]) / ".config/coindicator" 329 | user_data_dir.mkdir(exist_ok=True) 330 | 331 | icon_dir = user_data_dir / "coin-icons" 332 | icon_dir.mkdir(exist_ok=True) 333 | 334 | cache_dir = user_data_dir / "cache" 335 | cache_dir.mkdir(exist_ok=True) 336 | 337 | config_file = project_root / "config.yaml" 338 | config_data = yaml.load(config_file.open(), Loader=yaml.SafeLoader) 339 | 340 | user_settings_file = user_data_dir / "user.conf" 341 | settings = {} 342 | if user_settings_file.exists(): 343 | settings = yaml.load(user_settings_file.open(), Loader=yaml.SafeLoader) 344 | 345 | config = Config(config_data) 346 | config["project_root"] = project_root 347 | config["user_data_dir"] = user_data_dir 348 | config["icon_dir"] = icon_dir 349 | config["cache_dir"] = cache_dir 350 | 351 | config["user_settings_file"] = user_settings_file 352 | config["settings"] = settings 353 | 354 | Coin(config) 355 | 356 | 357 | if __name__ == "__main__": 358 | main() 359 | --------------------------------------------------------------------------------