فهرست منبع

new script: `mmnode-ticker`

Display cryptocurrency and other asset prices in convenient tabular format,
with optional display of your portfolio.  Output is highly configurable.

Usage information:

    $ mmnode-ticker --help
The MMGen Project 1 سال پیش
والد
کامیت
e7fcc00b95

+ 17 - 0
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 <mmgen@tuta.io>
+# 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')

+ 102 - 0
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 <mmgen@tuta.io>
+# 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',
+}

+ 777 - 0
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 <mmgen@tuta.io>
+# 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 '' )
+			)

+ 17 - 2
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):

+ 30 - 0
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

+ 6 - 0
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'

+ 1 - 1
mmgen_node_tools/data/version

@@ -1 +1 @@
-3.1.dev3
+3.1.dev4

+ 193 - 0
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 <mmgen@tuta.io>
+# 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)

+ 2 - 1
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

+ 1 - 0
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"}

+ 24 - 0
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

+ 6 - 0
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'

+ 5 - 0
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'

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
test/ref/ticker/ticker.json


+ 9 - 3
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"

+ 2 - 0
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

+ 223 - 0
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',
+			])

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است