diff --git a/cmds/mmnode-ticker b/cmds/mmnode-ticker new file mode 100755 index 0000000..d6e91fb --- /dev/null +++ b/cmds/mmnode-ticker @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2022 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools + +""" +mmnode-ticker: Display price information for cryptocurrency and other assets +""" + +from mmgen.main import launch + +launch('ticker',package='mmgen_node_tools') diff --git a/mmgen_node_tools/Misc.py b/mmgen_node_tools/Misc.py new file mode 100755 index 0000000..999b68b --- /dev/null +++ b/mmgen_node_tools/Misc.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2022 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools + +""" +mmgen_node_tools.Misc: miscellaneous data and functions for the MMGen Node Tools suite +""" + +curl_exit_codes = { + 1: 'Unsupported protocol. This build of curl has no support for this protocol', + 2: 'Failed to initialize', + 3: 'URL malformed. The syntax was not correct', + 4: 'A feature or option that was needed to perform the desired request was not enabled or was explicitly disabled at build-time. To make curl able to do this, you probably need another build of libcurl!', + 5: 'Couldn’t resolve proxy. The given proxy host could not be resolved', + 6: 'Couldn’t resolve host. The given remote host was not resolved', + 7: 'Failed to connect to host', + 8: 'Weird server reply. The server sent data curl couldn’t parse', + 9: 'FTP access denied. The server denied login or denied access to the particular resource or directory you wanted to reach. Most often you tried to change to a directory that doesn’t exist on the server', + 10: 'FTP accept failed. While waiting for the server to connect back when an active FTP session is used, an error code was sent over the control connection or similar', + 11: 'FTP weird PASS reply. Curl couldn’t parse the reply sent to the PASS request', + 12: 'During an active FTP session while waiting for the server to connect back to curl, the timeout expired', + 13: 'FTP weird PASV reply, Curl couldn’t parse the reply sent to the PASV request', + 14: 'FTP weird 227 format. Curl couldn’t parse the 227-line the server sent', + 15: 'FTP can’t get host. Couldn’t resolve the host IP we got in the 227-line', + 16: 'HTTP/2 error. A problem was detected in the HTTP2 framing layer. This is somewhat generic and can be one out of several problems, see the error message for details', + 17: 'FTP couldn’t set binary. Couldn’t change transfer method to binary', + 18: 'Partial file. Only a part of the file was transferred', + 19: 'FTP couldn’t download/access the given file, the RETR (or similar) command failed', + 21: 'FTP quote error. A quote command returned error from the server', + 22: 'HTTP page not retrieved. The requested url was not found or returned another error with the HTTP error code being 400 or above. This return code only appears if -f, --fail is used', + 23: 'Write error. Curl couldn’t write data to a local filesystem or similar', + 25: 'FTP couldn’t STOR file. The server denied the STOR operation, used for FTP uploading', + 26: 'Read error. Various reading problems', + 27: 'Out of memory. A memory allocation request failed', + 28: 'Operation timeout. The specified time-out period was reached according to the conditions', + 30: 'FTP PORT failed. The PORT command failed. Not all FTP servers support the PORT command, try doing a transfer using PASV instead!', + 31: 'FTP couldn’t use REST. The REST command failed. This command is used for resumed FTP transfers', + 33: 'HTTP range error. The range "command" didn’t work', + 34: 'HTTP post error. Internal post-request generation error', + 35: 'SSL connect error. The SSL handshaking failed', + 36: 'Bad download resume. Couldn’t continue an earlier aborted download', + 37: 'FILE couldn’t read file. Failed to open the file. Permissions?', + 38: 'LDAP cannot bind. LDAP bind operation failed', + 39: 'LDAP search failed', + 41: 'Function not found. A required LDAP function was not found', + 42: 'Aborted by callback. An application told curl to abort the operation', + 43: 'Internal error. A function was called with a bad parameter', + 45: 'Interface error. A specified outgoing interface could not be used', + 47: 'Too many redirects. When following redirects, curl hit the maximum amount', + 48: 'Unknown option specified to libcurl. This indicates that you passed a weird option to curl that was passed on to libcurl and rejected. Read up in the manual!', + 49: 'Malformed telnet option', + 51: 'The peer’s SSL certificate or SSH MD5 fingerprint was not OK', + 52: 'The server didn’t reply anything, which here is considered an error', + 53: 'SSL crypto engine not found', + 54: 'Cannot set SSL crypto engine as default', + 55: 'Failed sending network data', + 56: 'Failure in receiving network data', + 58: 'Problem with the local certificate', + 59: 'Couldn’t use specified SSL cipher', + 60: 'Peer certificate cannot be authenticated with known CA certificates', + 61: 'Unrecognized transfer encoding', + 62: 'Invalid LDAP URL', + 63: 'Maximum file size exceeded', + 64: 'Requested FTP SSL level failed', + 65: 'Sending the data requires a rewind that failed', + 66: 'Failed to initialise SSL Engine', + 67: 'The user name, password, or similar was not accepted and curl failed to log in', + 68: 'File not found on TFTP server', + 69: 'Permission problem on TFTP server', + 70: 'Out of disk space on TFTP server', + 71: 'Illegal TFTP operation', + 72: 'Unknown TFTP transfer ID', + 73: 'File already exists (TFTP)', + 74: 'No such user (TFTP)', + 75: 'Character conversion failed', + 76: 'Character conversion functions required', + 77: 'Problem with reading the SSL CA cert (path? access rights?)', + 78: 'The resource referenced in the URL does not exist', + 79: 'An unspecified error occurred during the SSH session', + 80: 'Failed to shut down the SSL connection', + 82: 'Could not load CRL file, missing or wrong format (added in 7.19.0)', + 83: 'Issuer check failed (added in 7.19.0)', + 84: 'The FTP PRET command failed', + 85: 'RTSP: mismatch of CSeq numbers', + 86: 'RTSP: mismatch of Session Identifiers', + 87: 'unable to parse FTP file list', + 88: 'FTP chunk callback reported error', + 89: 'No connection available, the session will be queued', + 90: 'SSL public key does not matched pinned public key', + 91: 'Invalid SSL certificate status', + 92: 'Stream error in HTTP/2 framing layer', + 93: 'An API function was called from inside a callback', + 94: 'An authentication function returned an error', + 95: 'A problem was detected in the HTTP/3 layer. This is somewhat generic and can be one out of several problems, see the error message for details', + 96: 'QUIC connection error. This error may be caused by an SSL library error. QUIC is the protocol used for HTTP/3 transfers', +} diff --git a/mmgen_node_tools/Ticker.py b/mmgen_node_tools/Ticker.py new file mode 100755 index 0000000..cafe815 --- /dev/null +++ b/mmgen_node_tools/Ticker.py @@ -0,0 +1,777 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2022 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools + +""" +mmgen_node_tools.Ticker: Display price information for cryptocurrency and other assets +""" + +api_host = 'api.coinpaprika.com' +api_url = f'https://{api_host}/v1/ticker' +ratelimit = 240 +btc_ratelimit = 10 + +# We use deprecated coinpaprika ‘ticker’ API for now because it returns ~45% less data. +# Old ‘ticker’ API (/v1/ticker): data['BTC']['price_usd'] +# New ‘tickers’ API (/v1/tickers): data['BTC']['quotes']['USD']['price'] + +# Possible alternatives: +# - https://min-api.cryptocompare.com/data/pricemultifull?fsyms=BTC,LTC&tsyms=USD,EUR + +import sys,os,time,json,yaml +from subprocess import run,PIPE,CalledProcessError +from decimal import Decimal +from collections import namedtuple +from mmgen.opts import opt +from mmgen.globalvars import g +from mmgen.color import * +from mmgen.util import die,fmt_list,msg,msg_r,Msg,do_pager,suf,fmt + +homedir = os.getenv('HOME') +cachedir = os.path.join(homedir,'.cache','mmgen-node-tools') +cfg_fn = 'ticker-cfg.yaml' +portfolio_fn = 'ticker-portfolio.yaml' + +def assets_list_gen(cfg_in): + for k,v in cfg_in.cfg['assets'].items(): + yield('') + yield(k.upper()) + for e in v: + yield(' {:4s} {}'.format(*e.split('-',1))) + +def gen_data(data): + """ + Filter the raw data and return it as a dict keyed by the IDs of the assets + we want to display. + + Add dummy entry for USD and entry for user-specified asset, if any. + + Since symbols in source data are not guaranteed to be unique (e.g. XAG), we + must search the data twice: first for unique IDs, then for symbols while + checking for duplicates. + """ + + def dup_sym_errmsg(dup_sym): + return ( + f'The symbol {dup_sym!r} is shared by the following assets:\n' + + '\n ' + '\n '.join(d['id'] for d in data if d['symbol'] == dup_sym) + + '\n\nPlease specify the asset by one of the full IDs listed above\n' + + f'instead of {dup_sym!r}' + ) + + def check_assets_found(wants,found,keys=['symbol','id']): + error = False + for k in keys: + missing = wants[k] - found[k] + if missing: + msg( + ('The following IDs were not found in source data:\n{}' if k == 'id' else + 'The following symbols could not be resolved:\n{}').format( + fmt_list(missing,fmt='col',indent=' ') + )) + error = True + if error: + die(1,'Missing data, exiting') + + rows_want = { + 'id': {r.id for r in cfg.rows if getattr(r,'id',None)} - {'usd-us-dollar'}, + 'symbol': {r.symbol for r in cfg.rows if isinstance(r,tuple) and r.id is None} - {'USD'}, + } + usr_assets = cfg.usr_rows + cfg.usr_columns + tuple(c for c in (cfg.query or ()) if c) + usr_wants = { + 'id': ( + {a.id for a in usr_assets if a.id} - + {a.id for a in usr_assets if a.amount and a.id} - {'usd-us-dollar'} ) + , + 'symbol': ( + {a.symbol for a in usr_assets if not a.id} - + {a.symbol for a in usr_assets if a.amount} - {'USD'} ), + } + + found = { 'id': set(), 'symbol': set() } + + for k in ['id','symbol']: + wants = rows_want[k] | usr_wants[k] + if wants: + for d in data: + if d[k] in wants: + if d[k] in found[k]: + die(1,dup_sym_errmsg(d[k])) + yield (d['id'],d) + found[k].add(d[k]) + if k == 'id' and len(found[k]) == len(wants): + break + + for d in data: + if d['id'] == 'btc-bitcoin': + btcusd = Decimal(d['price_usd']) + break + + for asset in (cfg.usr_rows + cfg.usr_columns): + if asset.amount: + """ + User-supplied rate overrides rate from source data. + """ + _id = asset.id or f'{asset.symbol}-user-asset-{asset.symbol}'.lower() + yield ( _id, { + 'symbol': asset.symbol, + 'id': _id, + 'price_usd': str(Decimal(1/asset.amount)), + 'price_btc': str(Decimal(1/asset.amount/btcusd)), + 'last_updated': int(now), + }) + + yield ('usd-us-dollar', { + 'symbol': 'USD', + 'id': 'usd-us-dollar', + 'price_usd': '1.0', + 'price_btc': str(Decimal(1/btcusd)), + 'last_updated': int(now), + }) + + check_assets_found(usr_wants,found) + +def get_src_data(curl_cmd): + + tor_captcha_msg = f""" + If you’re using Tor, the API request may have failed due to Captcha protection. + A workaround for this issue is to retrieve the JSON data with a browser from + the following URL: + + {api_url} + + and save it to: + + ‘{cfg.cachedir}/ticker.json’ + + Then invoke the program with --cached-data and without --btc + """ + + def rate_limit_errmsg(timeout,elapsed): + return ( + f'Rate limit exceeded! Retry in {timeout-elapsed} seconds' + + ('' if cfg.btc_only else ', or use --cached-data or --btc') + ) + + if not os.path.exists(cachedir): + os.makedirs(cachedir) + + if cfg.btc_only: + fn = os.path.join(cfg.cachedir,'ticker-btc.json') + timeout = 5 if g.test_suite else btc_ratelimit + else: + fn = os.path.join(cfg.cachedir,'ticker.json') + timeout = 5 if g.test_suite else ratelimit + + fn_rel = os.path.relpath(fn,start=homedir) + + if not os.path.exists(fn): + open(fn,'w').write('{}') + + if opt.cached_data: + json_text = open(fn).read() + else: + elapsed = int(time.time() - os.stat(fn).st_mtime) + if elapsed >= timeout: + msg_r(f'Fetching data from {api_host}...') + try: + cp = run(curl_cmd,check=True,stdout=PIPE) + except CalledProcessError as e: + msg('') + from .Misc import curl_exit_codes + msg(red(curl_exit_codes[e.returncode])) + msg(red('Command line:\n {}'.format( ' '.join((repr(i) if ' ' in i else i) for i in e.cmd) ))) + from mmgen.exception import MMGenCalledProcessError + raise MMGenCalledProcessError(f'Subprocess returned non-zero exit status {e.returncode}') + json_text = cp.stdout.decode() + msg('done') + else: + die(1,rate_limit_errmsg(timeout,elapsed)) + + try: + data = json.loads(json_text) + except: + msg(json_text[:1024] + '...') + msg(orange(fmt(tor_captcha_msg,strip_char='\t'))) + die(2,'Retrieved data is not valid JSON, exiting') + + if not data: + if opt.cached_data: + die(1,'No cached data! Run command without --cached-data option to retrieve data from remote host') + else: + die(2,'Remote host returned no data!') + elif 'error' in data: + die(1,data['error']) + + if opt.cached_data: + msg(f'Using cached data from ~/{fn_rel}') + else: + open(fn,'w').write(json_text) + msg(f'JSON data cached to ~/{fn_rel}') + + return data + +def main(cfg_parm,cfg_in_parm): + + def update_sample_file(usr_cfg_file): + src_data = files('mmgen_node_tools').joinpath('data',os.path.basename(usr_cfg_file)).read_text() + sample_file = usr_cfg_file + '.sample' + sample_data = open(sample_file).read() if os.path.exists(sample_file) else None + if src_data != sample_data: + os.makedirs(os.path.dirname(sample_file),exist_ok=True) + msg('{} {}'.format( + ('Updating','Creating')[sample_data is None], + sample_file )) + open(sample_file,'w').write(src_data) + + def get_curl_cmd(): + return ([ + 'curl', + '--tr-encoding', + '--compressed', # adds 'Accept-Encoding: gzip' + '--silent', + '--header', 'Accept: application/json', + ] + + (['--proxy', cfg.proxy] if cfg.proxy else []) + + [api_url + ('/btc-bitcoin' if cfg.btc_only else '')] + ) + + global cfg,cfg_in + cfg = cfg_parm + cfg_in = cfg_in_parm + + try: + from importlib.resources import files # Python 3.9 + except ImportError: + from importlib_resources import files + + update_sample_file(cfg_in.cfg_file) + update_sample_file(cfg_in.portfolio_file) + + if opt.portfolio and not cfg_in.portfolio: + die(1,'No portfolio configured!\nTo configure a portfolio, edit the file ~/{}'.format( + os.path.relpath(cfg_in.portfolio_file,start=homedir))) + + curl_cmd = get_curl_cmd() + + if opt.print_curl: + Msg(curl_cmd + '\n' + ' '.join(curl_cmd)) + return + + parsed_json = [get_src_data(curl_cmd)] if cfg.btc_only else get_src_data(curl_cmd) + + if opt.list_ids: + do_pager('\n'.join(e['id'] for e in parsed_json)) + return + + global now + now = 1659465400 if g.test_suite else time.time() # 1659524400 1659445900 + + (do_pager if opt.pager else Msg)( + '\n'.join(getattr(Ticker,cfg.clsname)(dict(gen_data(parsed_json))).gen_output()) + ) + +def make_cfg(cmd_args,cfg_in): + + def get_rows_from_cfg(add_data=None): + def create_row(e): + return asset_tuple(e.split('-')[0].upper(),e) + def gen(): + for n,(k,v) in enumerate(cfg_in.cfg['assets'].items()): + yield(k) + if add_data and k in add_data: + v += tuple(add_data[k]) + for e in v: + yield(create_row(e)) + return tuple(gen()) + + def parse_asset_tuple(s): + sym,id = (s.split('-')[0],s) if '-' in s else (s,None) + return asset_tuple( sym.upper(), id.lower() if id else None ) + + def parse_asset_triplet(s): + ss = s.split(':') + sym,amt = ( ss[0], Decimal(ss[1]) ) if len(ss) == 2 else ( s, None ) + return asset_triplet( *parse_asset_tuple(sym), amt ) + + def parse_usr_asset_arg(s): + return tuple(parse_asset_triplet(ss) for ss in s.split(',')) if s else () + + def parse_query_arg(s): + ss = s.split(':') + if len(ss) == 2: + return query_tuple( + asset = parse_asset_triplet(s), + to_asset = None ) + elif len(ss) in (3,4): + return query_tuple( + asset = parse_asset_triplet(':'.join(ss[:2])), + to_asset = parse_asset_triplet(':'.join(ss[2:])), + ) + else: + die(1,f'{s}: malformed argument') + + def gen_uniq(obj_list,key,preload=None): + found = set([getattr(obj,key) for obj in preload if hasattr(obj,key)] if preload else ()) + for obj in obj_list: + id = getattr(obj,key) + if id not in found: + yield obj + found.add(id) + + def get_usr_assets(): + return ( + 'user_added', + usr_rows + + (tuple(asset for asset in query if asset) if query else ()) + + usr_columns ) + + def get_portfolio_assets(ret=()): + if cfg_in.portfolio and opt.portfolio: + ret = tuple( asset_tuple(e.split('-')[0].upper(),e) for e in cfg_in.portfolio ) + return ( 'portfolio', tuple(e for e in ret if (not opt.btc) or e.symbol == 'BTC') ) + + def get_portfolio(): + return {k:Decimal(v) for k,v in cfg_in.portfolio.items() if (not opt.btc) or k == 'btc-bitcoin'} + + def parse_add_precision(s): + if not s: + return 0 + if not (s.isdigit() and s.isascii()): + die(1,f'{s}: invalid parameter for --add-precision (not an integer)') + if int(s) > 30: + die(1,f'{s}: invalid parameter for --add-precision (value >30)') + return int(s) + + def create_rows(): + rows = ( + ('trade_pair',) + query if (query and query.to_asset) else + ('bitcoin',parse_asset_tuple('btc-bitcoin')) if opt.btc else + get_rows_from_cfg( add_data={'fiat':['usd-us-dollar']} if opt.add_columns else None ) + ) + + for hdr,data in ( + (get_usr_assets(),) if query else + (get_usr_assets(), get_portfolio_assets()) + ): + if data: + uniq_data = tuple(gen_uniq(data,'symbol',preload=rows)) + if uniq_data: + rows += (hdr,) + uniq_data + return rows + + cfg_tuple = namedtuple('global_cfg',[ + 'rows', + 'usr_rows', + 'usr_columns', + 'query', + 'adjust', + 'clsname', + 'btc_only', + 'add_prec', + 'cachedir', + 'proxy', + 'portfolio' ]) + + query_tuple = namedtuple('query',['asset','to_asset']) + asset_triplet = namedtuple('asset_triplet',['symbol','id','amount']) + asset_tuple = namedtuple('asset_tuple',['symbol','id']) + + usr_rows = parse_usr_asset_arg(opt.add_rows) + usr_columns = parse_usr_asset_arg(opt.add_columns) + query = parse_query_arg(cmd_args[0]) if cmd_args else None + + return cfg_tuple( + rows = create_rows(), + usr_rows = usr_rows, + usr_columns = usr_columns, + query = query, + adjust = ( lambda x: (100 + x) / 100 if x else 1 )( Decimal(opt.adjust or 0) ), + clsname = 'trading' if query else 'overview', + btc_only = opt.btc, + add_prec = parse_add_precision(opt.add_precision), + cachedir = opt.cachedir or cfg_in.cfg.get('cachedir') or cachedir, + proxy = None if opt.proxy == '' else (opt.proxy or cfg_in.cfg.get('proxy')), + portfolio = get_portfolio() if cfg_in.portfolio and opt.portfolio and not query else None + ) + +def get_cfg_in(): + ret = namedtuple('cfg_in_data',['cfg','portfolio','cfg_file','portfolio_file']) + cfg_file,portfolio_file = ( + [os.path.join(g.data_dir_root,'node_tools',fn) for fn in (cfg_fn,portfolio_fn)] + ) + cfg_data,portfolio_data = ( + [yaml.safe_load(open(fn).read()) if os.path.exists(fn) else None for fn in (cfg_file,portfolio_file)] + ) + return ret( + cfg = cfg_data or { + 'assets': { + 'coin': [ 'btc-bitcoin', 'eth-ethereum', 'xmr-monero' ], + 'commodity': [ 'xau-gold-spot-token', 'xag-silver-spot-token', 'xbr-brent-crude-oil-spot' ], + 'fiat': [ 'gbp-pound-sterling-token', 'eur-euro-token' ], + 'index': [ 'dj30-dow-jones-30-token', 'spx-sp-500', 'ndx-nasdaq-100-token' ], + }, + 'proxy': 'http://vpn-gw:8118' + }, + portfolio = portfolio_data, + cfg_file = cfg_file, + portfolio_file = portfolio_file, + ) + +class Ticker: + + class base: + + offer = None + to_asset = None + + def __init__(self,data): + + self.comma = ',' if opt.thousands_comma else '' + + self.col1_wid = max(len('TOTAL'),( + max(len(self.create_label(d['id'])) for d in data.values()) if opt.name_labels else + max(len(d['symbol']) for d in data.values()) + )) + 1 + + self.rows = [row._replace(id=self.get_id(row)) if isinstance(row,tuple) else row for row in cfg.rows] + self.col_usd_prices = {k:Decimal(self.data[k]['price_usd']) for k in self.col_ids} + + self.prices = {row.id:self.get_row_prices(row.id) + for row in self.rows if isinstance(row,tuple) and row.id in data} + self.prices['usd-us-dollar'] = self.get_row_prices('usd-us-dollar') + + def format_last_update_col(self,cross_assets=()): + + if opt.elapsed: + from .Util import format_elapsed_hr + fmt_func = format_elapsed_hr + else: + fmt_func = lambda t,now: time.strftime('%F %X',time.gmtime(t)) # ticker API + # t.replace('T',' ').replace('Z','') # tickers API + + d = self.data + max_w = 0 + min_t = min( (int(d[a.id]['last_updated']) for a in cross_assets), default=None ) + + for row in self.rows: + if isinstance(row,tuple): + try: + t = int(d[row.id]['last_updated']) + except KeyError: + pass + else: + t_fmt = d[row.id]['last_updated_fmt'] = fmt_func( (min(t,min_t) if min_t else t), now ) + max_w = max(len(t_fmt),max_w) + + self.upd_w = max_w + + def init_prec(self): + exp = [(a.id,Decimal.adjusted(self.prices[a.id]['usd-us-dollar'])) for a in self.usr_col_assets] + self.uprec = { k: max(0,v+4) + cfg.add_prec for k,v in exp } + self.uwid = { k: 12 + max(0, abs(v)-6) + cfg.add_prec for k,v in exp } + + def get_id(self,asset): + if asset.id: + return asset.id + else: + for d in self.data.values(): + if d['symbol'] == asset.symbol: + return d['id'] + + def create_label(self,id): + return ' '.join(id.split('-')[1:]).upper() + + def gen_output(self): + yield 'Current time: {} UTC'.format(time.strftime('%F %X',time.gmtime(now))) + + for asset in self.usr_col_assets: + if asset.symbol != 'USD': + usdprice = Decimal(self.data[asset.id]['price_usd']) + yield '{} ({}) = {:{}.{}f} USD'.format( + asset.symbol, + self.create_label(asset.id), + usdprice, + self.comma, + max(2,int(-usdprice.adjusted())+4) ) + + if hasattr(self,'subhdr'): + yield self.subhdr + + if self.show_adj: + yield ( + ('Offered price differs from spot' if self.offer else 'Adjusting prices') + + ' by ' + + yellow('{:+.2f}%'.format( (self.adjust-1) * 100 )) + ) + + yield '' + + if cfg.portfolio: + yield blue('PRICES') + + if self.table_hdr: + yield self.table_hdr + + for row in self.rows: + if isinstance(row,str): + yield ('-' * self.hl_wid) + else: + try: + yield self.fmt_row(self.data[row.id]) + except KeyError: + yield gray(f'(no data for {row.id})') + + yield '-' * self.hl_wid + + if cfg.portfolio: + self.fs_num = self.fs_num2 + self.fs_str = self.fs_str2 + yield '' + yield blue('PORTFOLIO') + yield self.table_hdr + yield '-' * self.hl_wid + for sym,amt in cfg.portfolio.items(): + try: + yield self.fmt_row(self.data[sym],amt=amt) + except KeyError: + yield gray(f'(no data for {sym})') + yield '-' * self.hl_wid + if not cfg.btc_only: + yield self.fs_num.format( + lbl = 'TOTAL', pc1='', pc2='', upd='', amt='', + **{ k.replace('-','_'): v for k,v in self.prices['total'].items() } + ) + + class overview(base): + + def __init__(self,data): + self.data = data + self.adjust = cfg.adjust + self.show_adj = self.adjust != 1 + self.usr_col_assets = [asset._replace(id=self.get_id(asset)) for asset in cfg.usr_columns] + self.col_ids = ('usd-us-dollar',) + tuple(a.id for a in self.usr_col_assets) + ('btc-bitcoin',) + + super().__init__(data) + + self.format_last_update_col() + + if cfg.portfolio: + self.prices['total'] = { col_id: sum(self.prices[row.id][col_id] * cfg.portfolio[row.id] + for row in self.rows if isinstance(row,tuple) and row.id in cfg.portfolio and row.id in data) + for col_id in self.col_ids } + + self.init_prec() + self.init_fs() + + def get_row_prices(self,id): + if id in self.data: + d = self.data[id] + return { k: ( + Decimal(d['price_btc']) if k == 'btc-bitcoin' else + Decimal(d['price_usd']) / self.col_usd_prices[k] + ) * self.adjust for k in self.col_ids } + + def fmt_row(self,d,amt=None,amt_fmt=None): + + def fmt_pct(d): + if d in ('',None): + return gray(' --') + n = Decimal(d) + return (red,green)[n>=0](f'{n:+7.2f}') + + p = self.prices[d['id']] + + if amt is not None: + amt_fmt = f'{amt:{19+cfg.add_prec}{self.comma}.{8+cfg.add_prec}f}' + if '.' in amt_fmt: + amt_fmt = amt_fmt.rstrip('0').rstrip('.') + + return self.fs_num.format( + lbl = (self.create_label(d['id']) if opt.name_labels else d['symbol']), + pc1 = fmt_pct(d.get('percent_change_7d')), + pc2 = fmt_pct(d.get('percent_change_24h')), + upd = d.get('last_updated_fmt'), + amt = amt_fmt, + **{ k.replace('-','_'): v * (1 if amt is None else amt) for k,v in p.items() } + ) + + def init_fs(self): + + col_prec = {'usd-us-dollar':2+cfg.add_prec,'btc-bitcoin':8+cfg.add_prec } # | self.uprec # Python 3.9 + col_prec.update(self.uprec) + col_wid = {'usd-us-dollar':8+cfg.add_prec,'btc-bitcoin':12+cfg.add_prec } # """ + col_wid.update(self.uwid) + max_row = max( + ( (k,v['btc-bitcoin']) for k,v in self.prices.items() ), + key = lambda a: a[1] + ) + widths = { k: len('{:{}.{}f}'.format( self.prices[max_row[0]][k], self.comma, col_prec[k] )) + for k in self.col_ids } + + fd = namedtuple('format_str_data',['fs_str','fs_num','wid']) + + col_fs_data = { + 'label': fd(f'{{lbl:{self.col1_wid}}}',f'{{lbl:{self.col1_wid}}}',self.col1_wid), + 'pct7d': fd(' {pc1:7}', ' {pc1:7}', 8), + 'pct24h': fd(' {pc2:7}', ' {pc2:7}', 8), + 'update_time': fd(' {upd}', ' {upd}', max((19 if cfg.portfolio else 0),self.upd_w) + 2), + 'amt': fd(' {amt}', ' {amt}', 21), + } +# } | { k: fd( # Python 3.9 + col_fs_data.update({ k: fd( + ' {{{}:>{}}}'.format( k.replace('-','_'), widths[k] ), + ' {{{}:{}{}.{}f}}'.format( k.replace('-','_'), widths[k], self.comma, col_prec[k] ), + widths[k]+2 + ) for k in self.col_ids + }) + + cols = ( + ['label','usd-us-dollar'] + + [asset.id for asset in self.usr_col_assets] + + [a for a,b in ( + ( 'btc-bitcoin', not cfg.btc_only ), + ( 'pct7d', opt.percent_change ), + ( 'pct24h', opt.percent_change ), + ( 'update_time', opt.update_time ), + ) if b] + ) + cols2 = list(cols) + if opt.update_time: + cols2.pop() + cols2.append('amt') + + self.fs_str = ''.join(col_fs_data[c].fs_str for c in cols) + self.fs_num = ''.join(col_fs_data[c].fs_num for c in cols) + self.hl_wid = sum(col_fs_data[c].wid for c in cols) + + self.fs_str2 = ''.join(col_fs_data[c].fs_str for c in cols2) + self.fs_num2 = ''.join(col_fs_data[c].fs_num for c in cols2) + self.hl_wid2 = sum(col_fs_data[c].wid for c in cols2) + + @property + def table_hdr(self): + return self.fs_str.format( + lbl = '', + pc1 = ' CHG_7d', + pc2 = 'CHG_24h', + upd = 'UPDATED', + amt = ' AMOUNT', + usd_us_dollar = 'USD', + btc_bitcoin = ' BTC', + **{ a.id.replace('-','_'): a.symbol for a in self.usr_col_assets } + ) + + class trading(base): + + def __init__(self,data): + self.data = data + self.asset = cfg.query.asset._replace(id=self.get_id(cfg.query.asset)) + self.to_asset = ( + cfg.query.to_asset._replace(id=self.get_id(cfg.query.to_asset)) + if cfg.query.to_asset else None ) + self.col_ids = [self.asset.id] + self.adjust = cfg.adjust + if self.to_asset: + self.offer = self.to_asset.amount + if self.offer: + real_price = ( + self.asset.amount + * Decimal(data[self.asset.id]['price_usd']) + / Decimal(data[self.to_asset.id]['price_usd']) + ) + if self.adjust != 1: + die(1,'the --adjust option may not be combined with TO_AMOUNT in the trade specifier') + self.adjust = self.offer / real_price + self.hl_ids = [self.asset.id,self.to_asset.id] + else: + self.hl_ids = [self.asset.id] + + self.show_adj = self.adjust != 1 or self.offer + + super().__init__(data) + + self.usr_col_assets = [self.asset] + ([self.to_asset] if self.to_asset else []) + for a in self.usr_col_assets: + self.prices[a.id]['usd-us-dollar'] = Decimal(data[a.id]['price_usd']) + + self.format_last_update_col(cross_assets=self.usr_col_assets) + + self.init_prec() + self.init_fs() + + def get_row_prices(self,id): + if id in self.data: + d = self.data[id] + return { k: self.col_usd_prices[self.asset.id] / Decimal(d['price_usd']) for k in self.col_ids } + + def init_fs(self): + self.max_wid = max( + len('{:{}{}.{}f}'.format( + v[self.asset.id] * self.asset.amount, + 16 + cfg.add_prec, + self.comma, + 8 + cfg.add_prec + )) + for v in self.prices.values() + ) + self.fs_str = '{lbl:%s} {p_spot}' % self.col1_wid + self.hl_wid = self.col1_wid + self.max_wid + 1 + if self.show_adj: + self.fs_str += ' {p_adj}' + self.hl_wid += self.max_wid + 1 + if opt.update_time: + self.fs_str += ' {upd}' + self.hl_wid += self.upd_w + 2 + + def fmt_row(self,d): + id = d['id'] + p = self.prices[id][self.asset.id] * self.asset.amount + p_spot = '{:{}{}.{}f}'.format( p, self.max_wid, self.comma, 8+cfg.add_prec ) + p_adj = ( + '{:{}{}.{}f}'.format( p*self.adjust, self.max_wid, self.comma, 8+cfg.add_prec ) + if self.show_adj else '' ) + + return self.fs_str.format( + lbl = (self.create_label(id) if opt.name_labels else d['symbol']), + p_spot = green(p_spot) if id in self.hl_ids else p_spot, + p_adj = yellow(p_adj) if id in self.hl_ids else p_adj, + upd = d.get('last_updated_fmt'), + ) + + @property + def table_hdr(self): + return self.fs_str.format( + lbl = '', + p_spot = '{t:>{w}}'.format( + t = 'SPOT PRICE', + w = self.max_wid ), + p_adj = '{t:>{w}}'.format( + t = ('OFFERED' if self.offer else 'ADJUSTED') + ' PRICE', + w = self.max_wid ), + upd = 'UPDATED' + ) + + @property + def subhdr(self): + return ( + '{a}: {b:{c}} {d}'.format( + a = 'Offer' if self.offer else 'Amount', + b = self.asset.amount, + c = self.comma, + d = self.asset.symbol + ) + ( + ( + ' =>' + + (' {:{}}'.format(self.offer,self.comma) if self.offer else '') + + ' {} ({})'.format( + self.to_asset.symbol, + self.create_label(self.to_asset.id) ) + ) if self.to_asset else '' ) + ) diff --git a/mmgen_node_tools/Util.py b/mmgen_node_tools/Util.py index 2a67fe2..7fdc409 100755 --- a/mmgen_node_tools/Util.py +++ b/mmgen_node_tools/Util.py @@ -19,8 +19,8 @@ mmgen_node_tools.Util: utility functions for MMGen node tools """ -import time,subprocess -from mmgen.util import msg +import time +from mmgen.util import suf def get_hms(t=None,utc=False,no_secs=False): secs = t or time.time() @@ -33,11 +33,26 @@ def get_day_hms(t=None,utc=False): ret = (time.localtime,time.gmtime)[utc](secs) return '{:04}-{:02}-{:02} {:02}:{:02}:{:02}'.format(*ret[0:6]) +def format_elapsed_hr(t,now=None,cached={}): + now = now or time.time() + e = int(now - t) + if not e in cached: + h = abs(e) // 3600 + m = abs(e) // 60 % 60 + cached[e] = '{a}{b} minute{c} {d}'.format( + a = '{} hour{}, '.format(h,suf(h)) if h else '', + b = m, + c = suf(m), + d = 'ago' if e > 0 else 'in the future' ) if (h or m) else 'just now' + return cached[e] + def do_system(cmd,testing=False,shell=False): if testing: + from mmgen.util import msg msg("Would execute: '%s'" % cmd) return True else: + import subprocess return subprocess.call((cmd if shell else cmd.split()),shell,stderr=subprocess.PIPE) def get_url(url,gzip_ok=False,proxy=None,timeout=60,verbose=False,debug=False): diff --git a/mmgen_node_tools/data/ticker-cfg.yaml b/mmgen_node_tools/data/ticker-cfg.yaml new file mode 100644 index 0000000..2566da4 --- /dev/null +++ b/mmgen_node_tools/data/ticker-cfg.yaml @@ -0,0 +1,30 @@ +# Indentation must be consistent! Do not mix leading tabs and spaces. + +# See the curl manpage for supported --proxy parameters +# For a direct connection, leave the right-hand side blank +proxy: http://vpn-gw:8118 + +# Override the default cache directory (~/.cache/mmgen-node-tools) +cachedir: + +# Asset labels are arbitrary strings. Use as many or few as you wish. +# Invoke ‘mmnode-ticker --list-ids’ for a full list of supported asset IDs. +assets: + coin1: + - btc-bitcoin + - eth-ethereum + - xmr-monero + coin2: + - ada-cardano + - bnb-binance-coin + commodity: + - xau-gold-spot-token + - xag-silver-spot-token + - xbr-brent-crude-oil-spot + fiat: + - gbp-pound-sterling-token + - eur-euro-token + index: + - dj30-dow-jones-30-token + - spx-sp-500 + - ndx-nasdaq-100-token diff --git a/mmgen_node_tools/data/ticker-portfolio.yaml b/mmgen_node_tools/data/ticker-portfolio.yaml new file mode 100644 index 0000000..fcadd30 --- /dev/null +++ b/mmgen_node_tools/data/ticker-portfolio.yaml @@ -0,0 +1,6 @@ +# List each asset and amount in your portfolio here. +# Amounts should be strings rather than floating-point values. +# Invoke `mmnode-ticker --list-ids` for a full list of supported asset IDs. +btc-bitcoin: '1.23456789' +eth-ethereum: '2.3456789012' +xmr-monero: '4.5678901234' diff --git a/mmgen_node_tools/data/version b/mmgen_node_tools/data/version index 59afffe..7fc0f94 100644 --- a/mmgen_node_tools/data/version +++ b/mmgen_node_tools/data/version @@ -1 +1 @@ -3.1.dev3 +3.1.dev4 diff --git a/mmgen_node_tools/main_ticker.py b/mmgen_node_tools/main_ticker.py new file mode 100755 index 0000000..1949a66 --- /dev/null +++ b/mmgen_node_tools/main_ticker.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +# +# mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet +# Copyright (C)2013-2022 The MMGen Project +# Licensed under the GNU General Public License, Version 3: +# https://www.gnu.org/licenses +# Public project repositories: +# https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools + +""" +mmnode-ticker: Display price information for cryptocurrency and other assets +""" + +import sys,os +from mmgen.common import * +from .Ticker import * + +opts_data = { + 'sets': [ + ('wide', True, 'percent_change', True), + ('wide', True, 'name_labels', True), + ('wide', True, 'thousands_comma', True), + ('wide', True, 'update_time', True), + ], + 'text': { + 'desc': 'Display prices for cryptocurrency and other assets', + 'usage': '[opts] [TRADE_SPECIFIER]', + 'options': f""" +-h, --help Print this help message +--, --longhelp Print help message for long options (common options) +-A, --adjust=P Adjust prices by percentage ‘P’. In ‘trading’ mode, + spot and adjusted prices are shown in separate columns. +-b, --btc Fetch and display data for Bitcoin only +-c, --add-columns=LIST Add columns for asset specifiers in LIST (comma- + separated, see ASSET SPECIFIERS below). Can also be + used to supply a USD exchange rate for missing assets. +-C, --cached-data Use cached data from previous network query instead of + live data from server +-d, --cachedir=D Read and write cached JSON data to directory ‘D’ + instead of ‘~/{os.path.relpath(cachedir,start=homedir)}’ +-e, --add-precision=N Add ‘N’ digits of precision to columns +-E, --elapsed Show elapsed time in UPDATED column (see --update-time) +-F, --portfolio Display portfolio data +-l, --list-ids List IDs of all available assets +-n, --name-labels Label rows with asset names rather than symbols +-p, --percent-change Add percentage change columns +-P, --pager Pipe the output to a pager +-r, --add-rows=LIST Add rows for asset specifiers in LIST (comma-separated, + see ASSET SPECIFIERS below). Can also be used to supply + a USD exchange rate for missing assets. +-T, --thousands-comma Use comma as a thousands separator +-u, --update-time Include UPDATED (last update time) column +-U, --print-curl Print cURL command to standard output and exit +-w, --wide Display all optional columns (equivalent to -punT) +-x, --proxy=P Connect via proxy ‘P’. Set to the empty string to + disable. Consult the curl manpage for --proxy usage. +""", + 'notes': """ + +The script has two display modes: ‘overview’, the default, and ‘trading’, the +latter being enabled when a TRADE_SPECIFIER argument (see below) is supplied +on the command line. + +Overview mode displays prices of all configured assets, and optionally the +user’s portfolio, while trading mode displays the price of a given quantity +of an asset in relation to other assets, optionally comparing an offered +price to the spot price. + +ASSETS consist of either a symbol (e.g. ‘xmr’) or full ID consisting of +symbol plus label (e.g. ‘xmr-monero’). In cases where the symbol is +ambiguous, the full ID must be used. Examples: + + chf - specify asset by symbol + chf-swiss-franc-token - same as above, but use full ID instead of symbol + +ASSET SPECIFIERS consist of an ASSET followed by an optional colon and USD +rate. If the asset is not in the source data (see --list-ids), the label +part of the ID may be arbitrarily chosen by the user. Examples: + + inr:79.5 - INR is not in the source data, so supply USD rate + inr-indian-rupee:79.5 - same as above, but add an arbitrary label + ada-cardano:0.51 - ADA is in the source data, so use the listed ID + +A TRADE_SPECIFIER is a single argument in the format: + + ASSET:AMOUNT[:TO_ASSET[:TO_AMOUNT]] + + Examples: + + xmr:17.34 - price of 17.34 XMR in all configured assets + xmr-monero:17.34 - same as above, but with full ID + xmr:17.34:eur - price of 17.34 XMR in EUR only + xmr:17.34:eur:2800 - commission on an offer of 17.34 XMR for 2800 EUR + + TO_AMOUNT, if included, is used to calculate the percentage difference or + commission on an offer compared to the spot price. + + If either ASSET or TO_ASSET refer to assets not present in the source data, + a USD rate for the missing asset(s) must be supplied via the --add-columns + or --add-rows options. + + + PROXY NOTE + +The remote server used to obtain the price data, {api_host!r}, blocks +Tor behind a Captcha wall, so a Tor proxy cannot be used directly. If you’re +concerned about privacy, connect via a VPN, or better yet, VPN over Tor. Then +set up an HTTP proxy (e.g. Privoxy) on the VPN’ed host and set the ‘proxy’ +option in the config file or --proxy on the command line accordingly. Or run +the script directly on the VPN’ed host with ’proxy’ or --proxy set to the +null string. + +Alternatively, you may download the JSON source data in a Tor-proxied browser +from ‘{api_url}’, save it as ‘ticker.json’ in your +configured cache directory, and run the script with the --cached-data option. + + + RATE LIMITING NOTE + +To protect user privacy, all filtering and processing of data is performed +client side so that the remote server does not know which assets are being +examined. This means that data for ALL available assets (currently over 4000) +is fetched with each invocation of the script. A rate limit of {ratelimit} seconds +between calls is thus imposed to prevent abuse of the remote server. When the +--btc option is in effect, this limit is reduced to 10 seconds. To bypass the +rate limit entirely, use --cached-data. + + + EXAMPLES + +# Basic display in ‘overview’ mode: +$ mmnode-ticker + +# Display BTC price only: +$ mmnode-ticker --btc + +# Wide display, add EUR and INR columns, INR rate, extra precision and proxy: +$ mmnode-ticker -w -c eur,inr-indian-rupee:79.5 -e2 -x http://vpnhost:8118 + +# Wide display, use cached data from previous network query, show portfolio +# (see above), pipe output to pager, add DOGE row: +$ mmnode-ticker -wCFP -r doge + +# Display 17.234 XMR priced in all configured assets (‘trading’ mode): +$ mmnode-ticker xmr:17.234 + +# Same as above, but add INR price at specified USDINR rate: +$ mmnode-ticker -c inr:79.5 xmr:17.234 + +# Same as above, but view INR price only at specified rate, adding label: +$ mmnode-ticker -c inr-indian-rupee:79.5 xmr:17.234:inr + +# Calculate commission on an offer of 2700 USD for 0.123 BTC, compared to +# current spot price: +$ mmnode-ticker usd:2700:btc:0.123 + +# Calculate commission on an offer of 200000 INR for 0.1 BTC, compared to +# current spot price, at specified USDINR rate: +$ mmnode-ticker -n -c inr-indian-rupee:79.5 inr:200000:btc:0.1 + + +CONFIGURED ASSETS: +{assets} + +Customize output by editing the file + ~/{cfg} + +To add a portfolio, edit the file + ~/{pf_cfg} +""" + }, + 'code': { + 'notes': lambda s: s.format( + assets = fmt_list(assets_list_gen(cfg_in),fmt='col',indent=' '), + cfg = os.path.relpath(cfg_in.cfg_file,start=homedir), + pf_cfg = os.path.relpath(cfg_in.portfolio_file,start=homedir), + api_host = api_host, + api_url = api_url, + ratelimit = ratelimit, + ) + } +} + +cmd_args = opts.init(opts_data,do_post_init=True) + +cfg_in = get_cfg_in() + +cfg = make_cfg(cmd_args,cfg_in) + +opts.post_init() + +main(cfg,cfg_in) diff --git a/setup.cfg b/setup.cfg index 3f804e3..628ebe0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,7 @@ python_requires = >=3.7 include_package_data = True install_requires = - mmgen>=13.2.dev10 + mmgen>=13.2.dev12 packages = mmgen_node_tools @@ -36,3 +36,4 @@ scripts = cmds/mmnode-halving-calculator cmds/mmnode-netrate cmds/mmnode-peerblocks + cmds/mmnode-ticker diff --git a/test/ref/ticker/ticker-btc.json b/test/ref/ticker/ticker-btc.json new file mode 100644 index 0000000..75c8909 --- /dev/null +++ b/test/ref/ticker/ticker-btc.json @@ -0,0 +1 @@ +{"id":"btc-bitcoin","name":"Bitcoin","symbol":"BTC","rank":"1","price_usd":"23368.859557988893","price_btc":"1","volume_24h_usd":"24116251608.791744","market_cap_usd":"446560795287","circulating_supply":"19109225","total_supply":"19109231","max_supply":"21000000","percent_change_1h":"-0.23","percent_change_24h":"-1.87","percent_change_7d":"6.05","last_updated":"1659346445"} \ No newline at end of file diff --git a/test/ref/ticker/ticker-cfg.yaml b/test/ref/ticker/ticker-cfg.yaml new file mode 100644 index 0000000..504a7fb --- /dev/null +++ b/test/ref/ticker/ticker-cfg.yaml @@ -0,0 +1,24 @@ +proxy: http://asdfzxcv:32459 + +cachedir: + +assets: + coin1: + - btc-bitcoin + - ltc-litecoin + - eth-ethereum + - xmr-monero + coin2: + - ada-cardano + - algo-algorand + commodity: + - xau-gold-spot-token + - xag-silver-spot-token + - xbr-brent-crude-oil-spot + fiat: + - chf-swiss-franc-token + - eur-euro-token + index: + - dj30-dow-jones-30-token + - spx-sp-500 + - ndx-nasdaq-100-token diff --git a/test/ref/ticker/ticker-portfolio-bad.yaml b/test/ref/ticker/ticker-portfolio-bad.yaml new file mode 100644 index 0000000..0a2740e --- /dev/null +++ b/test/ref/ticker/ticker-portfolio-bad.yaml @@ -0,0 +1,6 @@ +btc-bitcoin: '1.23456789' +eth-ethereum: '2.345678901234567890' +xmr-monero: '4.567890123456' +ada-cardano: '123.45678901' +algo-algorand: '234.5678901' +noc-nocoin: '777.1234' diff --git a/test/ref/ticker/ticker-portfolio.yaml b/test/ref/ticker/ticker-portfolio.yaml new file mode 100644 index 0000000..332257f --- /dev/null +++ b/test/ref/ticker/ticker-portfolio.yaml @@ -0,0 +1,5 @@ +btc-bitcoin: '1.23456789' +eth-ethereum: '2.345678901234567890' +xmr-monero: '4.567890123456' +ada-cardano: '123.45678901' +algo-algorand: '234.5678901' diff --git a/test/ref/ticker/ticker.json b/test/ref/ticker/ticker.json new file mode 100644 index 0000000..2484e6d --- /dev/null +++ b/test/ref/ticker/ticker.json @@ -0,0 +1 @@ +[{"id":"btc-bitcoin","name":"Bitcoin","symbol":"BTC","rank":"1","price_usd":"23250.774053363122","price_btc":"1","volume_24h_usd":"28307579008.4866","market_cap_usd":"444336521633","circulating_supply":"19110612","total_supply":"19110619","max_supply":"21000000","percent_change_1h":"-0.27","percent_change_24h":"0.89","percent_change_7d":"11.15","last_updated":"1659464759"},{"id":"eth-ethereum","name":"Ethereum","symbol":"ETH","rank":"2","price_usd":"1659.6621665887371","price_btc":"0.07146396683507168","volume_24h_usd":"18216561308.363518","market_cap_usd":"202151289827","circulating_supply":"121802674","total_supply":"121802721","max_supply":"","percent_change_1h":"-0.03","percent_change_24h":"1.82","percent_change_7d":"21.42","last_updated":"1659464759"},{"id":"ltc-litecoin","name":"Litecoin","symbol":"LTC","rank":"23","price_usd":"59.013589192027936","price_btc":"0.002541086532993605","volume_24h_usd":"510336800.72056556","market_cap_usd":"4181502638","circulating_supply":"70856606","total_supply":"70856631","max_supply":"84000000","percent_change_1h":"-0.43","percent_change_24h":"0.4","percent_change_7d":"12.79","last_updated":"1659464759"},{"id":"xmr-monero","name":"Monero","symbol":"XMR","rank":"30","price_usd":"158.97302629813817","price_btc":"0.006845274482813666","volume_24h_usd":"78159392.30003875","market_cap_usd":"2886277702","circulating_supply":"18155770","total_supply":"18155768","max_supply":"","percent_change_1h":"0.21","percent_change_24h":"1.21","percent_change_7d":"7.28","last_updated":"1659464759"},{"id":"ada-cardano","name":"Cardano","symbol":"ADA","rank":"8","price_usd":"0.5069722536476557","price_btc":"0.00002182989348696495","volume_24h_usd":"507973898.04662097","market_cap_usd":"17111613980","circulating_supply":"33752565071","total_supply":"34277702082","max_supply":"45000000000","percent_change_1h":"0.05","percent_change_24h":"-0.11","percent_change_7d":"11.68","last_updated":"1659464759"},{"id":"algo-algorand","name":"Algorand","symbol":"ALGO","rank":"33","price_usd":"0.33196893931152616","price_btc":"0.00001429436529121747","volume_24h_usd":"62207779.35759487","market_cap_usd":"2306909784","circulating_supply":"6949173585","total_supply":"7350412337","max_supply":"","percent_change_1h":"-0.06","percent_change_24h":"-0.82","percent_change_7d":"9.69","last_updated":"1659464759"},{"id":"xau-gold-spot-token","name":"Gold Spot Token","symbol":"XAU","rank":"2634","price_usd":"1767.0700000000002","price_btc":"0.07608887785567188","volume_24h_usd":"11881071.852000002","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.33","percent_change_24h":"-0.19","percent_change_7d":"2.89","last_updated":"1659464759"},{"id":"xag-silver-spot-token","name":"Silver Spot Token","symbol":"XAG","rank":"4753","price_usd":"20.0265","price_btc":"0.0008623279849562342","volume_24h_usd":"445379.34674999997","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.61","percent_change_24h":"-1.54","percent_change_7d":"7.49","last_updated":"1659464759"},{"id":"xbr-brent-crude-oil-spot","name":"Brent Crude Oil Spot","symbol":"XBR","rank":"2616","price_usd":"100.39000000000001","price_btc":"0.0043227277062770015","volume_24h_usd":"4527788.105237802","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.89","percent_change_24h":"0.87","percent_change_7d":"1.02","last_updated":"1659464759"},{"id":"chf-swiss-franc-token","name":"Swiss Franc Token","symbol":"CHF","rank":"3234","price_usd":"1.0453383927173951","price_btc":"0.00004501158713651311","volume_24h_usd":"360978.80080814153","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.08","percent_change_24h":"-0.66","percent_change_7d":"0.65","last_updated":"1659464759"},{"id":"eur-euro-token","name":"Euro Token","symbol":"EUR","rank":"5116","price_usd":"1.0185784743417672","price_btc":"0.00004385932256255119","volume_24h_usd":"40587878.087926075","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"0.05","percent_change_24h":"-0.82","percent_change_7d":"0.6","last_updated":"1659464759"},{"id":"gbp-pound-sterling-token","name":"Pound Sterling Token","symbol":"GBP","rank":"3424","price_usd":"1.2181792934946465","price_btc":"0.00005245400321946659","volume_24h_usd":"5428622.322314565","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.06","percent_change_24h":"-0.61","percent_change_7d":"1.29","last_updated":"1659464759"},{"id":"dj30-dow-jones-30-token","name":"Dow Jones 30 Token","symbol":"DJ30","rank":"3192","price_usd":"32546","price_btc":"1.401409462381624","volume_24h_usd":"293564.92","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.1","percent_change_24h":"-0.91","percent_change_7d":"2.5","last_updated":"1659464759"},{"id":"spx-sp-500","name":"S&P 500 Token","symbol":"SPX","rank":"3342","price_usd":"4110.6","price_btc":"0.17699974608449287","volume_24h_usd":"780397.41","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"-0.03","percent_change_24h":"-0.27","percent_change_7d":"4.87","last_updated":"1659464759"},{"id":"ndx-nasdaq-100-token","name":"NASDAQ 100 Token","symbol":"NDX","rank":"5097","price_usd":"12948.199999999999","price_btc":"0.5575410188904857","volume_24h_usd":"11221816.494","market_cap_usd":"","circulating_supply":"","total_supply":"","max_supply":"","percent_change_1h":"0.08","percent_change_24h":"0.04","percent_change_7d":"7.21","last_updated":"1659464759"}] diff --git a/test/test-release.d/cfg.sh b/test/test-release.d/cfg.sh index fdc4800..f0c11d7 100755 --- a/test/test-release.d/cfg.sh +++ b/test/test-release.d/cfg.sh @@ -14,6 +14,7 @@ list_avail_tests() { echo " btc_rt - Bitcoin regtest" echo " bch_rt - Bitcoin Cash Node (BCH) regtest" echo " ltc_rt - Litecoin regtest" + echo " scripts - tests of scripts not requiring a coin daemon" echo " misc - miscellaneous tests that don't fit in the above categories" echo echo "AVAILABLE TEST GROUPS:" @@ -27,10 +28,10 @@ list_avail_tests() { } init_groups() { - dfl_tests='unit misc btc_rt bch_rt ltc_rt' + dfl_tests='unit misc scripts btc_rt bch_rt ltc_rt' extra_tests='' - noalt_tests='unit misc btc_rt' - quick_tests='unit misc btc_rt' + noalt_tests='unit misc scripts btc_rt' + quick_tests='unit misc scripts btc_rt' qskip_tests='bch_rt ltc_rt' } @@ -45,6 +46,11 @@ init_tests() { t_misc="- $test_py helpscreens" f_misc='Misc tests completed' + i_scripts='No-daemon scripts' + s_scripts="The following tests will test scripts not requiring a coin daemon" + t_scripts="- $test_py scripts" + f_scripts='No-daemon script tests completed' + i_btc_rt='Bitcoin regtest' s_btc_rt="The following tests will test various scripts using regtest mode" t_btc_rt="- $test_py regtest" diff --git a/test/test_py_d/cfg.py b/test/test_py_d/cfg.py index c56f74c..d2726c4 100755 --- a/test/test_py_d/cfg.py +++ b/test/test_py_d/cfg.py @@ -16,6 +16,7 @@ import os cmd_groups_dfl = { 'helpscreens': ('TestSuiteHelp',{'modname':'misc','full_data':True}), + 'scripts': ('TestSuiteScripts',{'modname':'misc'}), 'regtest': ('TestSuiteRegtest',{}), } @@ -23,6 +24,7 @@ cmd_groups_extra = {} cfgs = { '1': {}, # regtest + '2': {}, # scripts } def fixup_cfgs(): pass diff --git a/test/test_py_d/ts_misc.py b/test/test_py_d/ts_misc.py index 707f32f..c76bad6 100755 --- a/test/test_py_d/ts_misc.py +++ b/test/test_py_d/ts_misc.py @@ -12,6 +12,7 @@ ts_misc.py: Miscellaneous test groups for the test.py test suite """ +import shutil from ..include.common import * from .common import * from .ts_base import * @@ -48,3 +49,225 @@ class TestSuiteHelp(TestSuiteBase): def longhelpscreens(self): return self.helpscreens(arg='--longhelp',expect='USAGE:.*LONG OPTIONS:') + +refdir = os.path.join('test','ref','ticker') + +class TestSuiteScripts(TestSuiteBase): + 'scripts not requiring a coin daemon' + networks = ('btc',) + tmpdir_nums = [2] + passthru_opts = () + color = True + + cmd_group_in = ( + ('subgroup.ticker_setup', []), + ('subgroup.ticker', ['ticker_setup']), + ) + cmd_subgroups = { + 'ticker_setup': ( + "setup for 'ticker' subgroup", + ('ticker_setup', 'ticker setup'), + ), + 'ticker': ( + "'mmnode-ticker' script", + ('ticker1', 'ticker [--help)'), + ('ticker2', 'ticker (bad proxy)'), + ('ticker3', 'ticker [--cached-data]'), + ('ticker4', 'ticker [--cached-data --wide]'), + ('ticker5', 'ticker [--cached-data --wide --adjust=-0.766] (usr cfg file)'), + ('ticker6', 'ticker [--cached-data --wide --portfolio] (missing portfolio)'), + ('ticker7', 'ticker [--cached-data --wide --portfolio]'), + ('ticker8', 'ticker [--cached-data --wide --elapsed]'), + ('ticker9', 'ticker [--cached-data --wide --portfolio --elapsed --add-rows=fake-fakecoin:0.0123 --add-precision=2]'), + ('ticker10', 'ticker [--cached-data xmr:17.234]'), + ('ticker11', 'ticker [--cached-data xmr:17.234:btc]'), + ('ticker12', 'ticker [--cached-data --adjust=1.23 xmr:17.234:btc]'), + ('ticker13', 'ticker [--cached-data --wide --elapsed -c inr-indian-rupee:79.5 inr:200000:btc:0.1]'), + ('ticker14', 'ticker [--cached-data --wide --btc]'), + ('ticker15', 'ticker [--cached-data --wide --btc btc:2:usd:45000]'), + ) + } + + @property + def ticker_args(self): + return [ f'--cachedir={self.tmpdir}', '--proxy=http://asdfzxcv:32459' ] + + @property + def nt_datadir(self): + return os.path.join( g.data_dir_root, 'node_tools' ) + + def ticker_setup(self): + self.spawn('',msg_only=True) + shutil.copy2(os.path.join(refdir,'ticker.json'),self.tmpdir) + shutil.copy2(os.path.join(refdir,'ticker-btc.json'),self.tmpdir) + return 'ok' + + def ticker(self,args=[],expect_list=None,cached=True): + t = self.spawn( + f'mmnode-ticker', + (['--cached-data'] if cached else []) + self.ticker_args + args ) + if expect_list: + t.match_expect_list(expect_list) + return t + + def ticker1(self): + t = self.ticker(['--help']) + t.expect('USAGE:') + return t + + def ticker2(self): + t = self.ticker(cached=False) + if not opt.skipping_deps: + t.expect('Creating') + t.expect('Creating') + t.expect('proxy host could not be resolved') + t.req_exit_val = 3 + return t + + def ticker3(self): + return self.ticker( + [], + [ + 'USD BTC', + 'BTC 23250.77 1.00000000 ETH 1659.66 0.07146397' + ]) + + + def ticker4(self): + return self.ticker( + ['--wide','--add-columns=eur,inr-indian-rupee:79.5'], + [ + r'EUR \(EURO TOKEN\) = 1.0186 USD ' + + r'INR \(INDIAN RUPEE\) = 0.012579 USD', + 'USD EUR INR BTC CHG_7d CHG_24h UPDATED', + 'BITCOIN', + r'ETHEREUM 1,659.66 1,629.3906 131,943.14 0.07146397 \+21.42 \+1.82', + r'MONERO 158.97 156.0734 12,638.36 0.00684527 \+7.28 \+1.21 2022-08-02 18:25:59', + r'INDIAN RUPEE 0.01 0.0123 1.00 0.00000054 -- --', + ]) + + def ticker5(self): + shutil.copy2(os.path.join(refdir,'ticker-cfg.yaml'),self.nt_datadir) + t = self.ticker( + ['--wide','--adjust=-0.766'], + [ + 'Adjusting prices by -0.77%', + 'USD BTC CHG_7d CHG_24h UPDATED', + r'LITECOIN 58.56 0.00252162 \+12.79 \+0.40 2022-08-02 18:25:59', + r'MONERO 157.76 0.00679284 \+7.28 \+1.21' + ]) + os.unlink(os.path.join(self.nt_datadir,'ticker-cfg.yaml')) + return t + + def ticker6(self): + t = self.ticker( ['--wide','--portfolio'], None ) + t.expect('No portfolio') + t.req_exit_val = 1 + return t + + def ticker7(self): # demo + shutil.copy2(os.path.join(refdir,'ticker-portfolio.yaml'),self.nt_datadir) + t = self.ticker( + ['--wide','--portfolio'], + [ + 'USD BTC CHG_7d CHG_24h UPDATED', + r'ETHEREUM 1,659.66 0.07146397 \+21.42 \+1.82 2022-08-02 18:25:59', + 'CARDANO','ALGORAND', + 'PORTFOLIO','BITCOIN','ETHEREUM','MONERO','CARDANO','ALGORAND','TOTAL' + ]) + os.unlink(os.path.join(self.nt_datadir,'ticker-portfolio.yaml')) + return t + + def ticker8(self): + return self.ticker( + ['--wide','--elapsed'], + [ + 'USD BTC CHG_7d CHG_24h UPDATED', + r'BITCOIN 23,250.77 1.00000000 \+11.15 \+0.89 10 minutes ago' + ]) + + def ticker9(self): + shutil.copy2( + os.path.join(refdir,'ticker-portfolio-bad.yaml'), + os.path.join(self.nt_datadir,'ticker-portfolio.yaml') ) + t = self.ticker( + ['--wide','--portfolio','--elapsed','--add-rows=fake-fakecoin:0.0123','--add-precision=2'], + [ + 'USD BTC CHG_7d CHG_24h UPDATED', + r'BITCOIN 23,250.7741 1.0000000000 \+11.15 \+0.89 10 minutes ago', + r'FAKECOIN 81.3008 0.0034966927 -- -- just now', + r'\(no data for noc-nocoin\)', + ]) + os.unlink(os.path.join(self.nt_datadir,'ticker-portfolio.yaml')) + return t + + def ticker10(self): + return self.ticker( + ['XMR:17.234'], + [ + r'XMR \(MONERO\) = 158.97 USD ' + + 'Amount: 17.234 XMR', + 'SPOT PRICE', + 'BTC 0.11783441', + 'XMR 17.23400000', + 'XAU','NDX', + ]) + + def ticker11(self): + return self.ticker( + ['XMR:17.234:BTC'], + [ + r'XMR \(MONERO\) = 158.97 USD ' + + r'BTC \(BITCOIN\) = 23250.77 USD ' + + 'Amount: 17.234 XMR', + 'SPOT PRICE', + 'XMR 17.23400000 BTC 0.11783441', + ]) + + def ticker12(self): + return self.ticker( + ['--adjust=1.23','--wide','XMR:17.234:BTC'], + [ + r'XMR \(MONERO\) = 158.97 USD ' + + r'BTC \(BITCOIN\) = 23,250.77 USD ' + + 'Amount: 17.234 XMR', + r'Adjusting prices by \+1.23%', + 'SPOT PRICE ADJUSTED PRICE', + 'MONERO 17.23400000 17.44597820 2022-08-02 18:25:59 ' + + 'BITCOIN 0.11783441 0.11928377 2022-08-02 18:25:59', + ]) + + def ticker13(self): + return self.ticker( + ['-wE','-c','inr-indian-rupee:79.5','inr:200000:btc:0.1'], + [ + 'Offer: 200,000 INR', + 'Offered price differs from spot by -7.58%', + 'SPOT PRICE OFFERED PRICE UPDATED', + 'INDIAN RUPEE 200,000.00000000 184,843.65372424 10 minutes ago ' + + 'BITCOIN 0.10819955 0.10000000 10 minutes ago' + ]) + + def ticker14(self): + shutil.copy2(os.path.join(refdir,'ticker-portfolio.yaml'),self.nt_datadir) + t = self.ticker( + ['--btc','--wide','--portfolio','--elapsed'], + [ + 'PRICES', + r'BITCOIN 23,368.86 \+6.05 -1.87 33 hours, 2 minutes ago', + 'PORTFOLIO', + r'BITCOIN 28,850.44 \+6.05 -1.87 1.23456789' + ]) + os.unlink(os.path.join(self.nt_datadir,'ticker-portfolio.yaml')) + return t + + def ticker15(self): + return self.ticker( + ['--btc','--wide','--elapsed','-r','inr:79.5','btc:2:usd:45000'], + [ + r'BTC \(BITCOIN\) = 23,368.86 USD', + 'Offered price differs from spot by -3.72%', + 'SPOT PRICE OFFERED PRICE UPDATED', + 'BITCOIN 2.00000000 1.92563954 33 hours, 2 minutes ago ' + + 'US DOLLAR 46,737.71911598 45,000.00000000 33 hours, 2 minutes ago', + ])