diff --git a/MANIFEST.in b/MANIFEST.in index 7bdab90..2581100 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,12 @@ include README.md LICENSE -include test/test-release.sh -include test/unit_tests_d/*.py + include mmgen_node_tools/data/* + +include test/init.sh +include test/test-release.d/*.sh +include test/modtest_d/*.py +include test/cmdtest_d/*.py +include test/cmdtest_d/include/cfg.py +include test/cmdtest_d/httpd/ticker.py +include test/overlay/fakemods/mmgen_node_tools/*.py +include test/ref/*/* diff --git a/README.md b/README.md index 673155e..a536cce 100644 --- a/README.md +++ b/README.md @@ -4,45 +4,102 @@ Requires modules from the [MMGen online/offline cryptocurrency wallet][6]. -Currently tested on Linux only. Some scripts may not work under Windows/MSYS2. - ## Install: -First, install [MMGen][6]. +If installing as user (without venv), make sure that `~/.local/bin` is in `PATH`. -Then, +#### Windows/MSYS2: - $ git clone https://github.com/mmgen/mmgen-node-tools - $ cd mmgen-node-tools - $ python3 -m build --no-isolation - $ python3 -m pip install --user dist/*.whl +> Install [MSYS2 and the MMGen Wallet dependencies][8], skipping installation of +> scrypt, libsecp256k1 and the wallet itself if desired. +> +> Install some additional dependencies: +> ```bash +> $ pacman -S mingw-w64-ucrt-x86_64-python-pandas +> $ python3 -m pip install requests-futures +> $ python3 -m pip install --no-deps yahooquery +> ``` -Also make sure that `~/.local/bin` is in `PATH`. +#### Linux, macOS: + +> Install some [required packages][7] with your package manager and pip. + +### Stable version: + +```bash +$ python3 -m pip install --upgrade mmgen-node-tools +``` + +### Development version: + +First install the latest development version of [MMGen Wallet][6] for your +platform. Then perform the following steps: + +```bash +$ git clone https://github.com/mmgen/mmgen-node-tools +$ cd mmgen-node-tools +$ python3 -m build --no-isolation +$ python3 -m pip install dist/*.whl +``` ## Test: -*NOTE: the tests require that the MMGen and MMGen Node Tools repositories be +*NOTE: the tests require that the MMGen Wallet and MMGen Node Tools repositories be located in the same directory.* +#### Windows/MSYS2: + +> *Tested only on NTFS – with ReFS your mileage may vary* +> +> Turn on Developer Mode to enable symlinks: +> ``` +> Settings -> Update & Security -> For developers -> Developer Mode: On +> ``` +> and add this to your `~/.bashrc`: +> ```bash +> export MSYS=winsymlinks:nativestrict +> ``` +> Close and reopen the MSYS2 terminal to update your environment. + Initialize the test framework (must be run at least once after cloning, and possibly again after a pull if tests have been updated): - - $ test/init.sh - +``` +$ test/init.sh +``` BTC-only testing: - - $ test/test-release.sh -A - +``` +$ test/test-release.sh -A +``` Full testing: - - $ test/test-release.sh +``` +$ test/test-release.sh +``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -[**Forum**][4] | -[PGP Public Key][5] | -Donate: 15TLdmi5NYLdqmtCqczUs5pBPkJDXRs83w +Homepage: +[Clearnet](https://mmgen.org) | +[I2P](http://mmgen-wallet.i2p) | +[Onion](http://mmgen55rtcahqfp2hn3v7syqv2wqanks5oeezqg3ykwfkebmouzjxlad.onion) +Code repository: +[Clearnet](https://mmgen.org/project/mmgen/mmgen-node-tools) | +[I2P](http://mmgen-wallet.i2p/project/mmgen/mmgen-node-tools) | +[Onion](http://mmgen55rtcahqfp2hn3v7syqv2wqanks5oeezqg3ykwfkebmouzjxlad.onion/project/mmgen/mmgen-node-tools) +Code repository mirrors: +[Github](https://github.com/mmgen/mmgen-node-tools) | +[Gitlab](https://gitlab.com/mmgen/mmgen-node-tools) | +[Codeberg](https://codeberg.org/mmgen/mmgen-node-tools) +[Keybase](https://keybase.io/mmgen) | +[Twitter](https://twitter.com/TheMMGenProject) | +[Reddit](https://www.reddit.com/user/mmgen-py) | +[Bitcointalk](https://bitcointalk.org/index.php?topic=567069.new#new) +[PGP Signing Key][5]: 5C84 CB45 AEE2 250F 31A6 A570 3F8B 1861 E32B 7DA2 +Donate: + ⊙ BTC: *bc1qxmymxf8p5ckvlxkmkwgw8ap5t2xuaffmrpexap* + ⊙ BCH: *15TLdmi5NYLdqmtCqczUs5pBPkJDXRs83w* + ⊙ XMR: *8B14zb8wgLuKDdse5p8f3aKpFqRdB4i4xj83b7BHYABHMvHifWxiDXeKRELnaxL5FySfeRRS5girgUvgy8fQKsYMEzPUJ8h* -[4]: https://bitcointalk.org/index.php?topic=567069.0 -[5]: https://github.com/mmgen/mmgen/wiki/MMGen-Signing-Keys -[6]: https://github.com/mmgen/mmgen/ +[5]: https://github.com/mmgen/mmgen-wallet/wiki/MMGen-Signing-Keys +[6]: https://github.com/mmgen/mmgen-wallet/ +[7]: https://github.com/mmgen/mmgen-wallet/wiki/Install-MMGen-Wallet-on-Linux-or-macOS +[8]: https://github.com/mmgen/mmgen-wallet/wiki/Install-MMGen-on-Microsoft-Windows#a_m diff --git a/cmds/mmnode-addrbal b/cmds/mmnode-addrbal index 3a5bc68..a14edc0 100755 --- a/cmds/mmnode-addrbal +++ b/cmds/mmnode-addrbal @@ -14,4 +14,4 @@ mmnode-addrbal: Get balances for arbitrary addresses in the blockchain from mmgen.main import launch -launch('addrbal',package='mmgen_node_tools') +launch(mod='addrbal',package='mmgen_node_tools') diff --git a/cmds/mmnode-blocks-info b/cmds/mmnode-blocks-info index a1c855e..d542d1a 100755 --- a/cmds/mmnode-blocks-info +++ b/cmds/mmnode-blocks-info @@ -14,4 +14,4 @@ mmnode-blocks-info: Display information about a block or range of blocks from mmgen.main import launch -launch('blocks_info',package='mmgen_node_tools') +launch(mod='blocks_info',package='mmgen_node_tools') diff --git a/cmds/mmnode-feeview b/cmds/mmnode-feeview index 5a4625e..8b639bb 100755 --- a/cmds/mmnode-feeview +++ b/cmds/mmnode-feeview @@ -14,4 +14,4 @@ mmnode-feeview: Visualize the fee structure of a node’s mempool from mmgen.main import launch -launch('feeview',package='mmgen_node_tools') +launch(mod='feeview',package='mmgen_node_tools') diff --git a/cmds/mmnode-halving-calculator b/cmds/mmnode-halving-calculator index ebf1f6d..1759f77 100755 --- a/cmds/mmnode-halving-calculator +++ b/cmds/mmnode-halving-calculator @@ -14,4 +14,4 @@ mmnode-halving-calculator: Estimate date(s) of future block subsidy halving(s) from mmgen.main import launch -launch('halving_calculator',package='mmgen_node_tools') +launch(mod='halving_calculator',package='mmgen_node_tools') diff --git a/cmds/mmnode-netrate b/cmds/mmnode-netrate index cfefab2..f8c4ad1 100755 --- a/cmds/mmnode-netrate +++ b/cmds/mmnode-netrate @@ -14,4 +14,4 @@ mmnode-netrate: Bitcoin daemon network rate monitor from mmgen.main import launch -launch('netrate',package='mmgen_node_tools') +launch(mod='netrate',package='mmgen_node_tools') diff --git a/cmds/mmnode-peerblocks b/cmds/mmnode-peerblocks index be4bb41..5a02741 100755 --- a/cmds/mmnode-peerblocks +++ b/cmds/mmnode-peerblocks @@ -14,4 +14,4 @@ mmnode-peerblocks: List blocks in flight, disconnect stalling nodes from mmgen.main import launch -launch('peerblocks',package='mmgen_node_tools') +launch(mod='peerblocks',package='mmgen_node_tools') diff --git a/cmds/mmnode-ticker b/cmds/mmnode-ticker index d6e91fb..f0160e7 100755 --- a/cmds/mmnode-ticker +++ b/cmds/mmnode-ticker @@ -14,4 +14,4 @@ mmnode-ticker: Display price information for cryptocurrency and other assets from mmgen.main import launch -launch('ticker',package='mmgen_node_tools') +launch(mod='ticker',package='mmgen_node_tools') diff --git a/cmds/mmnode-txfind b/cmds/mmnode-txfind index 05213a8..fd48cf4 100755 --- a/cmds/mmnode-txfind +++ b/cmds/mmnode-txfind @@ -14,4 +14,4 @@ mmnode-txfind: Find a transaction in the blockchain or mempool from mmgen.main import launch -launch('txfind',package='mmgen_node_tools') +launch(mod='txfind',package='mmgen_node_tools') diff --git a/mmgen_node_tools/BlocksInfo.py b/mmgen_node_tools/BlocksInfo.py index 25e1caf..c068017 100755 --- a/mmgen_node_tools/BlocksInfo.py +++ b/mmgen_node_tools/BlocksInfo.py @@ -20,12 +20,58 @@ mmgen_node_tools.BlocksInfo: Display information about a block or range of blocks """ -import re,json +import re, json from collections import namedtuple -from time import strftime,gmtime +from time import strftime, gmtime +from decimal import Decimal -from mmgen.util import msg,Msg,Msg_r,die,suf,secs_to_ms,secs_to_dhms,is_int -from mmgen.rpc import json_encoder +from mmgen.util import msg, Msg, Msg_r, die, suf, secs_to_ms, secs_to_dhms, is_int +from mmgen.rpc.util import json_encoder + +class RangeParser: + + debug = False + + def __init__(self, caller, arg): + self.caller = caller + self.arg = self.orig_arg = arg + + def parse(self, target): + ret = getattr(self, 'parse_'+target)() + if self.debug: + msg(f'arg after parse({target}): {self.arg}') + return ret + + def finalize(self): + if self.arg: + die(1, f'{self.orig_arg!r}: invalid range specifier') + + def parse_from_tip(self): + m = re.match(r'-([0-9]+)(.*)', self.arg) + if m: + res, self.arg = (m[1], m[2]) + return self.caller.check_nblocks(int(res)) + + def parse_abs_range(self): + m = re.match(r'([^+-]+)(-([^+-]+)){0,1}(.*)', self.arg) + if m: + if self.debug: + msg(f'abs_range parse: first={m[1]}, last={m[3]}') + self.arg = m[4] + return ( + self.caller.conv_blkspec(m[1]), + self.caller.conv_blkspec(m[3]) if m[3] else None) + return (None, None) + + def parse_add(self): + m = re.match(r'\+([0-9*]+)(.*)', self.arg) + if m: + res, self.arg = (m[1], m[2]) + if res.strip('*') != res: + die(1, f"'+{res}': malformed nBlocks specifier") + if len(res) > 30: + die(1, f"'+{res}': overly long nBlocks specifier") + return self.caller.check_nblocks(eval(res)) # res is only digits plus '*', so eval safe class BlocksInfo: @@ -34,33 +80,33 @@ class BlocksInfo: total_solve_time = 0 header_printed = False - bf = namedtuple('block_info_fields',['fmt_func','src','fs','hdr1','hdr2','key1','key2']) + bf = namedtuple('block_info_fields', ['fmt_func', 'src', 'fs', 'hdr1', 'hdr2', 'key1', 'key2']) # bh=getblockheader, bs=getblockstats, lo=local fields = { - 'block': bf( None, 'bh', '{:<6}', '', 'Block', 'height', None ), - 'hash': bf( None, 'bh', '{:<64}', '', 'Hash', 'hash', None ), - 'date': bf( 'da', 'bh', '{:<19}', '', 'Date', 'time', None ), - 'interval': bf( 'td', 'lo', '{:>8}', 'Solve', 'Time ', 'interval', None ), - 'subsidy': bf( 'su', 'bs', '{:<5}', 'Sub-', 'sidy', 'subsidy', None ), - 'totalfee': bf( 'tf', 'bs', '{:>10}', '', 'Total Fee', 'totalfee', None ), - 'size': bf( None, 'bs', '{:>7}', '', 'Size', 'total_size', None ), - 'weight': bf( None, 'bs', '{:>7}', '', 'Weight', 'total_weight', None ), - 'fee90': bf( 'fe', 'bs', '{:>3}', '90%', 'Fee', 'feerate_percentiles', 4 ), - 'fee75': bf( 'fe', 'bs', '{:>3}', '75%', 'Fee', 'feerate_percentiles', 3 ), - 'fee50': bf( 'fe', 'bs', '{:>3}', '50%', 'Fee', 'feerate_percentiles', 2 ), - 'fee25': bf( 'fe', 'bs', '{:>3}', '25%', 'Fee', 'feerate_percentiles', 1 ), - 'fee10': bf( 'fe', 'bs', '{:>3}', '10%', 'Fee', 'feerate_percentiles', 0 ), - 'fee_max': bf( 'fe', 'bs', '{:>5}', 'Max', 'Fee', 'maxfeerate', None ), - 'fee_avg': bf( 'fe', 'bs', '{:>3}', 'Avg', 'Fee', 'avgfeerate', None ), - 'fee_min': bf( 'fe', 'bs', '{:>3}', 'Min', 'Fee', 'minfeerate', None ), - 'nTx': bf( None, 'bh', '{:>5}', '', ' nTx ', 'nTx', None ), - 'inputs': bf( None, 'bs', '{:>5}', 'In- ', 'puts', 'ins', None ), - 'outputs': bf( None, 'bs', '{:>5}', 'Out-', 'puts', 'outs', None ), - 'utxo_inc': bf( None, 'bs', '{:>6}', ' UTXO', ' Incr', 'utxo_increase', None ), - 'version': bf( None, 'bh', '{:<8}', '', 'Version', 'versionHex', None ), - 'difficulty': bf( 'di', 'bh', '{:<8}', 'Diffi-','culty', 'difficulty', None ), - 'miner': bf( None, 'lo', '{:<5}', '', 'Miner', 'miner', None ), - } + 'block': bf(None, 'bh', '{:<6}', '', 'Block', 'height', None), + 'hash': bf(None, 'bh', '{:<64}', '', 'Hash', 'hash', None), + 'date': bf('da', 'bh', '{:<19}', '', 'Date', 'time', None), + 'interval': bf('td', 'lo', '{:>8}', 'Solve', 'Time ', 'interval', None), + 'subsidy': bf('su', 'bs', '{:<5}', 'Sub-', 'sidy', 'subsidy', None), + 'totalfee': bf('tf', 'bs', '{:>10}', '', 'Total Fee', 'totalfee', None), + 'size': bf(None, 'bs', '{:>7}', '', 'Size', 'total_size', None), + 'weight': bf(None, 'bs', '{:>7}', '', 'Weight', 'total_weight', None), + 'fee90': bf('fe', 'bs', '{:>3}', '90%', 'Fee', 'feerate_percentiles', 4), + 'fee75': bf('fe', 'bs', '{:>3}', '75%', 'Fee', 'feerate_percentiles', 3), + 'fee50': bf('fe', 'bs', '{:>3}', '50%', 'Fee', 'feerate_percentiles', 2), + 'fee25': bf('fe', 'bs', '{:>3}', '25%', 'Fee', 'feerate_percentiles', 1), + 'fee10': bf('fe', 'bs', '{:>3}', '10%', 'Fee', 'feerate_percentiles', 0), + 'fee_max': bf('fe', 'bs', '{:>5}', 'Max', 'Fee', 'maxfeerate', None), + 'fee_avg': bf('fe', 'bs', '{:>3}', 'Avg', 'Fee', 'avgfeerate', None), + 'fee_min': bf('fe', 'bs', '{:>3}', 'Min', 'Fee', 'minfeerate', None), + 'nTx': bf(None, 'bh', '{:>5}', '', ' nTx ', 'nTx', None), + 'inputs': bf(None, 'bs', '{:>5}', 'In- ', 'puts', 'ins', None), + 'outputs': bf(None, 'bs', '{:>5}', 'Out-', 'puts', 'outs', None), + 'utxo_inc': bf(None, 'bs', '{:>6}', ' UTXO', ' Incr', 'utxo_increase', None), + 'version': bf(None, 'bh', '{:<8}', '', 'Version', 'versionHex', None), + 'difficulty': bf('di', 'bh', '{:<8}', 'Diffi-','culty', 'difficulty', None), + 'miner': bf(None, 'lo', '{:<5}', '', 'Miner', 'miner', None)} + dfl_fields = ( 'block', 'date', @@ -74,8 +120,8 @@ class BlocksInfo: 'fee10', 'fee_avg', 'fee_min', - 'version', - ) + 'version') + fixed_fields = ( 'block', # until ≈ 09/01/2028 (block 1000000) 'hash', @@ -84,36 +130,34 @@ class BlocksInfo: 'weight', # until ≈ 2.5x block size increase 'version', 'subsidy', # until ≈ 01/04/2028 (increases by 1 digit per halving until 9th halving [max 10 digits]) - 'difficulty', # until 1.00e+100 (i.e. never) - ) + 'difficulty') # until 1.00e+100 (i.e. never) # column width adjustment data: - fs_lsqueeze = ('totalfee','inputs','outputs','nTx') + fs_lsqueeze = ('totalfee', 'inputs', 'outputs', 'nTx') fs_rsqueeze = () fs_groups = ( - ('fee10','fee25','fee50','fee75','fee90','fee_avg','fee_min','fee_max'), - ) + ('fee10', 'fee25', 'fee50', 'fee75', 'fee90', 'fee_avg', 'fee_min', 'fee_max')) fs_lsqueeze2 = ('interval',) - all_stats = ['col_avg','range','avg','mini_avg','total','diff'] - dfl_stats = ['range','mini_avg','diff'] + all_stats = ['col_avg', 'range', 'avg', 'mini_avg', 'total', 'diff'] + dfl_stats = ['range', 'mini_avg', 'diff'] noindent_stats = ['col_avg'] - avg_stats_skip = {'block', 'hash', 'date', 'version','miner'} + avg_stats_skip = {'block', 'hash', 'date', 'version', 'miner'} - range_data = namedtuple('parsed_range_data',['first','last','from_tip','nblocks','step']) + range_data = namedtuple('parsed_range_data', ['first', 'last', 'from_tip', 'nblocks', 'step']) - t_fmt = lambda self,t: f'{t/86400:.2f} days' if t > 172800 else f'{t/3600:.2f} hrs' + t_fmt = lambda self, t: f'{t/86400:.2f} days' if t > 172800 else f'{t/3600:.2f} hrs' @classmethod - def parse_cslist(cls,uarg,full_set,dfl_set,desc): + def parse_cslist(cls, uarg, full_set, dfl_set, desc): - def make_list(m,func): + def make_list(m, func): groups_lc = [set(e.lower() for e in gi.split(',')) for gi in m.groups()] for group in groups_lc: for e in group: if e not in full_set_lc: - die(1,f'{e!r}: unrecognized {desc}') + die(1, f'{e!r}: unrecognized {desc}') # display elements in order: return [e for e in full_set if e.lower() in func(groups_lc)] @@ -121,47 +165,45 @@ class BlocksInfo: dfl_set_lc = set(e.lower() for e in dfl_set) cspat = r'(\w+(?:,\w+)*)' - for pat,func in ( - ( rf'{cspat}$', lambda g: g[0] ), - ( rf'\+{cspat}$', lambda g: dfl_set_lc | g[0] ), - ( rf'\-{cspat}$', lambda g: dfl_set_lc - g[0] ), - ( rf'\+{cspat}\-{cspat}$', lambda g: ( dfl_set_lc | g[0] ) - g[1] ), - ( rf'\-{cspat}\+{cspat}$', lambda g: ( dfl_set_lc - g[0] ) | g[1] ), - ( rf'all\-{cspat}$', lambda g: full_set_lc - g[0] ) - ): - m = re.match(pat,uarg,re.ASCII|re.IGNORECASE) + for pat, func in ( + (rf'{cspat}$', lambda g: g[0]), + (rf'\+{cspat}$', lambda g: dfl_set_lc | g[0]), + (rf'\-{cspat}$', lambda g: dfl_set_lc - g[0]), + (rf'\+{cspat}\-{cspat}$', lambda g: (dfl_set_lc | g[0]) - g[1]), + (rf'\-{cspat}\+{cspat}$', lambda g: (dfl_set_lc - g[0]) | g[1]), + (rf'all\-{cspat}$', lambda g: full_set_lc - g[0])): + m = re.match(pat, uarg, re.ASCII|re.IGNORECASE) if m: - return make_list(m,func) + return make_list(m, func) else: - die(1,f'{uarg}: invalid parameter') + die(1, f'{uarg}: invalid parameter') - def __init__(self,cfg,cmd_args,rpc): + def __init__(self, cfg, cmd_args, rpc): - def parse_cs_uarg(uarg,full_set,dfl_set,desc): + def parse_cs_uarg(uarg, full_set, dfl_set, desc): return ( full_set if uarg == 'all' else [] if uarg == 'none' else - self.parse_cslist(uarg,full_set,dfl_set,desc) - ) + self.parse_cslist(uarg, full_set, dfl_set, desc)) def get_fields(): - return parse_cs_uarg(self.cfg.fields,list(self.fields),self.dfl_fields,'field') + return parse_cs_uarg(self.cfg.fields, list(self.fields), self.dfl_fields, 'field') def get_stats(): - return parse_cs_uarg(self.cfg.stats.lower(),self.all_stats,self.dfl_stats,'stat') + return parse_cs_uarg(self.cfg.stats.lower(), self.all_stats, self.dfl_stats, 'stat') def parse_cmd_args(): # => (block_list, first, last, step) - if not cmd_args: - return (None,self.tip,self.tip,None) - elif len(cmd_args) == 1: - r = self.parse_rangespec(cmd_args[0]) - return ( - list(range(r.first,r.last+1,r.step)) if r.step else None, - r.first, - r.last, - r.step - ) - else: - return ([self.conv_blkspec(a) for a in cmd_args],None,None,None) + match cmd_args: + case [] | None: + return (None, self.tip, self.tip, None) + case [arg]: + r = self.parse_rangespec(arg) + return ( + list(range(r.first, r.last+1, r.step)) if r.step else None, + r.first, + r.last, + r.step) + case [*args]: + return ([self.conv_blkspec(a) for a in args], None, None, None) self.cfg = cfg self.rpc = rpc @@ -170,7 +212,7 @@ class BlocksInfo: from_satoshi = self.rpc.proto.coin_amt.satoshi to_satoshi = 1 / from_satoshi - self.block_list,self.first,self.last,self.step = parse_cmd_args() + self.block_list, self.first, self.last, self.step = parse_cmd_args() have_segwit = self.rpc.info('segwit_is_active') @@ -181,35 +223,33 @@ class BlocksInfo: self.stats_deps = { 'avg': set(self.fields) - self.avg_stats_skip, 'col_avg': set(self.fields) - self.avg_stats_skip, - 'mini_avg': {'interval','size'} | ({'weight'} if have_segwit else set()), - 'total': {'interval','subsidy','totalfee','nTx','inputs','outputs','utxo_inc'}, + 'mini_avg': {'interval', 'size'} | ({'weight'} if have_segwit else set()), + 'total': {'interval', 'subsidy', 'totalfee', 'nTx', 'inputs', 'outputs', 'utxo_inc'}, 'range': {}, - 'diff': {}, - } + 'diff': {}} self.fmt_funcs = { - 'da': lambda arg: strftime('%Y-%m-%d %X',gmtime(arg)), + 'da': lambda arg: strftime('%Y-%m-%d %X', gmtime(arg)), 'td': lambda arg: ( - '-{:02}:{:02}'.format(abs(arg)//60,abs(arg)%60) if arg < 0 else - ' {:02}:{:02}'.format(arg//60,arg%60) ), + '-{:02}:{:02}'.format(abs(arg)//60, abs(arg)%60) if arg < 0 else + ' {:02}:{:02}'.format(arg//60, arg%60)), 'tf': lambda arg: '{:.8f}'.format(arg * from_satoshi), 'su': lambda arg: str(arg * from_satoshi).rstrip('0').rstrip('.'), 'fe': lambda arg: str(arg), - 'di': lambda arg: '{:.2e}'.format(arg), - } + 'di': lambda arg: '{:.2e}'.format(Decimal(arg))} if self.cfg.coin == 'BCH': self.fmt_funcs.update({ 'su': lambda arg: str(arg).rstrip('0').rstrip('.'), - 'fe': lambda arg: str(int(arg * to_satoshi)), - 'tf': lambda arg: '{:.8f}'.format(arg), - }) + 'fe': lambda arg: str(int(Decimal(arg) * to_satoshi)), + 'tf': lambda arg: '{:.8f}'.format(Decimal(arg))}) self.fnames = tuple( - [f for f in self.fields if self.fields[f].src == 'bh' or f == 'interval'] if self.cfg.header_info else - get_fields() if self.cfg.fields else - self.dfl_fields - ) + [f for f in self.fields if self.fields[f].src == 'bh' or f == 'interval'] + if self.cfg.header_info + else get_fields() if self.cfg.fields + else self.dfl_fields) + if self.cfg.miner_info and 'miner' not in self.fnames: self.fnames += ('miner',) @@ -219,15 +259,15 @@ class BlocksInfo: if 'diff' in self.stats and not self.cfg.stats and self.last != self.tip: self.stats.remove('diff') - if {'avg','col_avg'} <= set(self.stats) and self.cfg.stats_only: + if {'avg', 'col_avg'} <= set(self.stats) and self.cfg.stats_only: self.stats.remove('col_avg') - if {'avg','mini_avg'} <= set(self.stats): + if {'avg', 'mini_avg'} <= set(self.stats): self.stats.remove('mini_avg') if self.cfg.full_stats: add_fnames = {fname for sname in self.stats for fname in self.stats_deps[sname]} - self.fnames = tuple(f for f in self.fields if f in {'block'} | set(self.fnames) | add_fnames ) + self.fnames = tuple(f for f in self.fields if f in {'block'} | set(self.fnames) | add_fnames) else: if 'col_avg' in self.stats and not self.fnames: self.stats.remove('col_avg') @@ -240,8 +280,7 @@ class BlocksInfo: self.bs_keys = set( [v.key1 for v in self.fvals if v.src == 'bs'] + ['total_size'] + - (['total_weight'] if have_segwit else []) - ) + (['total_weight'] if have_segwit else [])) if 'miner' in self.fnames: # capturing parens must contain only ASCII chars! @@ -255,17 +294,16 @@ class BlocksInfo: rb'([\x20-\x7e]{9,})', rb'[/^]([a-zA-Z0-9&. #/-]{5,})', rb'[/^]([_a-zA-Z0-9&. #/-]+)/', - rb'^\x03...\W{0,5}([\\_a-zA-Z0-9&. #/-]+)[/\\]', - )] + rb'^\x03...\W{0,5}([\\_a-zA-Z0-9&. #/-]+)[/\\]')] - self.block_data = namedtuple('block_data',self.fnames) - self.deps = { v.src for v in self.fvals } + self.block_data = namedtuple('block_data', self.fnames) + self.deps = {v.src for v in self.fvals} - def gen_fs(self,fnames,fill=[],fill_char='-',add_name=False): + def gen_fs(self, fnames, fill=[], fill_char='-', add_name=False): for i in range(len(fnames)): name = fnames[i] - ls = (' ','')[name in self.fs_lsqueeze + self.fs_lsqueeze2] - rs = (' ','')[name in self.fs_rsqueeze] + ls = (' ', '')[name in self.fs_lsqueeze + self.fs_lsqueeze2] + rs = (' ', '')[name in self.fs_rsqueeze] if i < len(fnames) - 1 and fnames[i+1] in self.fs_lsqueeze2: rs = '' if i: @@ -274,88 +312,52 @@ class BlocksInfo: ls = '' break repl = (name if add_name else '') + ':' + (fill_char if name in fill else '') - yield (ls + self.fields[name].fs.replace(':',repl) + rs) + yield (ls + self.fields[name].fs.replace(':', repl) + rs) - def conv_blkspec(self,arg): - if str(arg).lower() == 'cur': - return self.tip - elif is_int(arg): - if int(arg) < 0: - die(1,f'{arg}: block number must be non-negative') - elif int(arg) > self.tip: - die(1,f'{arg}: requested block height greater than current chain tip!') - else: - return int(arg) - else: - die(1,f'{arg}: invalid block specifier') + def conv_blkspec(self, arg): + match arg: + case str() if arg.lower() == 'cur': + return self.tip + case x if is_int(x): + match int(arg): + case x if x < 0: + die(1, f'{x}: block number must be non-negative') + case x if x > self.tip: + die(1, f'{x}: requested block height greater than current chain tip!') + case x: + return x + case _: + die(1, f'{arg}: invalid block specifier') - def check_nblocks(self,arg): - if arg <= 0: - die(1,'nBlocks must be a positive integer') - elif arg > self.tip: - die(1, f"'{arg}': nBlocks must be less than current chain height") - return arg + def check_nblocks(self, arg): + match arg: + case x if x <= 0: + die(1, 'nBlocks must be a positive integer') + case x if x > self.tip: + die(1, f'{arg}: nBlocks must be less than current chain height') + case _: + return arg - def parse_rangespec(self,arg): + def parse_rangespec(self, arg): - class RangeParser: - debug = False + p = RangeParser(self, arg) - def __init__(rp,arg): - rp.arg = rp.orig_arg = arg - - def parse(rp,target): - ret = getattr(rp,'parse_'+target)() - if rp.debug: msg(f'arg after parse({target}): {rp.arg}') - return ret - - def finalize(rp): - if rp.arg: - die(1,f'{rp.orig_arg!r}: invalid range specifier') - - def parse_from_tip(rp): - m = re.match(r'-([0-9]+)(.*)',rp.arg) - if m: - res,rp.arg = (m[1],m[2]) - return self.check_nblocks(int(res)) - - def parse_abs_range(rp): - m = re.match(r'([^+-]+)(-([^+-]+)){0,1}(.*)',rp.arg) - if m: - if rp.debug: msg(f'abs_range parse: first={m[1]}, last={m[3]}') - rp.arg = m[4] - return ( - self.conv_blkspec(m[1]), - self.conv_blkspec(m[3]) if m[3] else None - ) - return (None,None) - - def parse_add(rp): - m = re.match(r'\+([0-9*]+)(.*)',rp.arg) - if m: - res,rp.arg = (m[1],m[2]) - if res.strip('*') != res: - die(1,f"'+{res}': malformed nBlocks specifier") - if len(res) > 30: - die(1,f"'+{res}': overly long nBlocks specifier") - return self.check_nblocks(eval(res)) # res is only digits plus '*', so eval safe - - p = RangeParser(arg) - from_tip = p.parse('from_tip') - first,last = (self.tip-from_tip,None) if from_tip else p.parse('abs_range') - add1 = p.parse('add') - add2 = p.parse('add') + from_tip = p.parse('from_tip') + first, last = (self.tip-from_tip, None) if from_tip else p.parse('abs_range') + add1 = p.parse('add') + add2 = p.parse('add') p.finalize() if add2 and last is not None: - die(1,f'{arg!r}: invalid range specifier') + die(1, f'{arg!r}: invalid range specifier') - nblocks,step = (add1,add2) if last is None else (None,add1) + nblocks, step = (add1, add2) if last is None else (None, add1) - if p.debug: msg(repr(self.range_data(first,last,from_tip,nblocks,step))) + if p.debug: + msg(repr(self.range_data(first, last, from_tip, nblocks, step))) if nblocks: - if first == None: + if first is None: first = self.tip - nblocks + 1 last = first + nblocks - 1 @@ -363,12 +365,12 @@ class BlocksInfo: last = self.conv_blkspec(last or first) if p.debug: - msg(repr(self.range_data(first,last,from_tip,nblocks,step))) + msg(repr(self.range_data(first, last, from_tip, nblocks, step))) if first > last: - die(1,f'{first}-{last}: invalid block range') + die(1, f'{first}-{last}: invalid block range') - return self.range_data(first,last,from_tip,nblocks,step) + return self.range_data(first, last, from_tip, nblocks, step) async def process_blocks(self): @@ -378,7 +380,7 @@ class BlocksInfo: c = self.rpc - heights = self.block_list or range(self.first,self.last+1) + heights = self.block_list or range(self.first, self.last+1) self.hdrs = await get_hdrs(heights) if self.block_list: @@ -387,8 +389,7 @@ class BlocksInfo: else: self.first_prev_hdr = ( self.hdrs[0] if heights[0] == 0 else - await c.call('getblockheader',await c.call('getblockhash',heights[0]-1)) - ) + await c.call('getblockheader', await c.call('getblockhash', heights[0]-1))) self.t_cur = self.first_prev_hdr['time'] self.res = [] @@ -399,16 +400,16 @@ class BlocksInfo: ret = await self.process_block(self.hdrs[n]) self.res.append(ret) if self.fnames and not self.cfg.stats_only: - self.output_block(ret,n) + self.output_block(ret, n) - def output_block(self,data,n): + def output_block(self, data, n): def gen(): - for k,v in data._asdict().items(): + for k, v in data._asdict().items(): func = self.fields[k].fmt_func yield self.fmt_funcs[func](v) if func else v Msg(self.fs.format(*gen())) - async def process_block(self,hdr): + async def process_block(self, hdr): self.t_diff = hdr['time'] - self.t_cur self.t_cur = hdr['time'] @@ -416,14 +417,12 @@ class BlocksInfo: blk_data = { 'bh': hdr, - 'lo': { 'interval': self.t_diff } - } + 'lo': {'interval': self.t_diff}} if 'bs' in self.deps: bs = ( self.genesis_stats if hdr['height'] == 0 else - await self.rpc.call('getblockstats',hdr['hash'],list(self.bs_keys)) - ) + await self.rpc.call('getblockstats', hdr['hash'], list(self.bs_keys))) self.total_bytes += bs['total_size'] if 'total_weight' in bs: self.total_weight += bs['total_weight'] @@ -436,14 +435,13 @@ class BlocksInfo: for v in self.fvals: yield ( blk_data[v.src][v.key1] if v.key2 is None else - blk_data[v.src][v.key1][v.key2] - ) + blk_data[v.src][v.key1][v.key2]) return self.block_data(*gen()) - async def get_miner_string(self,H): - tx0 = (await self.rpc.call('getblock',H))['tx'][0] - bd = await self.rpc.call('getrawtransaction',tx0,1) + async def get_miner_string(self, H): + tx0 = (await self.rpc.call('getblock', H))['tx'][0] + bd = await self.rpc.call('getrawtransaction', tx0, 1) if type(bd) == tuple: return '---' else: @@ -454,13 +452,12 @@ class BlocksInfo: trmap_in = { '\\': ' ', '/': ' ', - ',': ' ', - } - trmap = { ord(a):b for a,b in trmap_in.items() } + ',': ' '} + trmap = {ord(a): b for a, b in trmap_in.items()} for pat in self.miner_pats: m = pat.search(cb) if m: - return re.sub( r'\s+', ' ', m[1].decode().strip('^').translate(trmap).strip() ) + return re.sub(r'\s+', ' ', m[1].decode().strip('^').translate(trmap).strip()) return '' def print_header(self): @@ -474,27 +471,28 @@ class BlocksInfo: yield self.fs.format(*hdr1) yield self.fs.format(*hdr2) - def process_stats(self,sname): - method = getattr(self,f'create_{sname}_stats',None) - return self.output_stats(method() if method else self.create_stats(sname),sname) + def process_stats(self, sname): + method = getattr(self, f'create_{sname}_stats', None) + return self.output_stats(method() if method else self.create_stats(sname), sname) - def fmt_stat_item(self,fs,s): + def fmt_stat_item(self, fs, s): return fs.format(s) if type(fs) == str else fs(s) - async def output_stats(self,res,sname): + async def output_stats(self, res, sname): def gen(data): for d in data: - if len(d) == 2: - yield (indent+d[0]).format(**{k:self.fmt_stat_item(*v) for k,v in d[1].items()}) - elif len(d) == 4: - yield (indent+d[0]).format(self.fmt_stat_item(d[2],d[3])) - elif type(d) == str: - yield d - else: - assert False, f'{d}: invalid stats data' + match d: + case [a, b]: + yield (indent + a).format(**{k: self.fmt_stat_item(*v) for k, v in b.items()}) + case [a, _, b, c]: + yield (indent + a).format(self.fmt_stat_item(b, c)) + case str(): + yield d + case _: + assert False, f'{d}: invalid stats data' - foo,data = await res + foo, data = await res indent = '' if sname in self.noindent_stats else ' ' Msg('\n'.join(gen(data))) @@ -513,15 +511,14 @@ class BlocksInfo: 'range': ('{}', self.hdrs[-1]['height'] - self.hdrs[0]['height'] + 1), 'elapsed': (self.t_fmt, elapsed), 'nBlocks': ('{}', total_blks), - 'step': ('{}', self.step), - } - ) - if elapsed: - yield ( 'Start: {}', 'start_date', self.fmt_funcs['da'], self.hdrs[0]['time'] ) - yield ( 'End: {}', 'end_date', self.fmt_funcs['da'], self.hdrs[-1]['time'] ) - yield ( 'Avg BDI: {} min', 'avg_bdi', '{:.2f}', elapsed / nblocks / 60 ) + 'step': ('{}', self.step)}) - return ( 'range', gen() ) + if elapsed: + yield ('Start: {}', 'start_date', self.fmt_funcs['da'], self.hdrs[0]['time']) + yield ('End: {}', 'end_date', self.fmt_funcs['da'], self.hdrs[-1]['time']) + yield ('Avg BDI: {} min', 'avg_bdi', '{:.2f}', elapsed / nblocks / 60) + + return ('range', gen()) async def create_diff_stats(self): @@ -530,26 +527,25 @@ class BlocksInfo: tip_hdr = ( self.hdrs[-1] if self.hdrs[-1]['height'] == self.tip else - await c.call('getblockheader',await c.call('getblockhash',self.tip)) - ) + await c.call('getblockheader', await c.call('getblockhash', self.tip))) min_sample_blks = 432 # ≈3 days - rel_hdr = await c.call('getblockheader',await c.call('getblockhash',self.tip-rel)) + rel_hdr = await c.call('getblockheader', await c.call('getblockhash', self.tip-rel)) if rel >= min_sample_blks: sample_blks = rel - bdi = ( tip_hdr['time'] - rel_hdr['time'] ) / rel + bdi = (tip_hdr['time'] - rel_hdr['time']) / rel else: - sample_blks = min(min_sample_blks,self.tip) - start_hdr = await c.call('getblockheader',await c.call('getblockhash',self.tip-sample_blks)) - diff_adj = float(tip_hdr['difficulty'] / start_hdr['difficulty']) + sample_blks = min(min_sample_blks, self.tip) + start_hdr = await c.call('getblockheader', await c.call('getblockhash', self.tip-sample_blks)) + diff_adj = Decimal(tip_hdr['difficulty']) / Decimal(start_hdr['difficulty']) time1 = rel_hdr['time'] - start_hdr['time'] time2 = tip_hdr['time'] - rel_hdr['time'] bdi = ((time1 * diff_adj) + time2) / sample_blks rem = self.rpc.proto.diff_adjust_interval - rel - return ( 'difficulty', ( + return ('difficulty', ( 'Difficulty Statistics:', ('Current height: {}', 'chain_tip', '{}', self.tip), ('Next diff adjust: {next_diff_adjust} (in {blks_remaining} block%s [{time_remaining}])' % suf(rem), @@ -565,112 +561,113 @@ class BlocksInfo: 'sample_blks': ('{}', sample_blks) } ), - ('Cur difficulty: {}', 'cur_diff', '{:.2e}', tip_hdr['difficulty']), + ('Cur difficulty: {}', 'cur_diff', '{:.2e}', Decimal(tip_hdr['difficulty'])), ('Est. diff adjust: {}%', 'est_diff_adjust_pct', '{:+.2f}', ((600 / bdi) - 1) * 100), )) + def sum_field_avg(self, field): + return self.sum_field_total(field) // len(self.res) + + def sum_field_total(self, field): + if isinstance(getattr(self.res[0], field), str): + return sum(Decimal(getattr(block, field)) for block in self.res) + else: + return sum(getattr(block, field) for block in self.res) + async def create_col_avg_stats(self): def gen(): for field in self.fnames: if field in self.avg_stats_skip: - yield ( field, ('{}','') ) + yield (field, ('{}', '')) else: - ret = sum(getattr(block,field) for block in self.res) // len(self.res) + ret = self.sum_field_avg(field) func = self.fields[field].fmt_func - yield ( field, ( (self.fmt_funcs[func] if func else '{}'), ret )) + yield (field, ((self.fmt_funcs[func] if func else '{}'), ret)) if not self.header_printed: self.print_header() - fs = ''.join(self.gen_fs(self.fnames,fill=self.avg_stats_skip,add_name=True)).strip() - return ('column_averages', ('Column averages:', (fs, dict(gen())) )) + fs = ''.join(self.gen_fs(self.fnames, fill=self.avg_stats_skip, add_name=True)).strip() + return ('column_averages', ('Column averages:', (fs, dict(gen())))) - def avg_stats_data(self,data,spec_conv,spec_val): + def avg_stats_data(self, data, spec_conv, spec_val): coin = self.rpc.proto.coin + return data( hdr = 'Averages for processed blocks:', - func = lambda field: sum(getattr(block,field) for block in self.res) // len(self.res), - spec_sufs = { 'subsidy': f' {coin}', 'totalfee': f' {coin}' }, + func = self.sum_field_avg, + spec_sufs = {'subsidy': f' {coin}', 'totalfee': f' {coin}'}, spec_convs = { - 'interval': spec_conv(0, lambda arg: secs_to_ms(arg)), + 'interval': spec_conv(0, lambda arg: secs_to_ms(arg)), 'utxo_inc': spec_conv(-1, '{:<+}'), - 'mb_per_hour': spec_conv(0, '{}'), - }, + 'mb_per_hour': spec_conv(0, '{}')}, spec_vals = ( spec_val( 'mb_per_hour', 'MB/hr', 'interval', lambda values: 'bs' in self.deps, lambda values: ( '{:.4f}'.format((self.total_bytes / 10000) / (self.total_solve_time / 36)) - if self.total_solve_time else 'N/A' ), - ), - ) - ) + if self.total_solve_time else 'N/A')), + )) mini_avg_stats_data = avg_stats_data - def total_stats_data(self,data,spec_conv,spec_val): + def total_stats_data(self, data, spec_conv, spec_val): coin = self.rpc.proto.coin return data( hdr = 'Totals for processed blocks:', - func = lambda field: sum(getattr(block,field) for block in self.res), - spec_sufs = { 'subsidy': f' {coin}', 'totalfee': f' {coin}', 'reward': f' {coin}' }, + func = self.sum_field_total, + spec_sufs = {'subsidy': f' {coin}', 'totalfee': f' {coin}', 'reward': f' {coin}'}, spec_convs = { - 'interval': spec_conv(0, lambda arg: secs_to_dhms(arg)), + 'interval': spec_conv(0, lambda arg: secs_to_dhms(arg)), 'utxo_inc': spec_conv(-1, '{:<+}'), - 'reward': spec_conv(0, self.fmt_funcs['tf']), - }, + 'reward': spec_conv(0, self.fmt_funcs['tf'])}, spec_vals = ( spec_val( 'reward', 'Reward', 'totalfee', - lambda values: {'subsidy','totalfee'} <= set(values), - lambda values: values['subsidy'] + values['totalfee'] - ), - ) - ) + lambda values: {'subsidy', 'totalfee'} <= set(values), + lambda values: values['subsidy'] + values['totalfee']), + )) - async def create_stats(self,sname): + async def create_stats(self, sname): def convert_stats_hdr(field): v = self.fields[field] - return '{} {}'.format(v.hdr1.strip(), v.hdr2.strip()).replace('- ','') if v.hdr1 else v.hdr2.strip() + return '{} {}'.format( + v.hdr1.strip(), v.hdr2.strip()).replace('- ', '') if v.hdr1 else v.hdr2.strip() - d = getattr(self,f'{sname}_stats_data')( - namedtuple('stats_data',['hdr','func','spec_sufs','spec_convs','spec_vals']), - namedtuple('spec_conv',['width_adj','conv']), - namedtuple('spec_val',['name','lbl','insert_after','condition','code']) - ) + d = getattr(self, f'{sname}_stats_data')( + namedtuple('stats_data', ['hdr', 'func', 'spec_sufs', 'spec_convs', 'spec_vals']), + namedtuple('spec_conv', ['width_adj', 'conv']), + namedtuple('spec_val', ['name', 'lbl', 'insert_after', 'condition', 'code'])) fnames = [n for n in self.fnames if n in self.stats_deps[sname]] - lbls = {n:convert_stats_hdr(n) for n in fnames} - values = {n:d.func(n) for n in fnames} - col1_w = max((len(l) for l in lbls.values()),default=0) + 2 + lbls = {n: convert_stats_hdr(n) for n in fnames} + values = {n: d.func(n) for n in fnames} + col1_w = max((len(l) for l in lbls.values()), default=0) + 2 for v in d.spec_vals: if v.condition(values): try: idx = fnames.index(v.insert_after) + 1 except: idx = 0 - fnames.insert(idx,v.name) + fnames.insert(idx, v.name) lbls[v.name] = v.lbl values[v.name] = v.code(values) def gen(): - for n,fname in enumerate(fnames): + for n, fname in enumerate(fnames): spec_conv = d.spec_convs.get(fname) yield ( '{lbl:{wid}} {{}}{suf}'.format( lbl = lbls[fname] + ':', wid = col1_w + (spec_conv.width_adj if spec_conv else 0), - suf = d.spec_sufs.get(fname) or '' - ), + suf = d.spec_sufs.get(fname) or ''), fname, spec_conv.conv if spec_conv else ( - (lambda x: self.fmt_funcs[x] if x else '{}')(self.fields[fname].fmt_func) - ), - values[fname] - ) + (lambda x: self.fmt_funcs[x] if x else '{}')(self.fields[fname].fmt_func)), + values[fname]) - return ( sname, (d.hdr,) + tuple(gen()) ) + return (sname, (d.hdr,) + tuple(gen())) - def process_stats_pre(self,i): + def process_stats_pre(self, i): if (self.fnames and not self.cfg.stats_only) or i != 0: Msg('') @@ -703,13 +700,12 @@ class BlocksInfo: 'totalfee': 0, 'txs': 1, 'utxo_increase': 1, - 'utxo_size_inc': 117 - } + 'utxo_size_inc': 117} class JSONBlocksInfo(BlocksInfo): - def __init__(self,cfg,cmd_args,opt,rpc): - super().__init__(cfg,cmd_args,opt,rpc) + def __init__(self, cfg, cmd_args, rpc): + super().__init__(cfg, cmd_args, rpc) if self.cfg.json_raw: self.output_block = self.output_block_raw self.fmt_stat_item = self.fmt_stat_item_raw @@ -720,37 +716,40 @@ class JSONBlocksInfo(BlocksInfo): await super().process_blocks() Msg_r(']') - def output_block_raw(self,data,n): - Msg_r( (', ','')[n==0] + json.dumps(data._asdict(),cls=json_encoder) ) + def output_block_raw(self, data, n): + Msg_r((', ', '')[n==0] + json.dumps(data._asdict(), cls=json_encoder)) - def output_block(self,data,n): + def output_block(self, data, n): def gen(): - for k,v in data._asdict().items(): + for k, v in data._asdict().items(): func = self.fields[k].fmt_func - yield ( k, (self.fmt_funcs[func](v) if func else v) ) - Msg_r( (', ','')[n==0] + json.dumps(dict(gen()),cls=json_encoder) ) + yield (k, (self.fmt_funcs[func](v) if func else v)) + Msg_r((', ', '')[n==0] + json.dumps(dict(gen()), cls=json_encoder)) def print_header(self): pass - def fmt_stat_item_raw(self,fs,s): + def fmt_stat_item_raw(self, fs, s): return s - async def output_stats(self,res,sname): + async def output_stats(self, res, sname): def gen(data): for d in data: - if len(d) == 2: - for k,v in d[1].items(): - yield (k,self.fmt_stat_item(*v)) - elif len(d) == 4: - yield (d[1],self.fmt_stat_item(d[2],d[3])) - elif type(d) != str: - assert False, f'{d}: invalid stats data' + match d: + case [_, a]: + for k, v in a.items(): + yield (k, self.fmt_stat_item(*v)) + case [_, a, b, c]: + yield (a, self.fmt_stat_item(b, c)) + case str(): + pass + case _: + assert False, f'{d}: invalid stats data' - varname,data = await res - Msg_r(', "{}_data": {}'.format( varname, json.dumps(dict(gen(data)),cls=json_encoder) )) + varname, data = await res + Msg_r(', "{}_data": {}'.format(varname, json.dumps(dict(gen(data)), cls=json_encoder))) - def process_stats_pre(self,i): pass + def process_stats_pre(self, i): pass def finalize_output(self): Msg('}') diff --git a/mmgen_node_tools/Misc.py b/mmgen_node_tools/Misc.py index 999b68b..5aa27f5 100755 --- a/mmgen_node_tools/Misc.py +++ b/mmgen_node_tools/Misc.py @@ -5,8 +5,8 @@ # 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 +# https://github.com/mmgen/mmgen-wallet https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen-wallet https://gitlab.com/mmgen/mmgen-node-tools """ mmgen_node_tools.Misc: miscellaneous data and functions for the MMGen Node Tools suite diff --git a/mmgen_node_tools/PeerBlocks.py b/mmgen_node_tools/PeerBlocks.py index 0b386fb..3329f07 100755 --- a/mmgen_node_tools/PeerBlocks.py +++ b/mmgen_node_tools/PeerBlocks.py @@ -5,8 +5,8 @@ # Licensed under the GNU General Public License, Version 3: # https://www.gnu.org/licenses # Public project repositories: -# https://github.com/mmgen/mmgen -# https://gitlab.com/mmgen/mmgen +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet """ mmgen_node_tools.PeerBlocks: List blocks in flight, disconnect stalling nodes @@ -14,66 +14,66 @@ mmgen_node_tools.PeerBlocks: List blocks in flight, disconnect stalling nodes import asyncio from collections import namedtuple -from mmgen.util import msg,msg_r,is_int -from mmgen.term import get_term,get_terminal_size,get_char +from mmgen.util import msg, msg_r, is_int +from mmgen.term import get_term, get_terminal_size, get_char from mmgen.ui import line_input from .PollDisplay import PollDisplay -RED,RESET = ('\033[31m','\033[0m') -COLORS = ['\033[38;5;%s;1m' % c for c in list(range(247,256)) + [231]] -ERASE_ALL,CUR_HOME = ('\033[J','\033[H') -CUR_HIDE,CUR_SHOW = ('\033[?25l','\033[?25h') +RED, RESET = ('\033[31m', '\033[0m') +COLORS = ['\033[38;5;%s;1m' % c for c in list(range(247, 256)) + [231]] +ERASE_ALL, CUR_HOME = ('\033[J', '\033[H') +CUR_HIDE, CUR_SHOW = ('\033[?25l', '\033[?25h') term = None class Display(PollDisplay): poll_secs = 2 - def __init__(self,cfg): + def __init__(self, cfg): super().__init__(cfg) - global term,term_width + global term, term_width if not term: term = get_term() term.init(noecho=True) term_width = self.cfg.columns or get_terminal_size().width msg_r(CUR_HOME+ERASE_ALL+CUR_HOME) - async def get_info(self,rpc): + async def get_info(self, rpc): return await rpc.call('getpeerinfo') - def display(self,count): + def display(self, count): msg_r( CUR_HOME + (ERASE_ALL if count == 1 else '') + 'CONNECTED PEERS ({a}) {b} - poll {c}'.format( a = len(self.info), b = self.desc, - c = count ).ljust(term_width)[:term_width] + c = count).ljust(term_width)[:term_width] + '\n' + ('\n'.join(self.gen_display()) + '\n' if self.info else '') + ERASE_ALL + f"Type a peer number to disconnect, 'q' to quit, or any other key for {self.other_desc} display:" - + '\b' ) + + '\b') - async def disconnect_node(self,rpc,addr): - return await rpc.call('disconnectnode',addr) + async def disconnect_node(self, rpc, addr): + return await rpc.call('disconnectnode', addr) def get_input(self): - s = get_char(immed_chars='q0123456789',prehold_protect=False,num_bytes=1) + s = get_char(immed_chars='q0123456789', prehold_protect=False, num_bytes=1) if not is_int(s): return s with self.info_lock: msg('') term.reset() # readline required for correct operation here; without it, user must re-type first digit - ret = line_input( self.cfg, 'peer number> ', insert_txt=s, hold_protect=False ) + ret = line_input(self.cfg, 'peer number> ', insert_txt=s, hold_protect=False) term.init(noecho=True) self.enable_display = False # prevent display from updating before process_input() return ret - async def process_input(self,rpc): + async def process_input(self, rpc): ids = tuple(str(i['id']) for i in self.info) ret = False @@ -83,7 +83,7 @@ class Display(PollDisplay): from mmgen.exception import RPCFailure addr = self.info[ids.index(self.input)]['addr'] try: - await self.disconnect_node(rpc,addr) + await self.disconnect_node(rpc, addr) except RPCFailure: msg_r(f'Unable to disconnect peer {self.input} ({addr})') else: @@ -105,8 +105,8 @@ class BlocksDisplay(Display): def gen_display(self): - pd = namedtuple('peer_data',['id','blks_data','blks_width']) - bd = namedtuple('block_datum',['num','disp']) + pd = namedtuple('peer_data', ['id', 'blks_data', 'blks_width']) + bd = namedtuple('block_datum', ['num', 'disp']) def gen_block_data(): global min_height @@ -114,15 +114,15 @@ class BlocksDisplay(Display): for d in self.info: if d.get('inflight'): blocks = d['inflight'] - min_height = min(blocks) if not min_height else min(min_height,min(blocks)) - line = ' '.join(map(str,blocks))[:blks_field_width] + min_height = min(blocks) if not min_height else min(min_height, min(blocks)) + line = ' '.join(map(str, blocks))[:blks_field_width] blocks_disp = line.split() yield pd( d['id'], - [bd(blocks[i],blocks_disp[i]) for i in range(len(blocks_disp))], - len(line) ) + [bd(blocks[i], blocks_disp[i]) for i in range(len(blocks_disp))], + len(line)) else: - yield pd(d['id'],[],0) + yield pd(d['id'], [], 0) def gen_line(peer_data): for blk in peer_data.blks_data: @@ -136,7 +136,7 @@ class BlocksDisplay(Display): for peer_data in tuple(gen_block_data()): yield fs.format( peer_data.id, - ' '.join(gen_line(peer_data)) + ' ' * (blks_field_width - peer_data.blks_width) ) + ' '.join(gen_line(peer_data)) + ' ' * (blks_field_width - peer_data.blks_width)) class PeersDisplay(Display): @@ -152,5 +152,4 @@ class PeersDisplay(Display): A = id_width, b = d['addr'], B = addr_width, - c = d['subver'] - ).ljust(term_width)[:term_width] + c = d['subver']).ljust(term_width)[:term_width] diff --git a/mmgen_node_tools/PollDisplay.py b/mmgen_node_tools/PollDisplay.py index 84a6023..8274e40 100755 --- a/mmgen_node_tools/PollDisplay.py +++ b/mmgen_node_tools/PollDisplay.py @@ -5,35 +5,35 @@ # Licensed under the GNU General Public License, Version 3: # https://www.gnu.org/licenses # Public project repositories: -# https://github.com/mmgen/mmgen -# https://gitlab.com/mmgen/mmgen +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet """ mmgen_node_tools.PollDisplay: update and display RPC data; get input from user """ -import sys,threading +import sys, threading from mmgen.util import msg from mmgen.term import get_char -class PollDisplay(): +class PollDisplay: info = None input = None poll_secs = 1 - def __init__(self,cfg): + def __init__(self, cfg): self.cfg = cfg self.info_lock = threading.Lock() # self.info accessed by 2 threads self.display_kill_flag = threading.Event() def get_input(self): - return get_char(immed_chars='q',prehold_protect=False,num_bytes=1) + return get_char(immed_chars='q', prehold_protect=False, num_bytes=1) - async def process_input(self,rpc): + async def process_input(self, rpc): return True - async def run(self,rpc): + async def run(self, rpc): async def do_display(): with self.info_lock: @@ -52,7 +52,7 @@ class PollDisplay(): count += 1 async def process_input(): - if self.input == None: + if self.input is None: sys.exit(1) elif self.input == 'q': msg('') @@ -68,7 +68,7 @@ class PollDisplay(): self.display_kill_flag.set() while True: - threading.Thread(target=get_input,daemon=True).start() + threading.Thread(target=get_input, daemon=True).start() await do_display() if await process_input(): break diff --git a/mmgen_node_tools/Sound.py b/mmgen_node_tools/Sound.py index 271253c..557b97a 100755 --- a/mmgen_node_tools/Sound.py +++ b/mmgen_node_tools/Sound.py @@ -19,25 +19,28 @@ mmgen_node_tools.Sound: audio-related functions for MMGen node tools """ -import sys,os,time -from mmgen_node_tools.Util import * +import sys, os, time + +from mmgen.util import die + +from mmgen_node_tools.Util import do_system _alsa_config_file = '/tmp/alsa-config-' + os.path.basename(sys.argv[0]) -_dvols = { 'Master': 78, 'Speaker': 78, 'Headphone': 15, 'PCM': 190 } +_dvols = {'Master': 78, 'Speaker': 78, 'Headphone': 15, 'PCM': 190} def timespec2secs(ts): import re - mul = { 's': 1, 'm': 60, 'h': 60*60, 'd': 60*60*24 } + mul = {'s': 1, 'm': 60, 'h': 60*60, 'd': 60*60*24} pat = r'^([0-9]+)([smhd]*)$' - m = re.match(pat,ts) - if m == None: + m = re.match(pat, ts) + if m is None: die(2,"'%s': invalid time specifier" % ts) - a,b = m.groups() + a, b = m.groups() return int(a) * (mul[b] if b else 1) def parse_repeat_spec(rs): - return [(timespec2secs(i),timespec2secs(j)) - for i,j in [a.split(':') for a in rs.split(',')]] + return [(timespec2secs(i), timespec2secs(j)) + for i, j in [a.split(':') for a in rs.split(',')]] def init_sound(): def _restore_sound(): @@ -48,33 +51,33 @@ def init_sound(): atexit.register(_restore_sound) do_system('sudo alsactl store -f ' + _alsa_config_file) -def play_sound(fn,vol,repeat_spec='',remote_host='',kill_flg=None,testing=False): +def play_sound(fn, vol, repeat_spec='', remote_host='', kill_flg=None, testing=False): if not remote_host: do_system('sudo alsactl store -f ' + _alsa_config_file) - for k in 'Master','Speaker','Headphone': - do_system(('sudo amixer -q set %s on' % k),testing) + for k in 'Master', 'Speaker', 'Headphone': + do_system(('sudo amixer -q set %s on' % k), testing) # do_system('amixer -q set Headphone off') - vols = dict([(k,int(_dvols[k] * float(vol) / 100)) for k in _dvols]) + vols = dict([(k, int(_dvols[k] * float(vol) / 100)) for k in _dvols]) for k in vols: - do_system('sudo amixer -q set %s %s' % (k,vols[k]),testing) + do_system('sudo amixer -q set %s %s' % (k, vols[k]), testing) fn = os.path.expanduser(fn) cmd = ( 'aplay -q %s' % fn, - 'ssh %s mmnode-play-sound -v%d %s' % (remote_host,vol,fn) + 'ssh %s mmnode-play-sound -v%d %s' % (remote_host, vol, fn) )[bool(remote_host)] if repeat_spec and kill_flg: - for interval,duration in parse_repeat_spec(repeat_spec): + for interval, duration in parse_repeat_spec(repeat_spec): start = time.time() while time.time() < start + duration: - do_system(cmd,testing) + do_system(cmd, testing) if kill_flg.wait(interval): if not remote_host: do_system('sudo alsactl restore -f ' + _alsa_config_file) return else: # Play once - do_system(cmd,testing) + do_system(cmd, testing) if not remote_host: do_system('sudo alsactl restore -f ' + _alsa_config_file) diff --git a/mmgen_node_tools/Ticker.py b/mmgen_node_tools/Ticker.py index e4b147c..a0adb39 100755 --- a/mmgen_node_tools/Ticker.py +++ b/mmgen_node_tools/Ticker.py @@ -5,43 +5,370 @@ # 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 +# https://github.com/mmgen/mmgen-wallet https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen-wallet 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. +# v3.2.dev4: switch to new coinpaprika ‘tickers’ API call (supports ‘limit’ parameter, more historical 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 +import os, re, time, datetime, json, yaml, random +from subprocess import run, PIPE, CalledProcessError from decimal import Decimal from collections import namedtuple -from mmgen.color import * -from mmgen.util import die,fmt_list,msg,msg_r,suf,fmt + +from mmgen.color import red, yellow, green, blue, orange, gray, cyan, pink +from mmgen.util import msg, msg_r, rmsg, Msg, Msg_r, die, fmt, fmt_list, fmt_dict, list_gen, suf, is_int +from mmgen.ui import do_pager homedir = os.getenv('HOME') -cachedir = os.path.join(homedir,'.cache','mmgen-node-tools') +dfl_cachedir = os.path.join(homedir, '.cache', 'mmgen-node-tools') cfg_fn = 'ticker-cfg.yaml' portfolio_fn = 'ticker-portfolio.yaml' +asset_tuple = namedtuple('asset_tuple', ['symbol', 'id', 'source']) +last_api_host = None + +percent_cols = { + 'd': 'day', + 'w': 'week', + 'm': 'month', + 'y': 'year'} + +sp = namedtuple('sort_parameter', ['key', 'sort_dfl', 'desc']) +sort_params = { + 'd': sp('percent_change_24h', 0.0, '1-day percent change'), + 'w': sp('percent_change_7d', 0.0, '1-week percent change'), + 'm': sp('percent_change_30d', 0.0, '1-month percent change'), + 'y': sp('percent_change_1y', 0.0, '1-year percent change'), + 'p': sp('price_usd', Decimal(0), 'asset price'), + 'c': sp('market_cap', 0, 'market cap')} + +class RowDict(dict): + + def __iter__(self): + return (e for v in self.values() for e in v) + +class DataSource: + + source_groups = [ + { + 'cc': 'coinpaprika' + }, { + 'fi': 'yahoospot', + 'hi': 'yahoohist', + }] + + @classmethod + def get_sources(cls, randomize=False): + g = random.sample(cls.source_groups, k=len(cls.source_groups)) if randomize else cls.source_groups + return {k: v for a in g for k, v in a.items()} + + class base: + + def fetch_delay(self): + global last_api_host + if not gcfg.testing and last_api_host and last_api_host != self.api_host: + delay = 1 + random.randrange(1, 5000) / 1000 + msg_r(f'Waiting {delay:.3f} seconds...') + time.sleep(delay) + msg('') + last_api_host = self.api_host + + def get_data_from_network(self): + + curl_cmd = list_gen( + ['curl', '--tr-encoding', '--header', 'Accept: application/json', True], + ['--compressed'], # adds 'Accept-Encoding: gzip' + ['--proxy', cfg.proxy, isinstance(cfg.proxy, str)], + ['--silent', not cfg.verbose], + ['--connect-timeout', str(gcfg.http_timeout), gcfg.http_timeout], + [self.api_url]) + + if gcfg.testing: + Msg(fmt_list(curl_cmd, fmt='bare')) + return + + try: + return run(curl_cmd, check=True, stdout=PIPE).stdout.decode() + 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}') + + def get_data(self): + + if not os.path.exists(cfg.cachedir): + os.makedirs(cfg.cachedir) + + use_cached_data = cfg.cached_data and not gcfg.download + + if use_cached_data: + data_type = 'json' + try: + data_in = open(self.json_fn).read() + except FileNotFoundError: + die(1, f'Cannot use cached data, because {self.json_fn_disp} does not exist') + else: + data_type = self.net_data_type + try: + mtime = os.stat(self.json_fn).st_mtime + except FileNotFoundError: + mtime = 0 + if (elapsed := int(time.time() - mtime)) >= self.timeout or gcfg.testing: + if gcfg.testing: + msg('') + self.fetch_delay() + msg_r(f'Fetching {self.data_desc} from {self.api_host}...') + if self.has_verbose and cfg.verbose: + msg('') + data_in = self.get_data_from_network() + msg('done') + if gcfg.testing: + return {} + else: + die(1, self.rate_limit_errmsg(elapsed)) + + match data_type: + case 'json': + try: + data = json.loads(data_in) + except: + self.json_data_error_msg(data_in) + die(2, 'Retrieved data is not valid JSON, exiting') + json_text = data_in + case 'python': + data = data_in + json_text = json.dumps(data_in) + + if not data: + if use_cached_data: + die(1, + f'No cached {self.data_desc}! Run command without the --cached-data option, ' + 'or use --download to retrieve data from remote host') + else: + die(2, 'Remote host returned no data!') + elif 'error' in data: + die(1, data['error']) + + self.data = self.postprocess_data(data) + + if use_cached_data: + self.json_text = None + if not cfg.quiet: + msg(f'Using cached data from {self.json_fn_disp}') + else: + self.json_text = json_text + if cache_data(self, no_overwrite=True): + self.json_text = None + + return self + + def json_data_error_msg(self, json_text): + pass + + def postprocess_data(self, data): + return data + + @property + def json_fn_disp(self): + return '~/' + os.path.relpath(self.json_fn, start=homedir) + + class coinpaprika(base): + desc = 'CoinPaprika' + data_desc = 'cryptocurrency data' + api_host = 'api.coinpaprika.com' + api_proto = 'https' + ratelimit = 240 + btc_ratelimit = 10 + net_data_type = 'json' + has_verbose = True + dfl_asset_limit = 2000 + max_asset_idx = 1_000_000 + + def __init__(self): + self.asset_limit = int(cfg.asset_limit) if is_int(cfg.asset_limit) else self.dfl_asset_limit + + def rate_limit_errmsg(self, elapsed): + rem = self.timeout - elapsed + return ( + f'Rate limit exceeded! Retry in {rem} second{suf(rem)}' + + ('' if cfg.btc_only else ', or use --cached-data or --btc')) + + @property + def api_url(self): + return ( + f'{self.api_proto}://{self.api_host}/v1/tickers/btc-bitcoin' + if cfg.btc_only else + f'{self.api_proto}://{self.api_host}/v1/tickers?limit={self.asset_limit}' + if self.asset_limit else + f'{self.api_proto}://{self.api_host}/v1/tickers') + + @property + def json_fn(self): + return os.path.join( + cfg.cachedir, + 'ticker-btc.json' if cfg.btc_only else 'ticker.json') + + @property + def timeout(self): + return 0 if gcfg.test_suite else self.btc_ratelimit if cfg.btc_only else self.ratelimit + + def json_data_error_msg(self, json_text): + 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: + + {self.api_url} + + and save it to: + + ‘{cfg.cachedir}/ticker.json’ + + Then invoke the program with --cached-data and without --btc + """ + msg(json_text[:1024] + '...') + msg(orange(fmt(tor_captcha_msg, strip_char='\t'))) + + def postprocess_data(self, data): + return [data] if cfg.btc_only else data + + @staticmethod + def parse_asset_id(s, require_label=True): + sym, label = (*s.split('-', 1), None)[:2] + if require_label and not label: + die(1, f'{s!r}: asset label is missing') + return asset_tuple( + symbol = sym.upper(), + id = (s.lower() if label else None), + source = 'cc') + + class yahoospot(base): + + desc = 'Yahoo Finance' + data_desc = 'spot financial data' + api_host = 'finance.yahoo.com' + ratelimit = 30 + net_data_type = 'python' + has_verbose = False + asset_id_pat = r'^\^.*|.*=[xf]$' + json_fn_basename = 'ticker-finance.json' + + @staticmethod + def get_id(sym, data): + return sym.lower() + + @staticmethod + def conv_data(sym, data, btcusd): + price_usd = Decimal(data['regularMarketPrice']['raw']) + return { + 'id': sym, + 'name': data['shortName'], + 'symbol': sym.upper(), + 'price_usd': price_usd, + 'price_btc': price_usd / btcusd, + 'percent_change_1y': data['pct_chg_1y'], + 'percent_change_30d': data['pct_chg_4wks'], + 'percent_change_7d': data['pct_chg_1wk'], + 'percent_change_24h': data['regularMarketChangePercent']['raw'] * 100, + 'market_cap': 0, # dummy - required for sorting + 'last_updated': data['regularMarketTime']} + + def rate_limit_errmsg(self, elapsed): + rem = self.timeout - elapsed + return f'Rate limit exceeded! Retry in {rem} second{suf(rem)}, or use --cached-data' + + @property + def json_fn(self): + return os.path.join(cfg.cachedir, self.json_fn_basename) + + @property + def timeout(self): + return 0 if gcfg.test_suite else self.ratelimit + + @property + def symbols(self): + return [r.symbol for r in cfg.rows if r.source == 'fi'] + + def get_data_from_network(self): + + kwargs = { + 'formatted': True, + 'asynchronous': True, + 'proxies': {'https': cfg.proxy2}} + + if gcfg.test_suite: + kwargs.update({'timeout': 1, 'retry': 0}) + + if gcfg.http_timeout: + kwargs.update({'timeout': gcfg.http_timeout}) + + if gcfg.testing: + Msg('\nyahooquery.Ticker(\n {},\n {}\n)'.format( + self.symbols, + fmt_dict(kwargs, fmt='kwargs'))) + return + + from yahooquery import Ticker + return self.process_network_data(Ticker(self.symbols,**kwargs)) + + def process_network_data(self, ticker): + return ticker.price + + @staticmethod + def parse_asset_id(s, require_label=True): + return asset_tuple( + symbol = s.upper(), + id = s.lower(), + source = 'fi') + + class yahoohist(yahoospot): + + json_fn_basename = 'ticker-finance-history.json' + data_desc = 'historical financial data' + net_data_type = 'json' + period = '1y' + interval = '1wk' + + def process_network_data(self, ticker): + return ticker.history( + period = self.period, + interval = self.interval).to_json(orient='index') + + def postprocess_data(self, data): + def gen(): + keys = set() + d = {} + for key, val in data.items(): + if m := re.match(r"\('(.*?)', datetime\.date\((.*)\)\)$", key): + date = '{}-{:>02}-{:>02}'.format(*m[2].split(', ')) + if (sym := m[1]) in keys: + d[date] = val + else: + keys.add(sym) + d = {date: val} + yield (sym, d) + return dict(gen()) def assets_list_gen(cfg_in): - for k,v in cfg_in.cfg['assets'].items(): - yield('') - yield(k.upper()) + for k, v in cfg_in.cfg['assets'].items(): + yield '' + yield k.upper() for e in v: - yield(' {:4s} {}'.format(*e.split('-',1))) + out = e.split('-', 1) + yield ' {:5s} {}'.format(out[0], out[1] if len(out) == 2 else '') def gen_data(data): """ @@ -55,15 +382,14 @@ def gen_data(data): checking for duplicates. """ - def dup_sym_errmsg(dup_sym): + def dup_sym_errmsg(data_type, 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 ' + '\n '.join(d['id'] for d in data[data_type].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}' - ) + f'instead of {dup_sym!r}') - def check_assets_found(wants,found,keys=['symbol','id']): + def check_assets_found(wants, found, keys=['symbol', 'id']): error = False for k in keys: missing = wants[k] - found[k] @@ -71,53 +397,122 @@ def gen_data(data): 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=' ') - )) + fmt_list(missing, fmt='col', indent=' '))) error = True if error: - die(1,'Missing data, exiting') + die(1, 'Missing data, exiting') + + class process_data: + + def cc(): + nonlocal btcusd + for d in data['cc'].data: + if d['id'] == 'btc-bitcoin': + btcusd = Decimal(str(d['quotes']['USD']['price'])) + break + else: + raise ValueError('malformed cryptocurrency data') + for k in ('id', 'symbol'): + for d in data['cc'].data: + if wants[k]: + if d[k] in wants[k]: + if d[k] in found[k]: + die(1, dup_sym_errmsg('cc', d[k])) + if not 'price_usd' in d: + d['price_usd'] = Decimal(str(d['quotes']['USD']['price'])) + d['price_btc'] = Decimal(str(d['quotes']['USD']['price'])) / btcusd + d['percent_change_24h'] = d['quotes']['USD']['percent_change_24h'] + d['percent_change_7d'] = d['quotes']['USD']['percent_change_7d'] + d['percent_change_30d'] = d['quotes']['USD']['percent_change_30d'] + d['percent_change_1y'] = d['quotes']['USD']['percent_change_1y'] + d['market_cap'] = d['quotes']['USD']['market_cap'] + d['last_updated'] = int(datetime.datetime.fromisoformat( + d['last_updated']).timestamp()) + yield (d['id'], d) + found[k].add(d[k]) + wants[k].remove(d[k]) + if d[k] in usr_rate_assets_want[k]: + rate_assets[d['symbol']] = d # NB: using symbol instead of ID for key + else: + break + + def fi(): + get_id = src_cls['fi'].get_id + conv_func = src_cls['fi'].conv_data + for k, v in data['fi'].data.items(): + id = get_id(k, v) + if wants['id']: + if id in wants['id']: + if not isinstance(v, dict): + die(2, str(v)) + if id in found['id']: + die(1, dup_sym_errmsg('fi', id)) + if hist := hist_close.get(k): + spot = v['regularMarketPrice']['raw'] + v['pct_chg_1wk'] = (spot / hist.close_1wk - 1) * 100 + v['pct_chg_4wks'] = (spot / hist.close_4wks - 1) * 100 # 4 weeks ≈ 1 month + v['pct_chg_1y'] = (spot / hist.close_1y - 1) * 100 + else: + v['pct_chg_1wk'] = v['pct_chg_4wks'] = v['pct_chg_1y'] = None + yield (id, conv_func(id, v, btcusd)) + found['id'].add(id) + wants['id'].remove(id) + if id in usr_rate_assets_want['id']: # NB: using symbol instead of ID for key: + rate_assets[k] = conv_func(id, v, btcusd) + else: + break + + def hi(): + ret = namedtuple('historical_closing_prices', ['close_1wk', 'close_4wks', 'close_1y']) + nonlocal hist_close + for k, v in data['hi'].data.items(): + hist = tuple(v.values()) + hist_close[k] = ret(hist[-2]['close'], hist[-5]['close'], hist[0]['close']) + return () 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'}, - } + 'id': {r.id for r in cfg.rows if r.id} - {'usd-us-dollar'}, + 'symbol': {r.symbol for r in cfg.rows if r.id is None} - {'USD'}} usr_rate_assets = tuple(u.rate_asset for u in cfg.usr_rows + cfg.usr_columns if u.rate_asset) usr_rate_assets_want = { 'id': {a.id for a in usr_rate_assets if a.id}, - 'symbol': {a.symbol for a in usr_rate_assets if not a.id} - } + 'symbol': {a.symbol for a in usr_rate_assets if not a.id}} 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 + usr_rate_assets if a.id} - - {a.id for a in usr_assets if a.rate and a.id} - {'usd-us-dollar'} ) + {a.id for a in usr_assets if a.rate and a.id} - {'usd-us-dollar'}) , 'symbol': ( {a.symbol for a in usr_assets + usr_rate_assets if not a.id} - - {a.symbol for a in usr_assets if a.rate} - {'USD'} ), - } + {a.symbol for a in usr_assets if a.rate} - {'USD'})} - found = { 'id': set(), 'symbol': set() } + found = {'id': set(), 'symbol': set()} rate_assets = {} - 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 d[k] in usr_rate_assets_want[k]: - rate_assets[d['symbol']] = d # NB: using symbol instead of ID - if k == 'id' and len(found[k]) == len(wants): - break + wants = {k: rows_want[k] | usr_wants[k] for k in ('id', 'symbol')} - for d in data: - if d['id'] == 'btc-bitcoin': - btcusd = Decimal(d['price_usd']) - break + btcusd = Decimal('1') # dummy + hist_close = {} + + parse_fail = False + for data_type in ('cc', 'hi', 'fi'): # 'fi' depends on 'cc' and 'hi' so must go last + if data_type in data: + try: + yield from getattr(process_data, data_type)() + except Exception as e: + rmsg(f'Error in source data {data_type!r}: {e}') + parse_fail = True + else: + cache_data(data[data_type]) + + if parse_fail: + die(2, 'Invalid data encountered, exiting') + + if gcfg.download: + return + + check_assets_found(usr_wants, found) for asset in (cfg.usr_rows + cfg.usr_columns): if asset.rate: @@ -125,134 +520,52 @@ def gen_data(data): User-supplied rate overrides rate from source data. """ _id = asset.id or f'{asset.symbol}-user-asset-{asset.symbol}'.lower() - ra_rate = Decimal(rate_assets[asset.rate_asset.symbol]['price_usd']) if asset.rate_asset else 1 - yield ( _id, { + ra_rate = rate_assets[asset.rate_asset.symbol]['price_usd'] if asset.rate_asset else 1 + yield (_id, { 'symbol': asset.symbol, 'id': _id, - 'price_usd': str(Decimal(ra_rate/asset.rate)), - 'price_btc': str(Decimal(ra_rate/asset.rate/btcusd)), - 'last_updated': int(now), - }) + 'name': ' '.join(_id.split('-')[1:]), + 'price_usd': ra_rate / asset.rate, + 'price_btc': ra_rate / asset.rate / btcusd, + 'last_updated': None}) yield ('usd-us-dollar', { 'symbol': 'USD', 'id': 'usd-us-dollar', - 'price_usd': '1.0', - 'price_btc': str(Decimal(1/btcusd)), - 'last_updated': int(now), - }) + 'name': 'US Dollar', + 'price_usd': Decimal(1), + 'price_btc': Decimal(1) / btcusd, + 'percent_change_24h': 0.0, + 'percent_change_7d': 0.0, + 'percent_change_30d': 0.0, + 'percent_change_1y': 0.0, + 'market_cap': 0, + 'last_updated': None}) - check_assets_found(usr_wants,found) +def cache_data(data_src, no_overwrite=False): + if data_src.json_text: + if os.path.exists(data_src.json_fn): + if no_overwrite: + return False + os.rename(data_src.json_fn, data_src.json_fn + '.bak') + with open(data_src.json_fn, 'w') as fh: + fh.write(data_src.json_text) + if not cfg.quiet: + msg(f'JSON data cached to {data_src.json_fn_disp}') + return True -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 gcfg.test_suite else btc_ratelimit - else: - fn = os.path.join(cfg.cachedir,'ticker.json') - timeout = 5 if gcfg.test_suite else ratelimit - - fn_rel = os.path.relpath(fn,start=homedir) - - if not os.path.exists(fn): - open(fn,'w').write('{}') - - if gcfg.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}...') - gcfg._util.vmsg('') - 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 gcfg.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 gcfg.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 main(): def update_sample_file(usr_cfg_file): - src_data = files('mmgen_node_tools').joinpath('data',os.path.basename(usr_cfg_file)).read_text() + usr_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) + if usr_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' - '--header', 'Accept: application/json', - ] + - (['--proxy', cfg.proxy] if cfg.proxy else []) + - (['--silent'] if not gcfg.verbose else []) + - [api_url + ('/btc-bitcoin' if cfg.btc_only else '')] - ) - - global cfg,cfg_in - cfg = cfg_parm - cfg_in = cfg_in_parm + ('Updating', 'Creating')[sample_data is None], + sample_file)) + open(sample_file, 'w').write(usr_data) try: from importlib.resources import files # Python 3.9 @@ -263,56 +576,80 @@ def main(cfg_parm,cfg_in_parm): update_sample_file(cfg_in.portfolio_file) if gcfg.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 gcfg.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) + die(1, 'No portfolio configured!\nTo configure a portfolio, edit the file ~/{}'.format( + os.path.relpath(cfg_in.portfolio_file, start=homedir))) if gcfg.list_ids: - from mmgen.ui import do_pager - do_pager('\n'.join(e['id'] for e in parsed_json)) + src_ids = ['cc'] + elif gcfg.download: + if not gcfg.download in DataSource.get_sources(): + die(1, f'{gcfg.download!r}: invalid data source') + src_ids = [gcfg.download] + else: + src_ids = DataSource.get_sources(randomize=True) + + src_data = {k: src_cls[k]().get_data() for k in src_ids} + + if gcfg.testing: return + if gcfg.list_ids: + do_pager('\n'.join(e['id'] for e in src_data['cc'].data)) + return + + global cfg + + if cfg.asset_range: + n, m = cfg.asset_range + cfg = cfg._replace(rows = RowDict({ + 'asset_list': + tuple( + asset_tuple(e['symbol'], e['id'], source='cc') + for e in src_data['cc'].data[n-1:m]), + 'extra': + tuple( + [asset_tuple('BTC', 'btc-bitcoin', source='cc')] + + [r for r in cfg.rows if r.source == 'fi'])})) + global now now = 1659465400 if gcfg.test_suite else time.time() # 1659524400 1659445900 - gcfg._util.stdout_or_pager( - '\n'.join(getattr(Ticker,cfg.clsname)(dict(gen_data(parsed_json))).gen_output()) + '\n' - ) + data = dict(gen_data(src_data)) -def make_cfg(cmd_args,cfg_in): + if gcfg.download: + return - def get_rows_from_cfg(add_data=None): - 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 parse_asset_id(e,True) - return tuple(gen()) + (do_pager if cfg.pager else Msg_r)( + '\n'.join(getattr(Ticker, cfg.clsname)(data).gen_output()) + '\n') - def parse_asset_id(s,require_label=False): - sym,label = (*s.split('-',1),None)[:2] - if require_label and not label: - die(1,f'{s!r}: asset label is missing') - return asset_tuple( sym.upper(), (s.lower() if label else None) ) +def make_cfg(gcfg_arg): - def parse_usr_asset_arg(s): + query_tuple = namedtuple('query', ['asset', 'to_asset']) + asset_data = namedtuple('asset_data', ['symbol', 'id', 'amount', 'rate', 'rate_asset', 'source']) + + def parse_asset_id(s, require_label=True): + return src_cls['fi' if re.match(fi_pat, s) else 'cc'].parse_asset_id(s, require_label) + + def parse_percent_cols(arg): + if arg is None or arg.lower() in ('none', ''): + return [] + res = arg.lower().split(',') + for s in res: + if s not in percent_cols: + die(1, '{!r}: invalid --percent-cols parameter (valid letters: {})'.format( + arg, + fmt_list(percent_cols))) + return res + + def parse_usr_asset_arg(key, use_cf_file=False): """ asset_id[:rate[:rate_asset]] """ def parse_parm(s): ss = s.split(':') - assert len(ss) in (1,2,3), f'{s}: malformed argument' - asset_id,rate,rate_asset = (*ss,None,None)[:3] - parsed_id = parse_asset_id(asset_id) + assert len(ss) in (1, 2, 3), f'{s}: malformed argument' + asset_id, rate, rate_asset = (*ss, None, None)[:3] + parsed_id = parse_asset_id(asset_id, require_label=False) return asset_data( symbol = parsed_id.symbol, @@ -321,139 +658,255 @@ def make_cfg(cmd_args,cfg_in): rate = ( None if rate is None else 1 / Decimal(rate[:-1]) if rate.lower().endswith('r') else - Decimal(rate) ), - rate_asset = parse_asset_id(rate_asset) if rate_asset else None ) + Decimal(rate)), + rate_asset = parse_asset_id(rate_asset, require_label=False) if rate_asset else None, + source = parsed_id.source) - return tuple(parse_parm(s2) for s2 in s.split(',')) if s else () + cl_opt = getattr(gcfg, key) + if cl_opt is None or cl_opt.lower() in ('none', ''): + return () + cf_opt = cfg_in.cfg.get(key,[]) if use_cf_file else [] + return tuple(parse_parm(s) for s in (cl_opt.split(',') if cl_opt else cf_opt)) + + def parse_asset_range(s): + max_idx = DataSource.coinpaprika.max_asset_idx + match s.split('-'): + case [a, b] if is_int(a) and is_int(b): + n, m = (int(a), int(b)) + case [a] if is_int(a): + n, m = (1, int(a)) + case _: + return None + if n < 1 or m < 1 or n > m: + raise ValueError(f'‘{s}’: invalid asset range specifier') + if m > max_idx: + raise ValueError(f'‘{s}’: end of range must be <= {max_idx}') + return (n, m) def parse_query_arg(s): """ asset_id:amount[:to_asset_id[:to_amount]] """ - def parse_query_asset(asset_id,amount): - parsed_id = parse_asset_id(asset_id) + def parse_query_asset(asset_id, amount): + parsed_id = parse_asset_id(asset_id, require_label=False) return asset_data( symbol = parsed_id.symbol, id = parsed_id.id, amount = None if amount is None else Decimal(amount), rate = None, - rate_asset = None ) + rate_asset = None, + source = parsed_id.source) ss = s.split(':') - assert len(ss) in (2,3,4), f'{s}: malformed argument' - asset_id,amount,to_asset_id,to_amount = (*ss,None,None)[:4] + assert len(ss) in (2, 3, 4), f'{s}: malformed argument' + asset_id, amount, to_asset_id, to_amount = (*ss, None, None)[:4] return query_tuple( - asset = parse_query_asset(asset_id,amount), - to_asset = parse_query_asset(to_asset_id,to_amount) if to_asset_id else None - ) + asset = parse_query_asset(asset_id, amount), + to_asset = parse_query_asset(to_asset_id, to_amount) if to_asset_id else None) - def gen_uniq(obj_list,key,preload=None): - found = set([getattr(obj,key) for obj in preload if hasattr(obj,key)] if preload else ()) + 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) + 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 ) + 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 gcfg.portfolio: - ret = (parse_asset_id(e,True) for e in cfg_in.portfolio) - return ( 'portfolio', tuple(e for e in ret if (not gcfg.btc) or e.symbol == 'BTC') ) + def get_portfolio_assets(): + if portfolio: + ret = (parse_asset_id(e) for e in portfolio) + return tuple(e for e in ret if (not gcfg.btc) or e.symbol == 'BTC') + else: + return () - def get_portfolio(): - return {k:Decimal(v) for k,v in cfg_in.portfolio.items() if (not gcfg.btc) or k == 'btc-bitcoin'} + def parse_portfolio(): + ret = {} + def add(k, v): + if gcfg.btc and k != 'btc-bitcoin': + return + if k in ret: + ret[k] += Decimal(v) + else: + ret[k] = Decimal(v) + for k, v in cfg_in.portfolio.items(): + if isinstance(v, dict): + for k2, v2 in v.items(): + add(k2, v2) + else: + add(k, v) + return ret - def parse_add_precision(s): - if not s: + def parse_add_precision(arg): + if not arg: return 0 + s = str(arg) if not (s.isdigit() and s.isascii()): - die(1,f'{s}: invalid parameter for --add-precision (not an integer)') + 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)') + 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_id('btc-bitcoin')) if gcfg.btc else - get_rows_from_cfg( add_data={'fiat':['usd-us-dollar']} if gcfg.add_columns else None ) - ) - - for hdr,data in ( - (get_usr_assets(),) if query else - (get_usr_assets(), get_portfolio_assets()) - ): + rows = RowDict( + {'trade_pair': query} if (query and query.to_asset) else + {'bitcoin': [parse_asset_id('btc-bitcoin')]} if gcfg.btc else + {k: tuple(parse_asset_id(e) for e in v) for k, v in cfg_in.cfg['assets'].items()}) + for hdr, data in ( + ('user_uniq', get_usr_assets()), + ('portfolio_uniq', get_portfolio_assets()), + ('pchg_unit_uniq', [pchg_unit] if pchg_unit else None)): if data: - uniq_data = tuple(gen_uniq(data,'symbol',preload=rows)) - if uniq_data: - rows += (hdr,) + uniq_data + if uniq_data := tuple(gen_uniq(data, 'symbol', preload=rows)): + rows[hdr] = uniq_data + else: + rows[hdr] = () return rows + def get_cfg_var(name): + if name in gcfg._uopts: + return getattr(gcfg, name) + else: + return getattr(gcfg, name) or cfg_in.cfg.get(name) + + def get_proxy(name): + proxy = getattr(gcfg, name) + return ( + '' if proxy == '' else 'none' if (proxy and proxy.lower() == 'none') + else (proxy or cfg_in.cfg.get(name))) + + def get_sort_opt(): + match get_cfg_var('sort'): + case None: + return None + case s if s in sort_params: + return (s, True) + case s if s in ['r' + ch for ch in sort_params]: + return (s[1], False) + case s: + die(1, + f'{s!r}: invalid parameter for --sort option (must be one of {fmt_list(sort_params)})' + '\nTo reverse the sort, prefix the code letter with ‘r’') + cfg_tuple = namedtuple('global_cfg',[ 'rows', 'usr_rows', 'usr_columns', 'query', + 'asset_range', 'adjust', 'clsname', 'btc_only', 'add_prec', 'cachedir', 'proxy', - 'portfolio' ]) + 'proxy2', + 'portfolio', + 'sort', + 'percent_cols', + 'pchg_unit', + 'asset_limit', + 'cached_data', + 'elapsed', + 'name_labels', + 'pager', + 'thousands_comma', + 'update_time', + 'quiet', + 'verbose']) - query_tuple = namedtuple('query',['asset','to_asset']) - asset_data = namedtuple('asset_data',['symbol','id','amount','rate','rate_asset']) - asset_tuple = namedtuple('asset_tuple',['symbol','id']) + global gcfg, cfg_in, src_cls, cfg - usr_rows = parse_usr_asset_arg(gcfg.add_rows) - usr_columns = parse_usr_asset_arg(gcfg.add_columns) - query = parse_query_arg(cmd_args[0]) if cmd_args else None + gcfg = gcfg_arg - return cfg_tuple( + src_cls = {k: getattr(DataSource, v) for k, v in DataSource.get_sources().items()} + fi_pat = src_cls['fi'].asset_id_pat + + cfg_in = get_cfg_in() + + if cmd_args := gcfg._args: + if len(cmd_args) > 1: + die(1, 'Only one command-line argument is allowed') + asset_range = parse_asset_range(cmd_args[0]) + query = None if asset_range else parse_query_arg(cmd_args[0]) + else: + asset_range = None + query = None + + usr_rows = parse_usr_asset_arg('add_rows') + usr_columns = parse_usr_asset_arg('add_columns', use_cf_file=True) + + proxy = get_proxy('proxy') + proxy = None if proxy == 'none' else proxy + proxy2 = get_proxy('proxy2') + + portfolio = ( + parse_portfolio() if cfg_in.portfolio and get_cfg_var('portfolio') and not query + else None) + + if portfolio and asset_range: + die(1, '--portfolio not supported in market cap view') + + pchg_unit = (lambda s: parse_asset_id(s, require_label=False) if s else None)( + get_cfg_var('pchg_unit')) + + cfg = 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(gcfg.adjust or 0) ), + asset_range = asset_range, + adjust = (lambda x: (100 + x) / 100 if x else 1)(Decimal(gcfg.adjust or 0)), clsname = 'trading' if query else 'overview', - btc_only = gcfg.btc, - add_prec = parse_add_precision(gcfg.add_precision), - cachedir = gcfg.cachedir or cfg_in.cfg.get('cachedir') or cachedir, - proxy = None if gcfg.proxy == '' else (gcfg.proxy or cfg_in.cfg.get('proxy')), - portfolio = get_portfolio() if cfg_in.portfolio and gcfg.portfolio and not query else None - ) + btc_only = get_cfg_var('btc'), + add_prec = parse_add_precision(get_cfg_var('add_precision')), + cachedir = get_cfg_var('cachedir') or dfl_cachedir, + proxy = proxy, + proxy2 = None if proxy2 == 'none' else '' if proxy2 == '' else (proxy2 or proxy), + portfolio = portfolio, + sort = get_sort_opt(), + percent_cols = parse_percent_cols(get_cfg_var('percent_cols')), + pchg_unit = pchg_unit, + asset_limit = get_cfg_var('asset_limit'), + cached_data = get_cfg_var('cached_data'), + elapsed = get_cfg_var('elapsed'), + name_labels = get_cfg_var('name_labels'), + pager = get_cfg_var('pager'), + thousands_comma = get_cfg_var('thousands_comma'), + update_time = get_cfg_var('update_time'), + quiet = get_cfg_var('quiet'), + verbose = get_cfg_var('verbose')) + + return (src_cls, cfg_in) def get_cfg_in(): - ret = namedtuple('cfg_in_data',['cfg','portfolio','cfg_file','portfolio_file']) - cfg_file,portfolio_file = ( - [os.path.join(gcfg.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)] - ) + ret = namedtuple('cfg_in_data', ['cfg', 'portfolio', 'cfg_file', 'portfolio_file']) + cfg_file, portfolio_file = ( + [os.path.join(gcfg.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, - ) + # gold futures, silver futures, Brent futures + 'commodity': [ 'gc=f', 'si=f', 'bz=f' ], + # Pound Sterling, Euro, Swiss Franc + 'fiat': [ 'gbpusd=x', 'eurusd=x', 'chfusd=x' ], + # Dow Jones Industrials, Nasdaq 100, S&P 500 + 'index': [ '^dji', '^ixic', '^gspc' ]}, + 'proxy': 'http://vpn-gw:8118'}, + portfolio = portfolio_data, + cfg_file = cfg_file, + portfolio_file = portfolio_file) class Ticker: @@ -461,86 +914,142 @@ class Ticker: offer = None to_asset = None + hidden_groups = ('extra', 'pchg_unit_uniq') - def __init__(self,data): + def __init__(self, data): - self.comma = ',' if gcfg.thousands_comma else '' + global cfg - self.col1_wid = max(len('TOTAL'),( - max(len(self.create_label(d['id'])) for d in data.values()) if gcfg.name_labels else - max(len(d['symbol']) for d in data.values()) - )) + 1 + self.comma = ',' if cfg.thousands_comma else '' - 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.col1_wid = max(len('TOTAL'), ( + max(len(self.create_label(d['id'])) for d in data.values()) if cfg.name_labels else + max(len(d['symbol']) for d in data.values()))) - 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.rows = RowDict( + {k: tuple(row._replace(id=self.get_id(row)) for row in v) for k, v in cfg.rows.items()}) + + if cfg.asset_range: + self.max_rank = 0 + for group, rows in self.rows.items(): + if group not in self.hidden_groups: + for row in rows: + self.max_rank = max(self.max_rank, int(data[row.id]['rank'])) + + if cfg.sort: + code, reverse = cfg.sort + key = sort_params[code].key + sort_dfl = sort_params[code].sort_dfl + sort_func = lambda row: data.get(row.id, {key: sort_dfl})[key] + pf_sort_func = lambda row: data.get(row, {key: sort_dfl})[key] + for group in self.rows.keys(): + if group not in self.hidden_groups: + self.rows[group] = sorted(self.rows[group], key=sort_func, reverse=reverse) + if cfg.portfolio: + cfg = cfg._replace(portfolio = + {k: cfg.portfolio[k] + for k in sorted(cfg.portfolio, key=pf_sort_func, reverse=reverse)}) + + if cfg.pchg_unit: + self.pchg_data = self.data[self.get_id(cfg.pchg_unit)] + self.pchg_factors = {k: (self.pchg_data[k] / 100) + 1 for k in ( + 'percent_change_24h', + 'percent_change_7d', + 'percent_change_30d', + 'percent_change_1y')} + + self.col_usd_prices = {k: 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 row.id in data} self.prices['usd-us-dollar'] = self.get_row_prices('usd-us-dollar') - def format_last_update_col(self,cross_assets=()): + def format_last_updated_col(self, cross_assets=()): - if gcfg.elapsed: + if cfg.elapsed: from mmgen.util2 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 + fmt_func = lambda t, now: time.strftime('%F %X', time.gmtime(t)) d = self.data max_w = 0 - min_t = min( (int(d[a.id]['last_updated']) for a in cross_assets), default=None ) + + if cross_assets: + last_updated_x = [d[a.id]['last_updated'] for a in cross_assets] + min_t = min((int(n) for n in last_updated_x if isinstance(n, int)), default=None) + else: + min_t = 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) + try: + t = int(d[row.id]['last_updated']) + except TypeError as e: + d[row.id]['last_updated_fmt'] = gray('--' if 'NoneType' in str(e) else str(e)) + except KeyError: + pass + else: + t_fmt = d[row.id]['last_updated_fmt'] = fmt_func( + (min(t, min_t) if min_t else t), + now = 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 } + exp = [(a.id, self.prices[a.id]['usd-us-dollar'].adjusted()) 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): + def get_id(self, asset): if asset.id: return asset.id else: + m = asset.symbol for d in self.data.values(): - if d['symbol'] == asset.symbol: + if m == d['symbol']: return d['id'] - def create_label(self,id): - return ' '.join(id.split('-')[1:]).upper() + def create_label(self, id): + return self.data[id]['name'].upper() def gen_output(self): - yield 'Current time: {} UTC'.format(time.strftime('%F %X',time.gmtime(now))) + + def process_rows(rows): + yield '-' * self.hl_wid + for row in rows: + try: + yield self.fmt_row(self.data[row.id]) + except KeyError: + yield gray(f'(no data for {row.id})') + + yield 'Current time: {}'.format(cyan(time.strftime('%F %X', time.gmtime(now)) + ' UTC')) + + if cfg.sort: + text = sort_params[cfg.sort[0]].desc + ('' if cfg.sort[1] else ' [reversed]') + yield f'Sort order: {pink(text.upper())}' + + if cfg.pchg_unit: + yield 'Percent change unit: {}'.format(orange('{} ({})'.format( + self.pchg_data['symbol'], + self.pchg_data['name'].upper()))) for asset in self.usr_col_assets: if asset.symbol != 'USD': - usdprice = Decimal(self.data[asset.id]['price_usd']) + usdprice = 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) ) + max(2, 4-usdprice.adjusted())) - if hasattr(self,'subhdr'): + 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 )) - ) + + yellow('{:+.2f}%'.format((self.adjust-1) * 100))) yield '' @@ -550,14 +1059,12 @@ class Ticker: 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})') + if cfg.asset_range: + yield from process_rows(self.rows['asset_list']) + else: + for group, rows in self.rows.items(): + if rows and group not in self.hidden_groups: + yield from process_rows(rows) yield '-' * self.hl_wid @@ -568,54 +1075,55 @@ class Ticker: yield blue('PORTFOLIO') yield self.table_hdr yield '-' * self.hl_wid - for sym,amt in cfg.portfolio.items(): + for sym, amt in cfg.portfolio.items(): try: - yield self.fmt_row(self.data[sym],amt=amt) + 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() } - ) + lbl = 'TOTAL', pc3='', pc4='', pc1='', pc2='', upd='', amt='', + **{k.replace('-', '_'): v for k, v in self.prices['total'].items()}) class overview(base): - def __init__(self,data): + 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',) + self.col_ids = ('usd-us-dollar', 'btc-bitcoin') + tuple(a.id for a in self.usr_col_assets) super().__init__(data) - self.format_last_update_col() + self.format_last_updated_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.prices['total'] = {col_id: sum(self.prices[row.id][col_id] * cfg.portfolio[row.id] + for row in self.rows + if 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): + 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 } + return {k: ( + d['price_btc'] if k == 'btc-bitcoin' else + 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_row(self, d, amt=None, amt_fmt=None): - def fmt_pct(d): - if d in ('',None): + def fmt_pct(d, key, wid=7): + if (n := d.get(key)) is None: return gray(' --') - n = Decimal(d) - return (red,green)[n>=0](f'{n:+7.2f}') + if cfg.pchg_unit: + n = ((((n / 100) + 1) / self.pchg_factors[key]) - 1) * 100 + return (red, green)[n>=0](f'{n:+{wid}.2f}') p = self.prices[d['id']] @@ -625,56 +1133,64 @@ class Ticker: amt_fmt = amt_fmt.rstrip('0').rstrip('.') return self.fs_num.format( - lbl = (self.create_label(d['id']) if gcfg.name_labels else d['symbol']), - pc1 = fmt_pct(d.get('percent_change_7d')), - pc2 = fmt_pct(d.get('percent_change_24h')), + idx = int(d['rank']) if cfg.asset_range else None, + mcap = d.get('market_cap') / 1_000_000_000 if cfg.asset_range else None, + lbl = self.create_label(d['id']) if cfg.name_labels else d['symbol'], + pc1 = fmt_pct(d, 'percent_change_7d'), + pc2 = fmt_pct(d, 'percent_change_24h'), + pc3 = fmt_pct(d, 'percent_change_1y', wid=8), + pc4 = fmt_pct(d, 'percent_change_30d'), 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() } - ) + **{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) + col_prec = {'usd-us-dollar': 2+cfg.add_prec, 'btc-bitcoin': 8+cfg.add_prec} | self.uprec 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 } + ((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']) + 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 - }) + 'label': fd(f'{{lbl:{self.col1_wid}}}', f'{{lbl:{self.col1_wid}}}', self.col1_wid), + 'pct1y': fd(' {pc3:8}', ' {pc3:8}', 9), + 'pct1m': fd(' {pc4:7}', ' {pc4:7}', 8), + 'pct1w': fd(' {pc1:7}', ' {pc1:7}', 8), + 'pct1d': 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( + ' {{{}:>{}}}'.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', gcfg.percent_change ), - ( 'pct24h', gcfg.percent_change ), - ( 'update_time', gcfg.update_time ), - ) if b] - ) + ['label', 'usd-us-dollar'] + + [asset.id for asset in self.usr_col_assets] + + [a for a, b in ( + ('btc-bitcoin', not cfg.btc_only), + ('pct1y', 'y' in cfg.percent_cols), + ('pct1m', 'm' in cfg.percent_cols), + ('pct1w', 'w' in cfg.percent_cols), + ('pct1d', 'd' in cfg.percent_cols), + ('update_time', cfg.update_time)) + if b]) + + if cfg.asset_range: + num_w = len(str(self.max_rank)) + col_fs_data.update({ + 'idx': fd(' ' * (num_w + 2), f'{{idx:{num_w}}}) ', num_w + 2), + 'mcap': fd('{mcap:>12}', '{mcap:12.5f}', 12)}) + cols = ['idx', 'label', 'mcap'] + cols[1:] + cols2 = list(cols) - if gcfg.update_time: + if cfg.update_time: cols2.pop() cols2.append('amt') @@ -690,23 +1206,25 @@ class Ticker: def table_hdr(self): return self.fs_str.format( lbl = '', + mcap = 'MarketCap(B)', pc1 = ' CHG_7d', pc2 = 'CHG_24h', + pc3 = ' CHG_1y', + pc4 = 'CHG_30d', upd = 'UPDATED', amt = ' AMOUNT', usd_us_dollar = 'USD', btc_bitcoin = ' BTC', - **{ a.id.replace('-','_'): a.symbol for a in self.usr_col_assets } - ) + **{a.id.replace('-', '_'): a.symbol for a in self.usr_col_assets}) class trading(base): - def __init__(self,data): + 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 ) + if cfg.query.to_asset else None) self.col_ids = [self.asset.id] self.adjust = cfg.adjust if self.to_asset: @@ -714,13 +1232,14 @@ class Ticker: if self.offer: real_price = ( self.asset.amount - * Decimal(data[self.asset.id]['price_usd']) - / Decimal(data[self.to_asset.id]['price_usd']) - ) + * data[self.asset.id]['price_usd'] + / 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') + 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] + self.hl_ids = [self.asset.id, self.to_asset.id] else: self.hl_ids = [self.asset.id] @@ -730,17 +1249,17 @@ class Ticker: 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.prices[a.id]['usd-us-dollar'] = data[a.id]['price_usd'] - self.format_last_update_col(cross_assets=self.usr_col_assets) + self.format_last_updated_col(cross_assets=self.usr_col_assets) self.init_prec() self.init_fs() - def get_row_prices(self,id): + 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 } + return {k: self.col_usd_prices[self.asset.id] / d['price_usd'] for k in self.col_ids} def init_fs(self): self.max_wid = max( @@ -748,33 +1267,30 @@ class Ticker: v[self.asset.id] * self.asset.amount, 16 + cfg.add_prec, self.comma, - 8 + cfg.add_prec - )) - for v in self.prices.values() - ) + 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 gcfg.update_time: + if cfg.update_time: self.fs_str += ' {upd}' self.hl_wid += self.upd_w + 2 - def fmt_row(self,d): + 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_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 '' ) + '{:{}{}.{}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 gcfg.name_labels else d['symbol']), + lbl = self.create_label(id) if cfg.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'), - ) + upd = d.get('last_updated_fmt')) @property def table_hdr(self): @@ -782,12 +1298,11 @@ class Ticker: lbl = '', p_spot = '{t:>{w}}'.format( t = 'SPOT PRICE', - w = self.max_wid ), + w = self.max_wid), p_adj = '{t:>{w}}'.format( t = ('OFFERED' if self.offer else 'ADJUSTED') + ' PRICE', - w = self.max_wid ), - upd = 'UPDATED' - ) + w = self.max_wid), + upd = 'UPDATED') @property def subhdr(self): @@ -800,9 +1315,8 @@ class Ticker: ) + ( ( ' =>' + - (' {:{}}'.format(self.offer,self.comma) if self.offer else '') + + (' {:{}}'.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 '' ) - ) + 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 9060f68..f36faad 100755 --- a/mmgen_node_tools/Util.py +++ b/mmgen_node_tools/Util.py @@ -20,55 +20,53 @@ mmgen_node_tools.Util: utility functions for MMGen node tools """ import time -from mmgen.util import suf -def get_hms(t=None,utc=False,no_secs=False): +def get_hms(t=None, utc=False, no_secs=False): secs = t or time.time() - ret = (time.localtime,time.gmtime)[utc](secs) - fs,n = (('{:02}:{:02}:{:02}',6),('{:02}:{:02}',5))[no_secs] + ret = (time.localtime, time.gmtime)[utc](secs) + fs, n = (('{:02}:{:02}:{:02}', 6), ('{:02}:{:02}', 5))[no_secs] return fs.format(*ret[3:n]) -def get_day_hms(t=None,utc=False): +def get_day_hms(t=None, utc=False): secs = t or time.time() - ret = (time.localtime,time.gmtime)[utc](secs) + ret = (time.localtime, time.gmtime)[utc](secs) return '{:04}-{:02}-{:02} {:02}:{:02}:{:02}'.format(*ret[0:6]) -def do_system(cmd,testing=False,shell=False): +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) + 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): +def get_url(url, gzip_ok=False, proxy=None, timeout=60, verbose=False, debug=False): if debug: print('get_url():') print(' url', url) - print(' gzip_ok:',gzip_ok, 'proxy:',proxy, 'timeout:',timeout, 'verbose:',verbose) - import pycurl,io + print(' gzip_ok:', gzip_ok, 'proxy:', proxy, 'timeout:', timeout, 'verbose:', verbose) + import pycurl, io c = pycurl.Curl() c_out = io.StringIO() - c.setopt(pycurl.WRITEFUNCTION,c_out.write) - c.setopt(pycurl.TIMEOUT,timeout) - c.setopt(pycurl.FOLLOWLOCATION,True) - c.setopt(pycurl.COOKIEFILE,'') - c.setopt(pycurl.VERBOSE,verbose) + c.setopt(pycurl.WRITEFUNCTION, c_out.write) + c.setopt(pycurl.TIMEOUT, timeout) + c.setopt(pycurl.FOLLOWLOCATION, True) + c.setopt(pycurl.COOKIEFILE, '') + c.setopt(pycurl.VERBOSE, verbose) if gzip_ok: - c.setopt(pycurl.USERAGENT,'Lynx/2.8.9dev.8 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.4.9') + c.setopt(pycurl.USERAGENT, 'Lynx/2.8.9dev.8 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/3.4.9') c.setopt(pycurl.HTTPHEADER, [ 'Accept: text/html, text/plain, text/sgml, text/css, application/xhtml+xml, */*;q=0.01', 'Accept-Encoding: gzip', - 'Accept-Language: en'] - ) + 'Accept-Language: en']) if proxy: - c.setopt(pycurl.PROXY,proxy) - c.setopt(pycurl.URL,url) + c.setopt(pycurl.PROXY, proxy) + c.setopt(pycurl.URL, url) c.perform() text = c_out.getvalue() if text[:2] == '\x1f\x8b': # gzip magic number - c_out.seek(0,0) + c_out.seek(0, 0) import gzip with gzip.GzipFile(fileobj=c_out) as f: text = f.read() @@ -104,20 +102,19 @@ big_digits = { """ } -_bnums_c,_bpunc_c = [[l.strip('\n') + ' ' * (big_digits[m]*big_digits['n']) +_bnums_c, _bpunc_c = [[l.strip('\n') + ' ' * (big_digits[m]*big_digits['n']) for l in big_digits[k][1:].split('\n')] - for k,m in (('nums','w'),('punc','pw'))] + for k, m in (('nums', 'w'), ('punc', 'pw'))] -_bnums_n,_bpunc_n = [[[l[0+(j*w):w+(j*w)] for l in i] - for j in range(big_digits[n])] for n,w,i in - (('n',big_digits['w'],_bnums_c),('pn',big_digits['pw'],_bpunc_c))] +_bnums_n, _bpunc_n = [[[l[0+(j*w):w+(j*w)] for l in i] + for j in range(big_digits[n])] for n, w, i in + (('n', big_digits['w'], _bnums_c), ('pn', big_digits['pw'], _bpunc_c))] -def display_big_digits(s,pre='',suf=''): - s = [int((d,10,11)[(d in '.:')+(d==':')]) for d in s] +def display_big_digits(s, pre='', suf=''): + s = [int((d, 10, 11)[(d in '.:')+(d==':')]) for d in s] return pre + ('\n'+pre).join( - [''.join([(_bnums_n+_bpunc_n)[d][l] for d in s]) + suf for l in range(big_digits['h'])] - ) + [''.join([(_bnums_n+_bpunc_n)[d][l] for d in s]) + suf for l in range(big_digits['h'])]) if __name__ == '__main__': num = '2345.17' - print(display_big_digits(num,pre='+ ',suf=' +')) + print(display_big_digits(num, pre='+ ', suf=' +')) diff --git a/mmgen_node_tools/data/keywords b/mmgen_node_tools/data/keywords index 3c3c563..f26b502 100644 --- a/mmgen_node_tools/data/keywords +++ b/mmgen_node_tools/data/keywords @@ -1 +1 @@ -Bitcoin, BTC, Ethereum, ETH, Monero, XMR, ERC20, cryptocurrency, wallet, BIP32, cold storage, offline, online, spending, open-source, command-line, Python, Linux, Bitcoin Core, bitcoind, hd, deterministic, hierarchical, secure, anonymous, Electrum, seed, mnemonic, brainwallet, Scrypt, utility, script, scriptable, blockchain, raw, transaction, permissionless, console, terminal, curses, ansi, color, tmux, remote, client, daemon, RPC, json, entropy, xterm, rxvt, PowerShell, MSYS, MSYS2, MinGW, MinGW64, MSWin, Armbian, Raspbian, Raspberry Pi, Orange Pi, BCash, BCH, Litecoin, LTC, altcoin, ZEC, Zcash, DASH, Dashpay, SHA256Compress, monerod, EMC, Emercoin, token, deploy, contract, gas, fee, smart contract, solidity, Parity, OpenEthereum, testnet, devmode, Kovan +Bitcoin, BTC, Ethereum, ETH, Monero, XMR, ERC20, cryptocurrency, wallet, cold storage, offline, signing, online, security, privacy, spending, financial, investment, open-source, command-line, Python, Linux, Microsoft Windows, macOS, Bitcoin Core, BIP32, BIP39, BIP44, BIP69, BIP125, bitcoind, hd, deterministic, hierarchical, secure, anonymous, Electrum, seed, mnemonic, brainwallet, Scrypt, utility, script, scriptable, blockchain, raw, transaction, permissionless, console, terminal, curses, ansi, color, tmux, remote, client, daemon, RPC, json, entropy, xterm, rxvt, MSYS2, MSWin, Armbian, Raspbian, Raspberry Pi, Orange Pi, Rock Pi, BCash, Bitcoin Cash Node, BCH, Litecoin, LTC, altcoin, ZEC, Zcash, SHA256Compress, monerod, token, deploy, contract, gas, fee, smart contract, solidity, Parity, OpenEthereum, testnet, devmode, regtest diff --git a/mmgen_node_tools/data/ticker-cfg.yaml b/mmgen_node_tools/data/ticker-cfg.yaml index 2566da4..6344445 100644 --- a/mmgen_node_tools/data/ticker-cfg.yaml +++ b/mmgen_node_tools/data/ticker-cfg.yaml @@ -1,14 +1,21 @@ -# Indentation must be consistent! Do not mix leading tabs and spaces. +### 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 +### See the curl manpage for supported --proxy parameters +### For a direct connection, leave the right-hand side blank proxy: http://vpn-gw:8118 +# proxy2: http://gw2:8118 -# Override the default cache directory (~/.cache/mmgen-node-tools) +### 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. +### Additional asset columns: +# add_columns: +# - cnhusd=x # Yuan +# - 6j=f # Yen futures + +### Asset rows +### 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 @@ -18,13 +25,14 @@ assets: - ada-cardano - bnb-binance-coin commodity: - - xau-gold-spot-token - - xag-silver-spot-token - - xbr-brent-crude-oil-spot + - gc=f # gold futures + - si=f # silver futures + - bz=f # Brent futures fiat: - - gbp-pound-sterling-token - - eur-euro-token + - gbpusd=x # Pound Sterling + - eurusd=x # Euro + - chfusd=x # Swiss Franc index: - - dj30-dow-jones-30-token - - spx-sp-500 - - ndx-nasdaq-100-token + - ^dji # Dow Jones Industrials + - ^ixic # Nasdaq 100 + - ^gspc # S&P 500 diff --git a/mmgen_node_tools/data/ticker-portfolio.yaml b/mmgen_node_tools/data/ticker-portfolio.yaml index fcadd30..b63e626 100644 --- a/mmgen_node_tools/data/ticker-portfolio.yaml +++ b/mmgen_node_tools/data/ticker-portfolio.yaml @@ -3,4 +3,12 @@ # 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' +xmr-monero: '4.567890123456' + +# Nested values are supported. Values for each asset will be summed. +wallet2: + btc-bitcoin: '0.12345678' + ltc-litecoin: '1.23456789' + +exchange1: + xmr-monero: '12.345678901234' diff --git a/mmgen_node_tools/data/version b/mmgen_node_tools/data/version index b473ce1..6b99850 100644 --- a/mmgen_node_tools/data/version +++ b/mmgen_node_tools/data/version @@ -1 +1 @@ -3.2.dev0 +3.6.dev12 diff --git a/mmgen_node_tools/main_addrbal.py b/mmgen_node_tools/main_addrbal.py index a461e9e..743b36e 100755 --- a/mmgen_node_tools/main_addrbal.py +++ b/mmgen_node_tools/main_addrbal.py @@ -5,16 +5,18 @@ # 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 +# https://github.com/mmgen/mmgen-wallet https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen-wallet https://gitlab.com/mmgen/mmgen-node-tools """ mmnode-addrbal: Get balances for arbitrary addresses in the blockchain """ -from mmgen.obj import CoinTxID,Int +import sys + +from mmgen.obj import CoinTxID from mmgen.cfg import Config -from mmgen.util import msg,Msg,die,suf,make_timestr,async_run +from mmgen.util import msg, Msg, die, suf, make_timestr, async_run from mmgen.color import red opts_data = { @@ -30,40 +32,39 @@ opts_data = { } } -def do_output(proto,addr_data,blk_hdrs): +def do_output(proto, addr_data, blk_hdrs): col1w = len(str(len(addr_data))) indent = ' ' * (col1w + 2) - for n,(addr,unspents) in enumerate(addr_data.items(),1): - Msg(f'\n{n:{col1w}}) Address: {addr.hl()}') + for n, (addr, unspents) in enumerate(addr_data.items(), 1): + Msg(f'\n{n:{col1w}}) Address: {addr.hl(addr.view_pref)}') if unspents: - heights = { u['height'] for u in unspents } + heights = {u['height'] for u in unspents} Msg('{}Balance: {}'.format( indent, - proto.coin_amt(sum(u['amount'] for u in unspents)).hl2(unit=True,fs='{:,}') )), + sum(proto.coin_amt(u['amount']) for u in unspents).hl3(unit=True, fs='{:,}'))), Msg('{}{} unspent output{} in {} block{}'.format( indent, red(str(len(unspents))), suf(unspents), red(str(len(heights))), - suf(heights) )) + suf(heights))) blk_w = len(str(unspents[-1]['height'])) - fs = '%s{:%s} {:19} {:64} {:4} {}' % (indent,max(5,blk_w)) - Msg(fs.format('Block','Date','TxID','Vout',' Amount')) + fs = '%s{:%s} {:19} {:64} {:4} {}' % (indent, max(5, blk_w)) + Msg(fs.format('Block', 'Date', 'TxID', 'Vout', ' Amount')) for u in unspents: Msg(fs.format( u['height'], - make_timestr( blk_hdrs[u['height']]['time'] ), + make_timestr(blk_hdrs[u['height']]['time']), CoinTxID(u['txid']).hl(), red(str(u['vout']).rjust(4)), - proto.coin_amt(u['amount']).fmt(color=True,iwidth=6,prec=8) - )) + proto.coin_amt(u['amount']).fmt(6, color=True, prec=8))) else: Msg(f'{indent}No balance') -def do_output_tabular(proto,addr_data,blk_hdrs): +def do_output_tabular(proto, addr_data, blk_hdrs): col1w = len(str(len(addr_data))) + 1 max_addrw = max(len(addr) for addr in addr_data) @@ -73,9 +74,9 @@ def do_output_tabular(proto,addr_data,blk_hdrs): lb_w = max(len(h) for h in lb_heights) fs = ( - ' {n:>%s} {a} {u} {b:>%s} {t:19} {B:>%s} {T:19} {A}' % (col1w,max(5,fb_w),max(4,lb_w)) + ' {n:>%s} {a} {u} {b:>%s} {t:19} {B:>%s} {T:19} {A}' % (col1w, max(5, fb_w), max(4, lb_w)) if cfg.first_block else - ' {n:>%s} {a} {u} {B:>%s} {T:19} {A}' % (col1w,max(4,lb_w)) ) + ' {n:>%s} {a} {u} {B:>%s} {T:19} {A}' % (col1w, max(4, lb_w))) Msg('\n' + fs.format( n = '', @@ -85,86 +86,85 @@ def do_output_tabular(proto,addr_data,blk_hdrs): t = 'Block', B = 'Last', T = 'Block', - A = ' Amount' )) + A = ' Amount')) - for n,(addr,unspents) in enumerate(addr_data.items(),1): + for n, (addr, unspents) in enumerate(addr_data.items(), 1): if unspents: Msg(fs.format( n = str(n) + ')', - a = addr.fmt(width=max_addrw,color=True), + a = addr.fmt(addr.view_pref, max_addrw, color=True), u = red(str(len(unspents)).rjust(5)), b = unspents[0]['height'], - t = make_timestr( blk_hdrs[unspents[0]['height']]['time'] ), + t = make_timestr(blk_hdrs[unspents[0]['height']]['time']), B = unspents[-1]['height'], - T = make_timestr( blk_hdrs[unspents[-1]['height']]['time'] ), - A = proto.coin_amt(sum(u['amount'] for u in unspents)).fmt(color=True,iwidth=7,prec=8) - )) + T = make_timestr(blk_hdrs[unspents[-1]['height']]['time']), + A = sum(proto.coin_amt(u['amount']) for u in unspents).fmt(7, color=True, prec=8))) else: Msg(fs.format( n = str(n) + ')', - a = addr.fmt(width=max_addrw,color=True), + a = addr.fmt(addr.view_pref, max_addrw, color=True), u = ' -', b = '-', t = '', B = '-', T = '', - A = ' -' )) + A = ' -')) async def main(req_addrs): proto = cfg._proto from mmgen.addr import CoinAddr - addrs = [CoinAddr(proto,addr) for addr in req_addrs] + addrs = [CoinAddr(proto, addr) for addr in req_addrs] from mmgen.rpc import rpc_init - rpc = await rpc_init(cfg,ignore_wallet=True) + rpc = await rpc_init(cfg, ignore_wallet=True) height = await rpc.call('getblockcount') Msg(f'{proto.coin} {proto.network.upper()} [height {height}]') from mmgen.proto.btc.misc import scantxoutset - res = await scantxoutset( cfg, rpc, [f'addr({addr})' for addr in addrs] ) + res = await scantxoutset(cfg, rpc, [f'addr({addr})' for addr in addrs]) if not res['success']: - die(1,'UTXO scanning failed or was interrupted') + die(1, 'UTXO scanning failed or was interrupted') elif not res['unspents']: msg('Address has no balance' if len(addrs) == 1 else - 'Addresses have no balances' ) + 'Addresses have no balances') else: addr_data = {k:[] for k in addrs} if 'desc' in res['unspents'][0]: import re - for unspent in sorted(res['unspents'],key=lambda x: x['height']): - addr = re.match('addr\((.*?)\)',unspent['desc'])[1] + for unspent in sorted(res['unspents'], key=lambda x: x['height']): + addr = re.match('addr\((.*?)\)', unspent['desc'])[1] addr_data[addr].append(unspent) else: - from mmgen.proto.btc.tx.base import scriptPubKey2addr - for unspent in sorted(res['unspents'],key=lambda x: x['height']): - addr = scriptPubKey2addr( proto, unspent['scriptPubKey'] )[0] - addr_data[addr].append(unspent) + from mmgen.proto.btc.tx.base import decodeScriptPubKey + for unspent in sorted(res['unspents'], key=lambda x: x['height']): + ds = decodeScriptPubKey(proto, unspent['scriptPubKey']) + addr_data[ds.addr].append(unspent) good_addrs = len([v for v in addr_data.values() if v]) Msg('Total: {} in {} address{}'.format( - proto.coin_amt(res['total_amount']).hl2(unit=True,fs='{:,}'), + proto.coin_amt(res['total_amount']).hl3(unit=True, fs='{:,}'), red(str(good_addrs)), - suf(good_addrs,'es') - )) + suf(good_addrs, 'es'))) blk_heights = {i['height'] for i in res['unspents']} blk_hashes = await rpc.batch_call('getblockhash', [(h,) for h in blk_heights]) blk_hdrs = await rpc.batch_call('getblockheader', [(H,) for H in blk_hashes]) - (do_output_tabular if cfg.tabular else do_output)( proto, addr_data, dict(zip(blk_heights,blk_hdrs)) ) + (do_output_tabular if cfg.tabular else do_output)( + proto, addr_data, dict(zip(blk_heights, blk_hdrs))) -cfg = Config( opts_data=opts_data, init_opts={'rpc_backend':'aiohttp'} ) +cfg = Config(opts_data=opts_data, init_opts={'rpc_backend': 'aiohttp'}) if len(cfg._args) < 1: - die(1,'This command requires at least one coin address argument') + die(1, 'This command requires at least one coin address argument') try: - async_run(main(cfg._args)) + async_run(cfg, main, args=[cfg._args]) except KeyboardInterrupt: sys.stderr.write('\n') diff --git a/mmgen_node_tools/main_blocks_info.py b/mmgen_node_tools/main_blocks_info.py index c2bbf59..eaed15b 100755 --- a/mmgen_node_tools/main_blocks_info.py +++ b/mmgen_node_tools/main_blocks_info.py @@ -20,9 +20,9 @@ mmnode-blocks-info: Display information about a block or range of blocks """ -from mmgen.cfg import gc,Config -from mmgen.util import async_run,fmt_list -from .BlocksInfo import BlocksInfo,JSONBlocksInfo +from mmgen.cfg import gc, Config +from mmgen.util import async_run, fmt_list +from .BlocksInfo import BlocksInfo, JSONBlocksInfo opts_data = { 'sets': [ @@ -145,14 +145,13 @@ EXAMPLES: $ {p} --rpc-backend=aio -H +1000 This program requires a txindex-enabled daemon for correct operation. -""" }, +"""}, 'code': { - 'notes': lambda cfg,proto,s: s.format( + 'notes': lambda cfg, proto, s: s.format( I = proto.diff_adjust_interval, - F = fmt_list(BlocksInfo.fields,fmt='bare'), - S = fmt_list(BlocksInfo.all_stats,fmt='bare'), - p = gc.prog_name, - ) + F = fmt_list(BlocksInfo.fields, fmt='bare'), + S = fmt_list(BlocksInfo.all_stats, fmt='bare'), + p = gc.prog_name) } } @@ -164,7 +163,7 @@ async def main(): cls = JSONBlocksInfo if cfg.json else BlocksInfo - m = cls( cfg, cfg._args, await rpc_init(cfg,ignore_wallet=True) ) + m = cls(cfg, cfg._args, await rpc_init(cfg, ignore_wallet=True)) if m.fnames and not cfg.no_header: m.print_header() @@ -172,10 +171,10 @@ async def main(): await m.process_blocks() if m.last: - for i,sname in enumerate(m.stats): + for i, sname in enumerate(m.stats): m.process_stats_pre(i) await m.process_stats(sname) m.finalize_output() -async_run(main()) +async_run(cfg, main) diff --git a/mmgen_node_tools/main_feeview.py b/mmgen_node_tools/main_feeview.py index 72692fc..188f60e 100755 --- a/mmgen_node_tools/main_feeview.py +++ b/mmgen_node_tools/main_feeview.py @@ -21,10 +21,10 @@ mmnode-feeview: Visualize the fee structure of a node’s mempool """ from mmgen.cfg import Config -from mmgen.util import async_run,die,fmt,make_timestr,check_int_between -from mmgen.util2 import int2bytespec,parse_bytespec +from mmgen.util import async_run, die, fmt, make_timestr, check_int_between +from mmgen.util2 import int2bytespec, parse_bytespec -min_prec,max_prec,dfl_prec = (0,6,4) +min_prec, max_prec, dfl_prec = (0, 6, 4) fee_brackets = [ 1, 2, 3, 4, 5, 6, 8, 10, 12, 14, 16, 18, @@ -42,9 +42,9 @@ fee_brackets = [ opts_data = { 'sets': [ - ('detail',True,'ranges',True), - ('detail',True,'show_mb_col',True), - ('detail',True,'precision',6), + ('detail', True, 'ranges', True), + ('detail', True, 'show_mb_col', True), + ('detail', True, 'precision', 6), ], 'text': { 'desc': 'Visualize the fee structure of a node’s mempool', @@ -83,37 +83,37 @@ cfg = Config(opts_data=opts_data) if cfg.ignore_below: if cfg.show_empty: - die(1,'Conflicting options: --ignore-below, --show-empty') + die(1, 'Conflicting options: --ignore-below, --show-empty') ignore_below = parse_bytespec(cfg.ignore_below) precision = ( - check_int_between(cfg.precision,min_prec,max_prec,'--precision arg') - if cfg.precision else dfl_prec ) + check_int_between(cfg.precision, min_prec, max_prec, desc='--precision arg') + if cfg.precision else dfl_prec) from mmgen.term import get_terminal_size width = cfg.columns or get_terminal_size().width class fee_bracket: - def __init__(self,top,bottom): + def __init__(self, top, bottom): self.top = top self.bottom = bottom self.tx_bytes = 0 self.tx_bytes_cum = 0 self.skip = False -def log(data,fn): +def log(data, fn): import json - from mmgen.rpc import json_encoder + from mmgen.rpc.util import json_encoder from mmgen.fileutil import write_data_to_file write_data_to_file( cfg = cfg, outfile = fn, - data = json.dumps(data,cls=json_encoder,sort_keys=True,indent=4), + data = json.dumps(data, cls=json_encoder, sort_keys=True, indent=4), desc = 'mempool', - ask_overwrite = False ) + ask_overwrite = False) -def create_data(coin_amt,mempool): - out = [fee_bracket(fee_brackets[i],fee_brackets[i-1] if i else 0) for i in range(len(fee_brackets))] +def create_data(coin_amt, mempool): + out = [fee_bracket(fee_brackets[i], fee_brackets[i-1] if i else 0) for i in range(len(fee_brackets))] # populate fee brackets: size_key = 'size' if proto.coin == 'BCH' else 'vsize' @@ -143,43 +143,42 @@ def create_data(coin_amt,mempool): return out -def gen_header(host,mempool,blockcount): +def gen_header(host, mempool, blockcount): - yield(fmt(f""" + yield fmt(f""" Mempool Fee Structure Date: {make_timestr()} UTC Host: {host} Network: {proto.coin.upper()} {proto.network.upper()} Block: {blockcount} TX count: {len(mempool)} - """)).strip() + """).strip() if cfg.show_empty: - yield('Displaying all fee brackets') + yield 'Displaying all fee brackets' elif cfg.ignore_below: - yield('Ignoring fee brackets with less than {:,} bytes ({})'.format( - ignore_below, - int2bytespec(ignore_below,'MB','0.6',strip=True,add_space=True), - )) + yield 'Ignoring fee brackets with less than {:,} bytes ({})'.format( + ignore_below, + int2bytespec(ignore_below, 'MB', '0.6', strip=True, add_space=True)) if cfg.include_current: - yield('Including transactions in current fee bracket in Total MB amounts') + yield 'Including transactions in current fee bracket in Total MB amounts' def fmt_mb(n): - return int2bytespec(n,'MB',f'0.{precision}',print_sym=False) + return int2bytespec(n, 'MB', f'0.{precision}', print_sym=False) def gen_body(data): - tx_bytes_max = max((i.tx_bytes for i in data),default=0) - top_max = max((i.top for i in data),default=0) - bot_max = max((i.bottom for i in data),default=0) - col1_w = max(len(f'{bot_max}-{top_max}') if cfg.ranges else len(f'{top_max}'),6) + tx_bytes_max = max((i.tx_bytes for i in data), default=0) + top_max = max((i.top for i in data), default=0) + bot_max = max((i.bottom for i in data), default=0) + col1_w = max(len(f'{bot_max}-{top_max}') if cfg.ranges else len(f'{top_max}'), 6) col2_w = len(fmt_mb(tx_bytes_max)) if cfg.show_mb_col else 0 col3_w = len(fmt_mb(data[-1].tx_bytes_cum)) if data else 0 col4_w = width - col1_w - col2_w - col3_w - (4 if col2_w else 3) if cfg.show_mb_col: - fs = '{a:<%i} {b:>%i} {c:>%i} {d}' % (col1_w,col2_w,col3_w) + fs = '{a:<%i} {b:>%i} {c:>%i} {d}' % (col1_w, col2_w, col3_w) else: - fs = '{a:<%i} {c:>%i} {d}' % (col1_w,col3_w) + fs = '{a:<%i} {c:>%i} {d}' % (col1_w, col3_w) yield fs.format(a='', b='', c=f'{"Total":<{col3_w}}', d='') yield fs.format(a='sat/B', b=f'{"MB":<{col2_w}}', c=f'{"MB":<{col3_w}}', d='') @@ -187,17 +186,17 @@ def gen_body(data): for i in data: if not i.skip: cum_bytes = i.tx_bytes_cum + i.tx_bytes if cfg.include_current else i.tx_bytes_cum - yield(fs.format( - a = '{}-{}'.format(i.bottom,i.top) if cfg.ranges else i.top, + yield fs.format( + a = '{}-{}'.format(i.bottom, i.top) if cfg.ranges else i.top, b = fmt_mb(i.tx_bytes), c = fmt_mb(cum_bytes), - d = '-' * int(col4_w * ( i.tx_bytes / tx_bytes_max )) )) + d = '-' * int(col4_w * (i.tx_bytes / tx_bytes_max))) - yield(fs.format( + yield fs.format( a = 'TOTAL', b = '', c = fmt_mb(data[-1].tx_bytes_cum + data[-1].tx_bytes if data else 0), - d = '' )) + d = '') async def main(): @@ -205,19 +204,19 @@ async def main(): proto = cfg._proto from mmgen.rpc import rpc_init - c = await rpc_init(cfg,ignore_wallet=True) + c = await rpc_init(cfg, ignore_wallet=True) - mempool = await c.call('getrawmempool',True) + mempool = await c.call('getrawmempool', True) if cfg.log: - log(mempool,'mempool.json') + log(mempool, 'mempool.json') - data = create_data(proto.coin_amt,mempool) + data = create_data(proto.coin_amt, mempool) cfg._util.stdout_or_pager( '\n'.join(gen_header( c.host, mempool, - await c.call('getblockcount') )) + '\n\n' + - '\n'.join(gen_body(data)) + '\n' ) + await c.call('getblockcount'))) + '\n\n' + + '\n'.join(gen_body(data)) + '\n') -async_run(main()) +async_run(cfg, main) diff --git a/mmgen_node_tools/main_halving_calculator.py b/mmgen_node_tools/main_halving_calculator.py index 1648d76..60c5440 100755 --- a/mmgen_node_tools/main_halving_calculator.py +++ b/mmgen_node_tools/main_halving_calculator.py @@ -21,7 +21,6 @@ mmnode-halving-calculator: Estimate date(s) of future block subsidy halving(s) """ import time -from decimal import Decimal from mmgen.cfg import Config from mmgen.util import async_run @@ -29,7 +28,7 @@ from mmgen.util import async_run bdr_proj = 9.95 opts_data = { - 'sets': [('mined',True,'list',True)], + 'sets': [('mined', True, 'list', True)], 'text': { 'desc': 'Estimate date(s) of future block subsidy halving(s)', 'usage':'[opts]', @@ -42,7 +41,7 @@ opts_data = { {bdr_proj:.5f} min) -s, --sample-size=N Block range to calculate block discovery interval for next halving estimate (default: dynamically calculated) -""" } +"""} } cfg = Config(opts_data=opts_data) @@ -54,28 +53,28 @@ def date(t): return '{}-{:02}-{:02} {:02}:{:02}:{:02}'.format(*time.gmtime(t)[:6]) def dhms(t): - t,neg = (-t,'-') if t < 0 else (t,' ') + t, neg = (-t, '-') if t < 0 else (t, ' ') return f'{neg}{t//60//60//24} days, {t//60//60%24:02}:{t//60%60:02}:{t%60:02} h/m/s' def time_diff_warning(t_diff): if abs(t_diff) > 60*60: print('Warning: block tip time is {} {} clock time!'.format( dhms(abs(t_diff)), - ('behind','ahead of')[t_diff<0])) + ('behind', 'ahead of')[t_diff<0])) async def main(): proto = cfg._proto from mmgen.rpc import rpc_init - c = await rpc_init( cfg, proto, ignore_wallet=True ) + c = await rpc_init(cfg, proto, ignore_wallet=True) tip = await c.call('getblockcount') assert tip > 1, 'block tip must be > 1' remaining = proto.halving_interval - tip % proto.halving_interval - sample_size = int(cfg.sample_size) if cfg.sample_size else min(tip-1,max(remaining,144)) + sample_size = int(cfg.sample_size) if cfg.sample_size else min(tip-1, max(remaining, 144)) - cur,old = await c.gathered_call('getblockstats',((tip,),(tip - sample_size,))) + cur, old = await c.gathered_call('getblockstats', ((tip,), (tip - sample_size,))) clock_time = int(time.time()) time_diff_warning(clock_time - cur['time']) @@ -99,8 +98,7 @@ async def main(): f'Current block discovery interval (over last {sample_size} blocks): {bdr/60:0.2f} min\n\n' f'Current clock time (UTC): {date(clock_time)}\n' f'Est. halving date (UTC): {date(t_next)}\n' - f'Est. time until halving: {dhms(cur["time"] + t_rem - clock_time)}' - ) + f'Est. time until halving: {dhms(cur["time"] + t_rem - clock_time)}') async def print_halvings(): halving_blocknums = [i*proto.halving_interval for i in range(proto.max_halvings+1)][1:] @@ -109,13 +107,13 @@ async def main(): nhist = len(hist_halvings) nSubsidy = int(proto.start_subsidy / proto.coin_amt.satoshi) - block0_hash = await c.call('getblockhash',0) - block0_date = (await c.call('getblock',block0_hash))['time'] + block0_hash = await c.call('getblockhash', 0) + block0_date = (await c.call('getblock', block0_hash))['time'] def gen_data(): total_mined = 0 date = block0_date - for n,blk in enumerate(halving_blocknums): + for n, blk in enumerate(halving_blocknums): mined = (nSubsidy >> n) * proto.halving_interval if n == 0: mined -= nSubsidy # subtract unspendable genesis block subsidy @@ -124,13 +122,11 @@ async def main(): bdi = ( (hist_halvings[n]['time'] - date) / (proto.halving_interval * 60) if n < nhist else bdr/60 if n == nhist - else bdr_proj - ) + else bdr_proj) date = ( hist_halvings[n]['time'] if n < nhist - else t_next + int((n - nhist) * halving_secs) - ) - yield ( n, sub, blk, mined, total_mined, bdi, date ) + else t_next + int((n - nhist) * halving_secs)) + yield (n, sub, blk, mined, total_mined, bdi, date) if sub == 0: break @@ -148,11 +144,10 @@ async def main(): b = 'BLOCK', c = 'DATE', d = '', - e = f'BDI (mins)', - f = f'SUBSIDY ({proto.coin})', + e = 'BDI (mins)', + f = 'SUBSIDY ({proto.coin})', g = f'MINED ({proto.coin})', - h = f'TOTAL MINED ({proto.coin})' - ) + h = f'TOTAL MINED ({proto.coin})') + '\n' + fs.format( a = '-' * 7, @@ -160,26 +155,24 @@ async def main(): c = '-' * 19, d = '-' * 2, e = '-' * 10, - f = '-' * 13, + f = '-' * 17, g = '-' * 17, - h = '-' * 17 - ) + h = '-' * 17) + '\n' + '\n'.join(fs.format( - a = n + 1, - b = blk, - c = date(t), - d = ' P' if n > nhist else '' if n < nhist else ' E', - e = f'{bdr:8.5f}', - f = proto.coin_amt(sub,from_unit='satoshi').fmt(iwidth=2,prec=8), - g = proto.coin_amt(mined,from_unit='satoshi').fmt(iwidth=8,prec=8), - h = proto.coin_amt(total_mined,from_unit='satoshi').fmt(iwidth=8,prec=8) - ) for n,sub,blk,mined,total_mined,bdr,t in gen_data()) - ) + a = n + 1, + b = blk, + c = date(t), + d = ' P' if n > nhist else '' if n < nhist else ' E', + e = f'{bdr:8.5f}', + f = proto.coin_amt(sub, from_unit='satoshi').fmt(2, prec=8), + g = proto.coin_amt(mined, from_unit='satoshi').fmt(8, prec=8), + h = proto.coin_amt(total_mined, from_unit='satoshi').fmt(8, prec=8) + ) for n, sub, blk, mined, total_mined, bdr, t in gen_data())) if cfg.list: await print_halvings() else: print_current_stats() -async_run(main()) +async_run(cfg, main) diff --git a/mmgen_node_tools/main_netrate.py b/mmgen_node_tools/main_netrate.py index f1661c5..f5cf992 100755 --- a/mmgen_node_tools/main_netrate.py +++ b/mmgen_node_tools/main_netrate.py @@ -20,7 +20,7 @@ mmnode-netrate: Bitcoin daemon network rate monitor """ -import sys,time +import sys, time from mmgen.cfg import Config from mmgen.util import async_run @@ -32,41 +32,40 @@ opts_data = { 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) -""" - } +"""} } cfg = Config(opts_data=opts_data) -ERASE_LINE,CUR_UP = '\033[K','\033[1A' +ERASE_LINE, CUR_UP = '\033[K', '\033[1A' async def main(): from mmgen.rpc import rpc_init - c = await rpc_init(cfg,ignore_wallet=True) + c = await rpc_init(cfg, ignore_wallet=True) async def get_data(): d = await c.call('getnettotals') - return [float(e) for e in (d['totalbytesrecv'],d['totalbytessent'],d['timemillis'])] + return [float(e) for e in (d['totalbytesrecv'], d['totalbytessent'], d['timemillis'])] - rs = None + rs, ss, ts = (None, None, None) while True: - r,s,t = await get_data() + r, s, t = await get_data() if rs is not None: sys.stderr.write( '\rrcvd: {:9.2f} kB/s\nsent: {:9.2f} kB/s '.format( (r-rs)/(t-ts), - (s-ss)/(t-ts) )) + (s-ss)/(t-ts))) time.sleep(2) if rs is not None: - sys.stderr.write('{}{}{}'.format(ERASE_LINE,CUR_UP,ERASE_LINE)) + sys.stderr.write('{}{}{}'.format(ERASE_LINE, CUR_UP, ERASE_LINE)) - rs,ss,ts = r,s,t + rs, ss, ts = (r, s, t) try: - async_run(main()) + async_run(cfg, main) except KeyboardInterrupt: sys.stderr.write('\n') diff --git a/mmgen_node_tools/main_peerblocks.py b/mmgen_node_tools/main_peerblocks.py index 6ebb5a3..6a7cb38 100755 --- a/mmgen_node_tools/main_peerblocks.py +++ b/mmgen_node_tools/main_peerblocks.py @@ -27,19 +27,18 @@ opts_data = { 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) -""" - } +"""} } +from mmgen.cfg import Config +cfg = Config(opts_data=opts_data) + async def main(): - from mmgen.cfg import Config - cfg = Config(opts_data=opts_data) - from mmgen.rpc import rpc_init - rpc = await rpc_init(cfg,ignore_wallet=True) + rpc = await rpc_init(cfg, ignore_wallet=True) - from .PeerBlocks import BlocksDisplay,PeersDisplay + from .PeerBlocks import BlocksDisplay, PeersDisplay blocks = BlocksDisplay(cfg) peers = PeersDisplay(cfg) @@ -48,4 +47,4 @@ async def main(): await peers.run(rpc) from mmgen.util import async_run -async_run(main()) +async_run(cfg, main) diff --git a/mmgen_node_tools/main_ticker.py b/mmgen_node_tools/main_ticker.py index 83d0b1c..cfca2ac 100755 --- a/mmgen_node_tools/main_ticker.py +++ b/mmgen_node_tools/main_ticker.py @@ -5,29 +5,33 @@ # 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 +# https://github.com/mmgen/mmgen-wallet https://github.com/mmgen/mmgen-node-tools +# https://gitlab.com/mmgen/mmgen-wallet https://gitlab.com/mmgen/mmgen-node-tools """ mmnode-ticker: Display price information for cryptocurrency and other assets """ -import sys,os -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), + ('widest', True, 'percent_cols', 'd,w,m,y'), + ('widest', True, 'name_labels', True), + ('widest', True, 'thousands_comma', True), + ('widest', True, 'update_time', True), + ('wide', True, 'percent_cols', 'd,w'), + ('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""" + 'usage': '[opts] [TRADE_SPECIFIER | ASSET_RANGE]', + 'options': """ -h, --help Print this help message --, --longhelp Print help message for long options (common options) +-a, --asset-limit=N Retrieve data for top ‘N’ cryptocurrencies by market + cap (default: {al}). To retrieve all available data, + specify a value of zero. -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 @@ -36,43 +40,67 @@ opts_data = { 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)}’ +-d, --download=D Retrieve and cache asset data ‘D’ from network (valid + options: {ds}) +-D, --cachedir=D Read and write cached JSON data to directory ‘D’ + instead of ‘~/{dfl_cachedir}’ -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, --percent-cols=C Add daily, weekly, monthly, or yearly percentage change + columns ‘C’ (specify with comma-separated letters + {pc}) -P, --pager Pipe the output to a pager +-q, --quiet Produce quieter output -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. +-s, --sort=P Sort output according to parameter P. Valid parameters + are {sp_codes}. See SORT PARAMETERS below. + To reverse the sort, prefix the parameter with ‘r’. +-t, --testing Print command(s) to be executed to stdout and exit -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 +-U, --pchg-unit=A Use asset ‘A’ as unit of reference for percentage + change columns (default: USD) -v, --verbose Be more verbose --w, --wide Display all optional columns (equivalent to -punT) +-w, --wide Display most optional columns (same as -unT -p d,w) +-W, --widest Display all optional columns (same as -unT -p d,w,m,y) -x, --proxy=P Connect via proxy ‘P’. Set to the empty string to - disable. Consult the curl manpage for --proxy usage. + completely disable or ‘none’ to allow override from + environment. Consult the curl manpage for --proxy usage. +-X, --proxy2=P Alternate proxy for non-crypto financial data. Defaults + to value of --proxy """, '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. +The script has three display modes: ‘overview’, enabled when no arguments are +given on the command line; ‘trading’, when a TRADE_SPECIFIER argument (see +below) is given; and ‘market cap’, when an ASSET_RANGE (see below) is given. 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. +user’s portfolio; 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; and market cap mode lists a range of crypto assets selected +by current market cap. + +The ASSET_RANGE argument can be either an integer N, in which case the top +N assets by market cap will be displayed, or a hyphen-separated range N-M, +in which case assets from N to M by market cap will be displayed. ASSETS consist of either a symbol (e.g. ‘xmr’) or full ID (see --list-ids) consisting of symbol plus label (e.g. ‘xmr-monero’). In cases where the -symbol is ambiguous, the full ID must be used. Examples: +symbol is ambiguous, the full ID must be used. For Yahoo Finance assets +the symbol and ID are identical: - chf - specify asset by symbol - chf-swiss-franc-token - same as above, but use full ID instead of symbol +Examples: + + ltc - specify asset by symbol + ltc-litecoin - same as above, but use full ID instead of symbol + ^dji - Dow Jones Industrial Average (Yahoo) + gc=f - gold futures (Yahoo) ASSET SPECIFIERS have the following format: @@ -89,7 +117,8 @@ postfixed with the letter ‘r’, its meaning is reversed, i.e. interpreted as inr:0.01257r - same as above, but use reverse rate (INR/USD) inr-indian-rupee:79.5 - same as first example, but add an arbitrary label omr-omani-rial:2.59r - Omani Rial is pegged to the Dollar at 2.59 USD - bgn-bg-lev:0.5113r:eur - Bulgarian Lev is pegged to the Euro at 0.5113 EUR + bgn-bulgarian-lev:0.5113r:eurusd=x + - Bulgarian Lev is pegged to the Euro at 0.5113 EUR A TRADE_SPECIFIER is a single argument in the format: @@ -99,8 +128,8 @@ A TRADE_SPECIFIER is a single argument in the format: 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 + xmr:17.34:eurusd=x - price of 17.34 XMR in EUR only + xmr:17.34:eurusd=x: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. @@ -109,31 +138,42 @@ A TRADE_SPECIFIER is a single argument in the format: a USD rate for the missing asset(s) must be supplied via the --add-columns or --add-rows options. +SORT PARAMETERS: + + {sp_fmt} + 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. +The remote server used to obtain the crypto price data, {cc.api_host}, +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. +from {cc.api_url}, save it as ‘ticker.json’ in your +configured cache directory and run the script with the --cached-data option. + +Financial data is obtained from {fi.desc}, which currently allows Tor. 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 {L} 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 {B} seconds. To bypass the -rate limit entirely, use --cached-data. +To protect user privacy, filtering and processing of cryptocurrency data is +performed client side so that the remote server does not know which assets +are being examined. This is done by fetching data for the top {al} crypto +assets by market cap (configurable via the --asset-limit option) with each +invocation of the script. A rate limit of {cc.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 {cc.btc_ratelimit} seconds. To bypass the rate limit +entirely, use --cached-data. + +Note that financial data obtained from {fi.api_host} is filtered in the +request, which has privacy implications. The rate limit for financial data +is {fi.ratelimit} seconds. EXAMPLES @@ -146,14 +186,15 @@ $ mmnode-ticker --btc # Wide display, add EUR and OMR columns, OMR/USD rate, extra precision and # proxy: -$ mmnode-ticker -w -c eur,omr-omani-rial:2.59r -e2 -x http://vpnhost:8118 +$ mmnode-ticker -w -c eurusd=x,omr-omani-rial:2.59r -e2 -x http://vpnhost:8118 # Wide display, elapsed update time, add EUR, BGN columns and BGN/EUR rate: -$ mmnode-ticker -wE -c eur,bgn-bulgarian-lev:0.5113r:eur +$ mmnode-ticker -wE -c eurusd=x,bgn-bulgarian-lev:0.5113r:eurusd=x -# 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 +# Widest display with all percentage change columns, 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 @@ -172,6 +213,12 @@ $ mmnode-ticker usd:2700:btc:0.123 # current spot price, at specified USDINR rate: $ mmnode-ticker -n -c inr-indian-rupee:79.5 inr:200000:btc:0.1 +# Display top 20 crypto assets by market cap, adding a Euro column: +$ mmnode-ticker -c eurusd=x 20 + +# Same as above, specifying assets using a range: +$ mmnode-ticker -c eurusd=x 1-20 + CONFIGURED ASSETS: {assets} @@ -181,31 +228,37 @@ Customize output by editing the file To add a portfolio, edit the file ~/{pf_cfg} -""" - }, +"""}, 'code': { + 'options': lambda s: s.format( + dfl_cachedir = os.path.relpath(dfl_cachedir, start=homedir), + ds = fmt_dict(DataSource.get_sources(), fmt='equal_compact'), + al = DataSource.coinpaprika.dfl_asset_limit, + sp_codes = fmt_list(sort_params, fmt='fancy'), + pc = fmt_list(Ticker.percent_cols, fmt='fancy')), '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, - L = ratelimit, - B = btc_ratelimit, - ) + 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), + al = DataSource.coinpaprika.dfl_asset_limit, + cc = src_cls['cc'](), + sp_fmt = '\n '.join(f'‘{k}’ - {v.desc}' for k, v in sort_params.items()), + fi = src_cls['fi']()) } } +import os + +from mmgen.util import fmt_list, fmt_dict from mmgen.cfg import Config -gcfg = Config( opts_data=opts_data, do_post_init=True ) +from . import Ticker -import mmgen_node_tools.Ticker as Ticker -Ticker.gcfg = gcfg +gcfg = Config(opts_data=opts_data, caller_post_init=True) -cfg_in = get_cfg_in() +src_cls, cfg_in = Ticker.make_cfg(gcfg) -cfg = make_cfg(gcfg._args,cfg_in) +from .Ticker import dfl_cachedir, homedir, DataSource, assets_list_gen, sort_params gcfg._post_init() -main(cfg,cfg_in) +Ticker.main() diff --git a/mmgen_node_tools/main_txfind.py b/mmgen_node_tools/main_txfind.py index cc8df14..0f9db5b 100755 --- a/mmgen_node_tools/main_txfind.py +++ b/mmgen_node_tools/main_txfind.py @@ -20,8 +20,10 @@ mmnode-txfind: Find a transaction in the blockchain or mempool """ +import sys + from mmgen.cfg import Config -from mmgen.util import msg,Msg,die +from mmgen.util import msg, Msg, die, is_hex_str, async_run opts_data = { 'text': { @@ -46,29 +48,26 @@ msg_data = { 'normal': { 'none': 'Transaction not found in blockchain or mempool', 'block': 'Transaction is in block {b} ({c} confirmations)', - 'mem': 'Transaction is in mempool', - }, + 'mem': 'Transaction is in mempool'}, 'quiet': { 'none': 'None', 'block': '{b} {c}', - 'mem': 'mempool', - } -} + 'mem': 'mempool'}} async def main(txid): if len(txid) != 64 or not is_hex_str(txid): - die(2,f'{txid}: invalid transaction ID') + die(2, f'{txid}: invalid transaction ID') if cfg.verbose: msg(f'TxID: {txid}') from mmgen.rpc import rpc_init - c = await rpc_init(cfg,ignore_wallet=True) + c = await rpc_init(cfg, ignore_wallet=True) exitval = 0 try: tip1 = await c.call('getblockcount') - ret = await c.call('getrawtransaction',txid,True) + ret = await c.call('getrawtransaction', txid, True) tip2 = await c.call('getblockcount') except: Msg('\r' + msgs['none']) @@ -88,6 +87,6 @@ cfg = Config(opts_data=opts_data) msgs = msg_data['quiet' if cfg.quiet else 'normal'] if len(cfg._args) != 1: - die(1,'One transaction ID must be specified') + die(1, 'One transaction ID must be specified') -sys.exit(async_run(main(cfg._args[0]))) +sys.exit(async_run(cfg, main, args=[cfg._args[0]])) diff --git a/nix/README.node-tools b/nix/README.node-tools new file mode 100644 index 0000000..9308181 --- /dev/null +++ b/nix/README.node-tools @@ -0,0 +1,11 @@ +Nix configuration directory for the MMGen Node Tools suite + +Usage is as described in ‘nix/README’ in the mmgen-wallet repository, with the +following differences: + + a) all commands are executed from the repository root of mmgen-node-tools + instead of mmgen-wallet + + b) for NixOS, complete the steps as described up until the rebuild step. + Copy the contents of this directory to ‘/etc/nixos/mmgen-project’ (this + will overwrite ‘default.nix’), and continue with the rebuild step. diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..7b4b214 --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,6 @@ +import ( + if builtins.pathExists ./merged-packages.nix then + ./merged-packages.nix + else + ../../mmgen-wallet/nix/merged-packages.nix + ) { add_pkgs_path = ./node-tools-packages.nix; } diff --git a/nix/node-tools-packages.nix b/nix/node-tools-packages.nix new file mode 100644 index 0000000..1244425 --- /dev/null +++ b/nix/node-tools-packages.nix @@ -0,0 +1,12 @@ +{ pkgs, python }: + +{ + system-packages = with pkgs; { + cacert = cacert; # ticker (curl) + }; + + python-packages = with python.pkgs; { + yahooquery = (callPackage ./yahooquery.nix {}); # ticker + pyyaml = pyyaml; # ticker + }; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..acfdee3 --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,4 @@ +import ../../mmgen-wallet/nix/shell.nix { + repo = "mmgen-node-tools"; + add_pkgs_path = ./node-tools-packages.nix; +} diff --git a/nix/use-system-libs.patch b/nix/use-system-libs.patch new file mode 100644 index 0000000..1d04487 --- /dev/null +++ b/nix/use-system-libs.patch @@ -0,0 +1,23 @@ +diff --git a/scripts/build.py b/scripts/build.py +index b705a0d..9bfcaab 100644 +--- a/scripts/build.py ++++ b/scripts/build.py +@@ -105,7 +105,6 @@ def get_curl_libraries(): + ffibuilder = FFI() + system = platform.system() + root_dir = Path(__file__).parent.parent +-download_libcurl() + + + ffibuilder.set_source( +@@ -114,9 +113,7 @@ ffibuilder.set_source( + #include "shim.h" + """, + # FIXME from `curl-impersonate` +- libraries=get_curl_libraries(), +- extra_objects=get_curl_archives(), +- library_dirs=[arch["libdir"]], ++ libraries=["curl-impersonate-chrome"], + source_extension=".c", + include_dirs=[ + str(root_dir / "include"), diff --git a/nix/yahooquery.nix b/nix/yahooquery.nix new file mode 100644 index 0000000..ecb2494 --- /dev/null +++ b/nix/yahooquery.nix @@ -0,0 +1,37 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + python, +}: + +buildPythonPackage rec { + pname = "yahooquery"; + version = "2.4.1"; + pyproject = true; + + src = fetchPypi { + pname = "yahooquery"; + version = version; + hash = "sha256-GQPGXq5qEtlelFAGNHkhbAeEbwE7riojkXkTUxt/rls="; + }; + + build-system = with python.pkgs; [ hatchling ]; + + propagatedBuildInputs = with python.pkgs; [ + curl-cffi + pandas + requests-futures + tqdm + lxml + beautifulsoup4 + ]; + + doCheck = false; # skip tests + + meta = with lib; { + description = "Python wrapper for an unofficial Yahoo Finance API"; + homepage = "https://yahooquery.dpguthrie.com"; + license = licenses.mit; + }; +} diff --git a/pyproject.toml b/pyproject.toml index 374b58c..25af44f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,3 +4,60 @@ requires = [ "wheel" ] build-backend = "setuptools.build_meta" + +[tool.ruff] +line-length = 106 +indent-width = 4 + +[tool.ruff.format] +quote-style = "single" +indent-style = "tab" + +[tool.ruff.lint] +ignore = [ + "E401", # multiple imports per line + "E701", # multiple statements per line + "E721", # use isinstance() + "E731", # lambda instead of def + "E402", # module import not top of file + "E722", # bare except + "E713", # membership 'not in' + "E741", # ambiguous variable name +] + +[tool.ruff.lint.per-file-ignores] +"test/include/common.py" = [ "F821" ] # undefined name 'cfg' +"test/misc/input_func.py" = [ "F401" ] # imported but unused +"test/modtest_d/cashaddr.py" = [ "F841" ] # assigned to but never used +"test/modtest_d/dep.py" = [ "F401" ] # imported but unused +"test/modtest_d/testdep.py" = [ "F401" ] # imported but unused +"test/modtest_d/obj.py" = [ "F841" ] # assigned to but never used +"test/objtest_d/*" = [ "F401" ] # imported but unused +"test/objattrtest_d/*" = [ "F401" ] # imported but unused +"test/overlay/fakemods/*" = [ "F403", "F405" ] # `import *` used +"test/*.py" = [ "F401" ] # imported but unused +"test/colortest.py" = [ "F403", "F405" ] # `import *` used +"test/tooltest2.py" = [ "F403", "F405" ] # `import *` used +"test/overlay/tree/*" = [ "ALL" ] + +[tool.pylint.format] +indent-string = "\t" +indent-after-paren = 2 +max-line-length = 110 + +[tool.pylint.main] +py-version = "3.7" +recursive = true +jobs = 0 + +[tool.pylint."messages control"] +ignored-modules = [ + "mmgen.term", + "mmgen.color", +] +ignored-classes = [ + "mmgen_node_tools.Ticker.Ticker.base", + "mmgen_node_tools.Ticker.DataSource.base", + "mmgen_node_tools.PeerBlocks.Display", + "mmgen_node_tools.PollDisplay.PollDisplay", +] diff --git a/setup.cfg b/setup.cfg index 0cdbed1..0753d4f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -name = MMGen Node Tools +name = mmgen-node-tools version = file: mmgen_node_tools/data/version description = Optional online tools for the MMGen wallet suite long_description = file: README.md @@ -8,22 +8,38 @@ author = The MMGen Project author_email = mmgen@tuta.io url = https://github.com/mmgen/mmgen-node-tools license = GNU GPL v3 -platforms = Linux, Armbian, Raspbian, MS Windows +platforms = Linux, Armbian, Raspbian, MS Windows, MacOS keywords = file: mmgen_node_tools/data/keywords project_urls = + Website = https://mmgen.org Bug Tracker = https://github.com/mmgen/mmgen-node-tools/issues classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: GNU General Public License v3 (GPLv3) Operating System :: POSIX :: Linux Operating System :: Microsoft :: Windows + Operating System :: MacOS + Environment :: Console + Programming Language :: Python :: 3 + Programming Language :: C + Framework :: AsyncIO + Framework :: aiohttp + Topic :: Office/Business :: Financial + Topic :: Security :: Cryptography + Topic :: Software Development :: Libraries :: Python Modules + Topic :: Utilities + Intended Audience :: Developers + Intended Audience :: End Users/Desktop + Intended Audience :: Financial and Insurance Industry + Intended Audience :: System Administrators + Development Status :: 5 - Production/Stable [options] -python_requires = >=3.7 +python_requires = >=3.11 include_package_data = True install_requires = - mmgen>=13.3.dev44 + mmgen-wallet>=16.1.dev26 + pyyaml + yahooquery packages = mmgen_node_tools diff --git a/test/cmdtest_d/httpd/ticker.py b/test/cmdtest_d/httpd/ticker.py new file mode 100755 index 0000000..6131875 --- /dev/null +++ b/test/cmdtest_d/httpd/ticker.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# +# MMGen Node Tools, terminal-based programs for Bitcoin and forkcoin nodes +# Copyright (C)2013-2025 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-node-tools +# https://gitlab.com/mmgen/mmgen-node-tools + +""" +test.cmdtest_d.httpd.ticker: Ticker WSGI http server +""" + +from . import HTTPD + +class TickerServer(HTTPD): + name = 'ticker server' + port = 19900 + content_type = 'application/json' + + def make_response_body(self, method, environ): + + with open('test/ref/ticker/ticker.json') as fh: + text = fh.read() + + return text.encode() diff --git a/test/cmdtest_d/include/cfg.py b/test/cmdtest_d/include/cfg.py new file mode 100755 index 0000000..08b6263 --- /dev/null +++ b/test/cmdtest_d/include/cfg.py @@ -0,0 +1,42 @@ +#!/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-node-tools +# https://gitlab.com/mmgen/mmgen-node-tools + +""" +test.cmdtest_d.include.cfg: configuration data for cmdtest.py +""" + +from collections import namedtuple + +cmd_groups_altcoin = [] + +gd = namedtuple('cmd_groups_data', ['clsname', 'params']) + +cmd_groups_dfl = { + 'main': gd('CmdTestMain', {}), + 'helpscreens': gd('CmdTestHelp', {'modname': 'misc', 'full_data': True}), + 'scripts': gd('CmdTestScripts', {'modname': 'misc'}), + 'regtest': gd('CmdTestRegtest', {}), +} + +cmd_groups_extra = {} + +cfgs = { + '1': {}, # regtest + '2': {}, # scripts + '3': {}, # main +} + +def fixup_cfgs(): + import os + + for k in cfgs: + cfgs[k]['tmpdir'] = os.path.join('test', 'tmp', str(k)) + +fixup_cfgs() diff --git a/test/test_py_d/ts_main.py b/test/cmdtest_d/main.py similarity index 81% rename from test/test_py_d/ts_main.py rename to test/cmdtest_d/main.py index 162e3fb..f1881f8 100755 --- a/test/test_py_d/ts_main.py +++ b/test/cmdtest_d/main.py @@ -5,20 +5,19 @@ # Licensed under the GNU General Public License, Version 3: # https://www.gnu.org/licenses # Public project repositories: -# https://github.com/mmgen/mmgen -# https://gitlab.com/mmgen/mmgen +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet """ -test_py_d.ts_main: Basic operations tests for the test.py test suite +cmdtest_d.main: Basic operations tests for the cmdtest.py test suite """ -import time +import sys, time -from ..include.common import * -from .common import * -from .ts_base import * +from ..include.common import cfg +from .base import CmdTestBase -class TestSuiteMain(TestSuiteBase): +class CmdTestMain(CmdTestBase): 'basic operations with fake RPC data' tmpdir_nums = [3] networks = ('btc',) # fake data, so test peerblocks for BTC mainnet only @@ -36,13 +35,13 @@ class TestSuiteMain(TestSuiteBase): "'mmnode-peerblocks' script", ('peerblocks1', '--help'), ('peerblocks2', 'interactive (popen spawn)'), - ('peerblocks3', 'interactive, 80 columns (pexpect_spawn)'), + ('peerblocks3', 'interactive, 80 columns (pexpect_spawn [on Linux])'), ), } def peerblocks(self,args,expect_list=None,pexpect_spawn=False): t = self.spawn( - f'mmnode-peerblocks', + 'mmnode-peerblocks', args, pexpect_spawn = pexpect_spawn ) if cfg.exact_output: # disable echoing of input @@ -95,4 +94,6 @@ class TestSuiteMain(TestSuiteBase): return t def peerblocks3(self): - return self.peerblocks2(['--columns=80'],pexpect_spawn=True) + return self.peerblocks2( + ['--columns=80'], + pexpect_spawn = sys.platform == 'linux') diff --git a/test/cmdtest_d/misc.py b/test/cmdtest_d/misc.py new file mode 100755 index 0000000..d5cb51d --- /dev/null +++ b/test/cmdtest_d/misc.py @@ -0,0 +1,502 @@ +#!/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-node-tools +# https://gitlab.com/mmgen/mmgen-node-tools + +""" +test.cmdtest_d.misc: Miscellaneous test groups for the cmdtest.py test suite +""" + +import os, shutil + +from ..include.common import cfg +from .base import CmdTestBase +from .httpd.ticker import TickerServer + +refdir = os.path.join('test','ref','ticker') + +class CmdTestHelp(CmdTestBase): + 'help, info and usage screens' + networks = ('btc','ltc','bch') + tmpdir_nums = [] + passthru_opts = ('daemon_data_dir','rpc_port','coin','testnet') + cmd_group = ( + ('version', (1,'version message',[])), + ('helpscreens', (1,'help screens', [])), + ('longhelpscreens', (1,'help screens (--longhelp)',[])), + ) + color = True + + def version(self): + t = self.spawn('mmnode-netrate', ['--version']) + t.expect('MMNODE-NETRATE version') + return t + + def helpscreens(self,arg='--help',scripts=(),expect='USAGE:.*OPTIONS:'): + + scripts = list(scripts) or [s for s in os.listdir('cmds') if s.startswith('mmnode-')] + + for s in sorted(scripts): + t = self.spawn(s,[arg],extra_desc=f'({s})') + t.expect(expect,regex=True) + t.read() + t.ok() + t.skip_ok = True + + return t + + def longhelpscreens(self): + return self.helpscreens(arg='--longhelp',expect='USAGE:.*GLOBAL OPTIONS:') + +class CmdTestScripts(CmdTestBase): + 'scripts not requiring a coin daemon' + networks = ('btc',) + tmpdir_nums = [2] + passthru_opts = () + color = True + + cmd_group_in = ( + ('subgroup.ticker', []), + ) + cmd_subgroups = { + 'ticker': ( + "'mmnode-ticker' script", + ('ticker1', 'ticker [--help]'), + ('copy_cache_files', 'copying JSON files to cache'), + ('ticker1a', 'ticker [--download=cc] (early caching)'), + ('ticker1b', 'ticker [--download=cc] (late caching)'), + ('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]'), + ('ticker16', 'ticker [--cached-data --wide --elapsed -c eur,omr-omani-rial:2.59r'), + ('ticker17', 'ticker [--cached-data --wide --elapsed -c bgn-bulgarian-lev:0.5113r:eur'), + ('ticker18', 'ticker [--cached-data --widest --add-columns eurusd=x 10]'), + ('ticker19', 'ticker [--cached-data 1-5]'), + ('ticker20', 'ticker [--cached-data 2-5]'), + ('ticker21', 'ticker [--cached-data 5-5]'), + ('ticker22', 'ticker [--sort=rp]'), + ('ticker23', 'ticker [--sort=rp xmr:10]'), + ('ticker24', 'ticker [--sort=p]'), + ('ticker25', 'ticker [--sort=p 200]'), + ('ticker26', 'ticker [--sort=c -r algo,ada]'), + ('ticker27', 'ticker [--sort=rp -r algo,ada]'), + ('ticker28', 'ticker [--sort=d -r algo,ada]'), + ('ticker29', 'ticker [--sort=y -r algo,ada]'), + ('ticker30', 'ticker [--cached-data --wide --pchg-unit=btc --sort=d] (cf with config file)'), + ('ticker31', 'ticker [--cached-data --wide --pchg-unit=usd] (cf with no USD)'), + ('ticker32', 'ticker [--cached-data --wide --pchg-unit=gc=f]'), + ('ticker33', 'ticker [--cached-data --wide --pchg-unit=btc --sort=c] (cfg file with USD)'), + ('ticker34', 'ticker [--cached-data --wide --pchg-unit=btc --sort=y] (cfg file with USD)'), + ('ticker35', 'ticker [--cached-data --wide --pchg-unit=btc --sort=p] (cfg file with USD)'), + ) + } + + def __init__(self, cfg, trunner, cfgs, spawn): + if not trunner: + return + self.ticker_server = TickerServer(cfg) + self.ticker_server.start() + self.dests = { + 'nt_datadir': os.path.join(cfg.data_dir_root, 'node_tools'), + 'cache': self.tmpdir} + return super().__init__(cfg, trunner, cfgs, spawn) + + def rm_file(self, fn, dest='nt_datadir'): + os.unlink(os.path.join(self.dests[dest], fn)) + + def copy_file(self, src_fn, dest_fn=None, dest='nt_datadir'): + shutil.copy2( + os.path.join(refdir, src_fn), + os.path.join(self.dests[dest], dest_fn or src_fn)) + + def copy_cache_files(self): + self.spawn('', msg_only=True) + self.copy_file('ticker-finance.json', dest='cache') + self.copy_file('ticker-finance-history.json', dest='cache') + self.copy_file('ticker-btc.json', dest='cache') + return 'ok' + + def ticker( + self, + args = [], + expect_list = None, + cached_data = True, + add_opts = [], + use_proxy = True, + no_msg = False, + exit_val = None): + t = self.spawn( + 'mmnode-ticker', + (['--cached-data'] if cached_data else []) + + [f'--cachedir={self.tmpdir}'] + + (['--proxy=http://asdfzxcv:32459'] if use_proxy else []) + + add_opts + + args, + no_msg = no_msg, + exit_val = exit_val) + if expect_list: + t.match_expect_list(expect_list) + return t + + def ticker1(self): + t = self.ticker(['--help']) + t.expect('USAGE:') + return t + + def ticker1a(self, first_run=True): + t = self.ticker( + add_opts = ['--proxy', '', '--download=cc'], + cached_data = False, + use_proxy = False) + if first_run and not cfg.skipping_deps: + t.expect('Creating') + t.expect('Creating') + return t + + def ticker1b(self): + return self.ticker1a(first_run=False) + + def ticker2(self): + t = self.ticker(cached_data=False) + ret = t.expect(['proxy host could not be resolved', 'unexpected keyword']) + t.exit_val = 1 if ret else 3 + return t + + def ticker3(self): + return self.ticker( + [], + [ + 'USD BTC', + 'BTC 23250.77 1.00000000 ETH 1659.66 0.07138094' + ]) + + def ticker4(self): + return self.ticker( + ['--widest','--add-columns=eurusd=x,inr-indian-rupee:79.5'], + [ + r'EURUSD=X \(EUR/USD\) = 1.0642 USD ' + + r'INR \(INDIAN RUPEE\) = 0.012579 USD', + 'USD EURUSD=X INR BTC CHG_1y CHG_30d CHG_7d CHG_24h UPDATED', + 'BITCOIN', + r'ETHEREUM 1,659.66 1,559.5846 131,943.14 0.07138094 \+36.41 \+29.99 \+21.42 \+1.82', + r'MONERO 158.97 149.3870 12,638.36 0.00683732 \+12.38 \+10.19 \+7.28 \+1.21 2022-08-02 18:25:59', + r'S&P 500 4,320.06 4,059.5604 343,444.77 0.18580285 -1.71 \+12.93 \+9.05 -0.23', + r'INDIAN RUPEE 0.01 0.0118 1.00 0.00000054 -- -- -- --', + ]) + + def ticker5(self): + self.copy_file('ticker-cfg.yaml') + t = self.ticker( + ['--wide','--adjust=-0.766'], + [ + 'Adjusting prices by -0.77%', + 'USD BTC CHG_7d CHG_24h UPDATED', + r'LITECOIN 58.56 0.00251869 \+12.79 \+0.40 2022-08-02 18:25:59', + r'MONERO 157.76 0.00678495 \+7.28 \+1.21' + ]) + self.rm_file('ticker-cfg.yaml') + return t + + def ticker6(self): + t = self.ticker(['--wide','--portfolio'], None, exit_val=1) + t.expect('No portfolio') + return t + + def ticker7(self): # demo + self.copy_file('ticker-portfolio.yaml') + t = self.ticker( + ['--wide','--portfolio'], + [ + 'USD BTC CHG_7d CHG_24h UPDATED', + r'ETHEREUM 1,659.66 0.07138094 \+21.42 \+1.82 2022-08-02 18:25:59', + 'CARDANO','ALGORAND', + 'PORTFOLIO','BITCOIN','ETHEREUM','MONERO','CARDANO','ALGORAND','TOTAL' + ]) + self.rm_file('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): + self.copy_file('ticker-portfolio-bad.yaml', '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 -- -- --', + r'\(no data for noc-nocoin\)', + ]) + self.rm_file('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', + 'GC=F',r'\^IXIC', + ]) + + 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 -- ' + + 'BITCOIN 0.10819955 0.10000000 10 minutes ago' + ]) + + def ticker14(self): + self.copy_file('ticker-portfolio.yaml') + t = self.ticker( + ['--btc','--wide','--portfolio','--elapsed'], + [ + 'PRICES', + r'BITCOIN 23,368.86 \+6.05 -1.87 1 day 9 hours 2 minutes ago', + 'PORTFOLIO', + r'BITCOIN 28,850.44 \+6.05 -1.87 1.23456789' + ]) + self.rm_file('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 1 day 9 hours 2 minutes ago ' + + 'US DOLLAR 46,737.71911598 45,000.00000000 --', + ]) + + def ticker16(self): + return self.ticker( + ['--wide','--elapsed','-c','eurusd=x,omr-omani-rial:2.59r'], + [ + r'EURUSD=X \(EUR/USD\) = 1.0642 USD ' + + r'OMR \(OMANI RIAL\) = 2.5900 USD', + 'USD EURUSD=X OMR BTC CHG_7d CHG_24h UPDATED', + r'BITCOIN 23,250.77 21,848.7527 8,977.1328 1.00000000 \+11.15 \+0.89 10 minutes ago', + 'OMANI RIAL 2.59 2.4338 1.0000 0.00011139 -- -- --' + ]) + + def ticker17(self): + # BGN pegged at 0.5113 EUR + return self.ticker( + ['--wide','--elapsed','-c','bgn-bulgarian-lev:0.5113r:eurusd=x'], + [ + r'BGN \(BULGARIAN LEV\) = 0.54411 USD', + 'USD BGN BTC CHG_7d CHG_24h UPDATED', + 'BITCOIN 23,250.77 42,731.767 1.00000000', + 'BULGARIAN LEV 0.54 1.000 0.00002340', + ]) + + def ticker18(self): + return self.ticker( + ['10'], + [ + r'1\) BITCOIN 444.33652 23,250.77 21,848.7527 1.00000000 \+18.96 \+15.61 \+11.15 \+0.89', + r'33\) ALGORAND 2.30691 0.33 0.3120 0.00001428 \+16.47 \+13.57 \+9.69 \-0.82' + ], + add_opts = ['--widest', '--add-columns=eurusd=x']) + + def ticker19(self): + return self.ticker( + ['1-5'], + [ + r'MarketCap\(B\) USD EURUSD=X BTC ' + '---------------------------------------------------------- ' + r'1\) BTC 444.33652 23250.77 21848.7527 1.00000000', + r'8\) ADA 17.11161 0.51 0.4764 0.00002180' + ' ----------------------------------------------------------' + ], + add_opts = ['--add-columns=eurusd=x']) + + def ticker20(self): + return self.ticker( + ['2-5'], + [ + r'MarketCap\(B\) USD EURUSD=X BTC ' + '---------------------------------------------------------- ' + r'2\) ETH 202.15129 1659.66 1559.5846 0.07138094', + r'8\) ADA 17.11161 0.51 0.4764 0.00002180', + ], + add_opts = ['--add-columns=eurusd=x']) + + def ticker21(self): + return self.ticker( + ['5-5'], + [ + r'MarketCap\(B\) USD EURUSD=X BTC ' + '--------------------------------------------------------- ' + r'8\) ADA 17.11161 0.51 0.4764 0.00002180', + ], + add_opts = ['--add-columns=eurusd=x']) + + def ticker22(self): + self.copy_file('ticker-cfg-bad.yaml', 'ticker-cfg.yaml') + t = self.ticker( + [], + ['MONERO', 'ETHEREUM', 'BITCOIN', 'SILVER', 'BRENT', 'GOLD'], + add_opts = ['--name-labels', '--sort=rp']) + self.rm_file('ticker-cfg.yaml') + return t + + def ticker23(self): + return self.ticker( + [], + ['MONERO', 'ETHEREUM', 'BITCOIN', 'SILVER', 'BRENT', 'GOLD'], + add_opts = ['--name-labels', '--sort=rp', 'xmr:10']) + + def ticker24(self): + return self.ticker( + [], + ['BITCOIN', 'ETHEREUM', 'MONERO', 'GOLD', 'BRENT', 'SILVER'], + add_opts = ['--name-labels', '--sort=p']) + + def ticker25(self): + return self.ticker( + [], + [ + r' 1\) BITCOIN', + r' 2\) ETHEREUM', + r'30\) MONERO', + r'23\) LITECOIN', + r' 8\) CARDANO', + r'33\) ALGORAND' + ], + add_opts = ['--name-labels', '--sort=p', '200']) + + def ticker26(self): + return self.ticker( + [], + ['BITCOIN', 'ETHEREUM', 'MONERO', 'CARDANO', 'ALGORAND'], + add_opts = ['--name-labels', '--sort=c', '-r', 'ada,algo']) + + def ticker27(self): + return self.ticker( + [], + ['MONERO', 'ETHEREUM', 'BITCOIN', 'S&P', 'NASDAQ', 'DOW', 'ALGORAND', 'CARDANO'], + add_opts = ['--name-labels', '--sort=rp', '--add-rows=ada-cardano,algo-algorand']) + + def ticker28(self): + return self.ticker( + [], + ['ETHEREUM', 'MONERO', 'BITCOIN', 'NASDAQ', 'S&P', 'DOW', 'CARDANO', 'ALGORAND'], + add_opts = ['--widest', '--sort=d', '-r', 'ada,algo']) + + def ticker29(self): + return self.ticker( + [], + ['ETHEREUM', 'BITCOIN', 'MONERO', 'S&P', 'DOW', 'NASDAQ', 'CARDANO', 'ALGORAND'], + add_opts = ['--widest', '-s', 'y', '-r', 'ada,algo']) + + def ticker30(self): + self.copy_file('ticker-cfg-sort-pchg.yaml', 'ticker-cfg.yaml') + t = self.ticker(add_opts=['--wide']) + chk1 = '\n'.join(t.read().splitlines()[5:-2]) + self.rm_file('ticker-cfg.yaml') + + self.copy_file('ticker-cfg-bad.yaml', 'ticker-cfg.yaml') + t = self.ticker(add_opts=['--wide', '--pchg-unit=btc', '--sort=d'], no_msg=True) + chk2 = '\n'.join(t.read().splitlines()[5:-2]) + self.rm_file('ticker-cfg.yaml') + + assert chk1 == chk2, f'\nOUTPUT 1\n{chk1}\n!= OUTPUT 2\n{chk2}\n' + return t + + def ticker31(self): + t = self.ticker(add_opts=['--wide']) + chk1 = '\n'.join(t.read().splitlines()[5:-2]) + + t = self.ticker(add_opts=['--wide', '--pchg-unit=usd'], no_msg=True) + chk2 = '\n'.join(t.read().splitlines()[6:-2]) + + assert chk1 == chk2, f'\nOUTPUT 1\n{chk1}\n!= OUTPUT 2\n{chk2}\n' + return t + + def ticker32(self): + return self.ticker( + [], + [ + 'BITCOIN', r'\+10.99', r'\+7.06', '-1.18', r'\+1.05', + 'ETHEREUM', + 'GOLD', r'\+0.00', r'\+0.00', r'\+0.00', r'\+0.00', + 'SILVER' + ], + add_opts = ['--widest', '--pchg-unit=gc=f', '--sort=c']) + + def _ticker_cur(self, sort): + self.copy_file('ticker-cfg-usd.yaml', 'ticker-cfg.yaml') + t = self.ticker( + [], + [ + 'BITCOIN 23,250.77 1.00000000 \+0.00', + 'US DOLLAR 1.00 0.00004301 -15.93', + ], + add_opts = ['--widest', '--pchg-unit=btc', f'--sort={sort}']) + self.rm_file('ticker-cfg.yaml') + return t + + def ticker33(self): + return self._ticker_cur(sort='c') + + def ticker34(self): + return self._ticker_cur(sort='y') + + def ticker35(self): + return self._ticker_cur(sort='p') diff --git a/test/test_py_d/ts_regtest.py b/test/cmdtest_d/regtest.py similarity index 54% rename from test/test_py_d/ts_regtest.py rename to test/cmdtest_d/regtest.py index f8859b8..387594b 100755 --- a/test/test_py_d/ts_regtest.py +++ b/test/cmdtest_d/regtest.py @@ -9,36 +9,38 @@ # https://gitlab.com/mmgen/mmgen-node-tools """ -test.test_py_d.ts_regtest: Regtest tests for the test.py test suite +test.cmdtest_d.regtest: Regtest tests for the cmdtest.py test suite """ -import os -from mmgen.util import die,gmsg +import sys, os +from decimal import Decimal + +from mmgen.util import msg_r, die, gmsg from mmgen.protocol import init_proto from mmgen.proto.btc.regtest import MMGenRegtest -from ..include.common import * -from .common import * -from .ts_base import * +from ..include.common import cfg, imsg, stop_test_daemons, joinpath +from .base import CmdTestBase args1 = ['--bob'] -args2 = ['--bob','--rpc-backend=http'] +args2 = ['--bob', '--rpc-backend=http'] -def gen_addrs(proto,network,keys): +def gen_addrs(proto, network, keys): from mmgen.tool.api import tool_api tool = tool_api(cfg) - tool.init_coin(proto.coin,'regtest') + tool.init_coin(proto.coin, 'regtest') tool.addrtype = proto.mmtypes[-1] return [tool.privhex2addr('{:064x}'.format(key)) for key in keys] -class TestSuiteRegtest(TestSuiteBase): +class CmdTestRegtest(CmdTestBase): 'various operations via regtest mode' - networks = ('btc','ltc','bch') + networks = ('btc', 'ltc', 'bch') passthru_opts = ('coin',) - extra_spawn_args = ['--regtest=1'] tmpdir_nums = [1] color = True deterministic = False + bdb_wallet = True + cmd_group_in = ( ('setup', 'regtest mode setup'), ('subgroup.netrate', []), @@ -103,180 +105,194 @@ class TestSuiteRegtest(TestSuiteBase): ), } - def __init__(self,trunner,cfgs,spawn): - TestSuiteBase.__init__(self,trunner,cfgs,spawn) - if trunner == None: + def __init__(self, cfg, trunner, cfgs, spawn): + CmdTestBase.__init__(self, cfg, trunner, cfgs, spawn) + if trunner is None: return - if self.proto.testnet: - die(2,'--testnet and --regtest options incompatible with regtest test suite') + if cfg._proto.testnet: + die(2, '--testnet and --regtest options incompatible with regtest test suite') self.proto = init_proto( cfg, self.proto.coin, network='regtest', need_amt=True ) - self.addrs = gen_addrs(self.proto,'regtest',[1,2,3,4,5]) - self.regtest = MMGenRegtest(cfg,self.proto.coin) + self.addrs = [a.views[a.view_pref] for a in gen_addrs(self.proto, 'regtest', [1, 2, 3, 4, 5])] + + self.use_bdb_wallet = self.bdb_wallet and self.proto.coin != 'BTC' + self.regtest = MMGenRegtest(cfg, self.proto.coin, bdb_wallet=self.use_bdb_wallet) def setup(self): - stop_test_daemons(self.proto.network_id,force=True,remove_datadir=True) + stop_test_daemons(self.proto.network_id, force=True, remove_datadir=True) from shutil import rmtree - try: rmtree(joinpath(self.tr.data_dir,'regtest')) - except: pass - t = self.spawn('mmgen-regtest',['-n','setup']) - for s in ('Starting','Creating','Creating','Creating','Mined','Setup complete'): + try: + rmtree(joinpath(self.tr.data_dir, 'regtest')) + except: + pass + t = self.spawn( + 'mmgen-regtest', + (['--bdb-wallet'] if self.use_bdb_wallet else []) + + ['--setup-no-stop-daemon', 'setup']) + for s in ('Starting', 'Creating', 'Creating', 'Creating', 'Mined', 'Setup complete'): t.expect(s) return t - def netrate(self,add_args,expect_str): - t = self.spawn( 'mmnode-netrate', args1 + add_args ) - t.expect(expect_str,regex=True) + def netrate(self, add_args, expect_str, exit_val=None): + t = self.spawn('mmnode-netrate', args1 + add_args, exit_val=exit_val) + t.expect(expect_str, regex=True) return t def netrate1(self): return self.netrate( ['--help'], 'USAGE:.*' ) def netrate2(self): - t = self.netrate( [], r'sent:.*' ) + t = self.netrate([], r'sent:.*', exit_val=-15) t.kill(15) - t.req_exit_val = -15 + if sys.platform == 'win32': + return 'ok' return t - def halving_calculator(self,add_args,expect_list): - t = self.spawn('mmnode-halving-calculator',args1+add_args) + def halving_calculator(self, add_args, expect_list): + t = self.spawn('mmnode-halving-calculator', args1+add_args) t.match_expect_list(expect_list) return t def halving_calculator1(self): - return self.halving_calculator(['--help'],['USAGE:']) + return self.halving_calculator(['--help'], ['USAGE:']) def halving_calculator2(self): - return self.halving_calculator([],['Current block: 393',f'Current block subsidy: 12.5 {cfg.coin}']) + return self.halving_calculator([], ['Current block: 393', f'Current block subsidy: 12.5 {cfg.coin}']) def halving_calculator3(self): - return self.halving_calculator(['--list'],['33 4950','0']) + return self.halving_calculator(['--list'], ['33 4950', '0']) def halving_calculator4(self): - return self.halving_calculator(['--mined'],['0 0.0000015 14949.9999835']) + return self.halving_calculator(['--mined'], ['0 0.0000015 14949.9999835']) def halving_calculator5(self): - return self.halving_calculator(['--mined','--bdr-proj=5'],['5.00000 0 0.0000015 14949.9999835']) + return self.halving_calculator(['--mined', '--bdr-proj=5'], ['5.00000 0 0.0000015 14949.9999835']) def halving_calculator6(self): - return self.halving_calculator(['--mined','--sample-size=20'],['33 4950','0 0.0000015 14949.9999835']) + return self.halving_calculator(['--mined', '--sample-size=20'], ['33 4950', '0 0.0000015 14949.9999835']) - def sendto(self,addr,amt): - return self.spawn('mmgen-regtest',['send',addr,amt]) + def sendto(self, addr, amt): + return self.spawn('mmgen-regtest', ['send', addr, amt]) - def sendto1(self): return self.sendto(self.addrs[0],'0.123') - def sendto2(self): return self.sendto(self.addrs[0],'0.234') - def sendto3(self): return self.sendto(self.addrs[1],'0.345') + def sendto1(self): return self.sendto(self.addrs[0], '0.123') + def sendto2(self): return self.sendto(self.addrs[0], '0.234') + def sendto3(self): return self.sendto(self.addrs[1], '0.345') - def addrbal(self,args,expect_list): - t = self.spawn('mmnode-addrbal',args) + def addrbal(self, args, expect_list): + t = self.spawn('mmnode-addrbal', args2 + args) t.match_expect_list(expect_list) return t def addrbal_single(self): return self.addrbal( - args2 + [self.addrs[0]], + [self.addrs[0]], [ f'Balance: 0.357 {cfg.coin}', '2 unspent outputs in 2 blocks', - '394','0.123', - '395','0.234' + '394', '0.123', + '395', '0.234' ]) def addrbal_multiple(self): return self.addrbal( - args2 + [self.addrs[1],self.addrs[0]], + [self.addrs[1], self.addrs[0]], [ - '396','0.345', - '394','0.123', - '395','0.234' + '396', '0.345', + '394', '0.123', + '395', '0.234' ]) def addrbal_multiple_tabular1(self): return self.addrbal( - args2 + ['--tabular',self.addrs[1],self.addrs[0]], + ['--tabular', self.addrs[1], self.addrs[0]], [ - self.addrs[1] + ' 1 396','0.345', - self.addrs[0] + ' 2 395','0.357' + self.addrs[1] + ' 1 396', '0.345', + self.addrs[0] + ' 2 395', '0.357' ]) def addrbal_multiple_tabular2(self): return self.addrbal( - args2 + ['--tabular','--first-block',self.addrs[1],self.addrs[0]], + ['--tabular', '--first-block', self.addrs[1], self.addrs[0]], [ - self.addrs[1] + ' 1 396','396','0.345', - self.addrs[0] + ' 2 394','395','0.357' + self.addrs[1] + ' 1 396', '396', '0.345', + self.addrs[0] + ' 2 394', '395', '0.357' ]) def addrbal_nobal1(self): return self.addrbal( - args2 + [self.addrs[2]], ['Address has no balance'] ) + [self.addrs[2]], ['Address has no balance']) def addrbal_nobal2(self): return self.addrbal( - args2 + [self.addrs[2],self.addrs[3]], ['Addresses have no balances'] ) + [self.addrs[2], self.addrs[3]], ['Addresses have no balances']) def addrbal_nobal3(self): return self.addrbal( - args2 + [self.addrs[4],self.addrs[0],self.addrs[3]], + [self.addrs[4], self.addrs[0], self.addrs[3]], [ 'No balance', '2 unspent outputs in 2 blocks', - '394','0.123','395','0.234', + '394', '0.123', '395', '0.234', 'No balance' ]) def addrbal_nobal3_tabular1(self): return self.addrbal( - args2 + ['--tabular',self.addrs[4],self.addrs[0],self.addrs[3]], + ['--tabular', self.addrs[4], self.addrs[0], self.addrs[3]], [ self.addrs[4] + ' - - -', - self.addrs[0] + ' 2 395','0.357', + self.addrs[0] + ' 2 395', '0.357', self.addrs[3] + ' - - -', ]) def addrbal_nobal3_tabular2(self): return self.addrbal( - args2 + ['--tabular','--first-block',self.addrs[4],self.addrs[0],self.addrs[3]], + ['--tabular', '--first-block', self.addrs[4], self.addrs[0], self.addrs[3]], [ self.addrs[4] + ' - - - -', - self.addrs[0] + ' 2 394','395','0.357', + self.addrs[0] + ' 2 394', '395', '0.357', self.addrs[3] + ' - - - -', ]) - def blocks_info(self,args,expect_list): - t = self.spawn('mmnode-blocks-info',args) + def blocks_info(self, args, expect_list): + t = self.spawn('mmnode-blocks-info', args1 + args) t.match_expect_list(expect_list) return t def blocks_info1(self): - return self.blocks_info( args1 + ['--help'], ['USAGE:','OPTIONS:']) + return self.blocks_info( + ['--help'], + ['USAGE:', 'OPTIONS:']) def blocks_info2(self): - return self.blocks_info( args1, [ - 'Current height: 396', - ]) + return self.blocks_info( + [], + ['Current height: 396']) def blocks_info3(self): - return self.blocks_info( args1 + ['+100'], [ - 'Range: 297-396', - 'Current height: 396', - 'Next diff adjust: 2016' - ]) + return self.blocks_info( + ['+100'], + [ + 'Range: 297-396', + 'Current height: 396', + 'Next diff adjust: 2016' + ]) def blocks_info4(self): - n1,i1,o1,n2,i2,o2 = (2,1,3,6,3,9) if cfg.coin == 'BCH' else (2,1,4,6,3,12) - return self.blocks_info( args1 + ['--miner-info','--fields=all','--stats=all','+3'], [ - 'Averages', - f'nTx: {n1}', - f'Inputs: {i1}', - f'Outputs: {o1}', - 'Totals', - f'nTx: {n2}', - f'Inputs: {i2}', - f'Outputs: {o2}', - 'Current height: 396', - 'Next diff adjust: 2016' - ]) + n1, i1, o1, n2, i2, o2 = (2, 1, 3, 6, 3, 9) if cfg.coin == 'BCH' else (2, 1, 4, 6, 3, 12) + return self.blocks_info( + ['--miner-info', '--fields=all', '--stats=all', '+3'], + [ + 'Averages', + f'nTx: {n1}', + f'Inputs: {i1}', + f'Outputs: {o1}', + 'Totals', + f'nTx: {n2}', + f'Inputs: {i2}', + f'Outputs: {o2}', + 'Current height: 396', + 'Next diff adjust: 2016' + ]) async def feeview_setup(self): @@ -286,61 +302,74 @@ class TestSuiteRegtest(TestSuiteBase): from collections import namedtuple t = tool_api(cfg) - t.init_coin(self.proto.coin,self.proto.network) + t.init_coin(self.proto.coin, self.proto.network) t.addrtype = 'compressed' if self.proto.coin == 'BCH' else 'bech32' - wp = namedtuple('wifaddrpair',['wif','addr']) + wp = namedtuple('wifaddrpair', ['wif', 'addr']) def gen(): - for n in range(0xfaceface,nPairs+0xfaceface): + for n in range(0xfaceface, nPairs+0xfaceface): wif = t.hex2wif(f'{n:064x}') yield wp( wif, t.wif2addr(wif) ) return list(gen()) - def gen_fees(n_in,low,high): + def gen_fees(n_in, low, high): # very approximate tx size estimation: - ibytes,wbytes,obytes = (148,0,34) if self.proto.coin == 'BCH' else (43,108,31) - x = (ibytes + (wbytes//4) + (obytes * nPairs)) * self.proto.coin_amt(self.proto.coin_amt.satoshi) + ibytes, wbytes, obytes = (148, 0, 34) if self.proto.coin == 'BCH' else (43, 108, 31) + x = (ibytes + (wbytes//4) + (obytes * nPairs)) * self.proto.coin_amt.satoshi n = n_in - 1 vmax = high - low for i in range(n_in): - yield (low + (i/n)**6 * vmax) * x + yield Decimal(low + (i/n)**6 * vmax) * x - async def do_tx(inputs,outputs,wif): - tx_hex = await r.rpc_call( 'createrawtransaction', inputs, outputs ) - tx = await r.rpc_call( 'signrawtransactionwithkey', tx_hex, [wif], [], self.proto.sighash_type ) - assert tx['complete'] == True + async def do_tx(inputs, outputs, wif): + tx_hex = await r.rpc_call('createrawtransaction', inputs, outputs) + if wif: + tx = await r.rpc_call( + 'signrawtransactionwithkey', + tx_hex, + [wif], + [], + self.proto.sighash_type) + else: + tx = await r.rpc_call( + 'signrawtransactionwithwallet', + tx_hex, + None, # prevtxs + self.proto.sighash_type, + wallet = 'miner') + assert tx['complete'] return tx['hex'] async def do_tx1(): - us = await r.rpc_call('listunspent',wallet='miner') + us = await r.rpc_call('listunspent', wallet='miner') tx_input = us[7] # 25 BTC in coinbase -- us[0] could have < 25 BTC fee = self.proto.coin_amt('0.001') - outputs = {p.addr:tx1_amt for p in pairs[:nTxs]} - outputs.update({burn_addr: tx_input['amount'] - (tx1_amt*nTxs) - fee}) + outputs = {p.addr: tx1_amt for p in pairs[:nTxs]} + outputs.update({burn_addr: self.proto.coin_amt(tx_input['amount']) - (tx1_amt*nTxs) - fee}) return await do_tx( - [{ 'txid': tx_input['txid'], 'vout': 0 }], + [{'txid': tx_input['txid'], 'vout': 0}], outputs, - r.miner_wif ) + await r.miner_wif) - async def do_tx2(tx,pairno): - fee = fees[pairno] - outputs = {p.addr:tx2_amt for p in pairs} + async def do_tx2(tx, pairno): + fee = self.proto.coin_amt(fees[pairno], from_decimal=True) + outputs = {p.addr: tx2_amt for p in pairs} outputs.update({burn_addr: tx1_amt - (tx2_amt*len(pairs)) - fee}) return await do_tx( - [{ 'txid': tx['txid'], 'vout': pairno }], + [{'txid': tx['txid'], 'vout': pairno}], outputs, pairs[pairno].wif ) async def do_txs(tx_in): for pairno in range(nTxs): - tx_hex = await do_tx2(tx_in,pairno) - await r.rpc_call('sendrawtransaction',tx_hex) + tx_hex = await do_tx2(tx_in, pairno) + await r.rpc_call('sendrawtransaction', tx_hex) - self.spawn('',msg_only=True) + self.spawn('', msg_only=True) r = self.regtest nPairs = 100 @@ -355,22 +384,22 @@ class TestSuiteRegtest(TestSuiteBase): imsg(f'Creating funding transaction with {nTxs} outputs of value {tx1_amt} {self.proto.coin}') tx1_hex = await do_tx1() - imsg(f'Relaying funding transaction') - await r.rpc_call('sendrawtransaction',tx1_hex) + imsg('Relaying funding transaction') + await r.rpc_call('sendrawtransaction', tx1_hex) - imsg(f'Mining a block') - await r.generate(1,silent=True) + imsg('Mining a block') + await r.generate(1, silent=True) - imsg(f'Generating fees for mempool transactions') - fees = list(gen_fees(nTxs,2,120)) + imsg('Generating fees for mempool transactions') + fees = list(gen_fees(nTxs, 2, 120)) imsg(f'Creating and relaying {nTxs} mempool transactions with {nPairs} outputs each') - await do_txs(await r.rpc_call('decoderawtransaction',tx1_hex)) + await do_txs(await r.rpc_call('decoderawtransaction', tx1_hex)) return 'ok' - def _feeview(self,args,expect_list=[]): - t = self.spawn('mmnode-feeview',args) + def _feeview(self, args, expect_list=[]): + t = self.spawn('mmnode-feeview', args1 + args) if expect_list: t.match_expect_list(expect_list) return t @@ -379,7 +408,7 @@ class TestSuiteRegtest(TestSuiteBase): return self._feeview([]) def feeview2(self): - return self._feeview(['--columns=40','--include-current']) + return self._feeview(['--columns=40', '--include-current']) def feeview3(self): return self._feeview(['--precision=6']) @@ -388,7 +417,7 @@ class TestSuiteRegtest(TestSuiteBase): return self._feeview(['--detail']) def feeview5(self): - return self._feeview(['--show-empty','--log',f'--outdir={self.tmpdir}']) + return self._feeview(['--show-empty', '--log', f'--outdir={self.tmpdir}']) def feeview6(self): return self._feeview(['--ignore-below=1MB']) @@ -398,13 +427,13 @@ class TestSuiteRegtest(TestSuiteBase): async def feeview8(self): imsg('Clearing mempool') - await self.regtest.generate(1,silent=True) + await self.regtest.generate(1, silent=True) return self._feeview([]) def stop(self): if cfg.no_daemon_stop: - self.spawn('',msg_only=True) + self.spawn('', msg_only=True) msg_r('(leaving daemon running by user request)') return 'ok' else: - return self.spawn('mmgen-regtest',['stop']) + return self.spawn('mmgen-regtest', ['stop']) diff --git a/test/init.sh b/test/init.sh index a8d03a9..eec6ec3 100755 --- a/test/init.sh +++ b/test/init.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # # mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet # Copyright (C)2013-2022 The MMGen Project @@ -10,6 +10,7 @@ RED="\e[31;1m" GREEN="\e[32;1m" YELLOW="\e[33;1m" BLUE="\e[34;1m" RESET="\e[0m" +set -e set -o errtrace set -o functrace @@ -17,77 +18,148 @@ trap 'echo -e "${GREEN}Exiting at user request$RESET"; exit' INT trap 'echo -e "${RED}Node Tools test suite initialization exited with error (line $BASH_LINENO) $RESET"' ERR umask 0022 +for i in '-c' '-f'; do + stat $i %i / >/dev/null 2>&1 && stat_fmt_opt=$i +done + +[ "$stat_fmt_opt" ] || { echo 'No suitable ‘stat’ binary found. Cannot proceed'; exit; } + +STDOUT_DEVNULL='>/dev/null' +STDERR_DEVNULL='2>/dev/null' + PROGNAME=$(basename $0) -while getopts h OPT +while getopts hcv OPT do case "$OPT" in h) printf " %-16s Initialize the MMGen Node Tools test suite\n" "${PROGNAME}:" echo " USAGE: $PROGNAME" echo " OPTIONS: '-h' Print this help message" + echo " -c Create links from mmgen-wallet ‘cmds’ subdirectory" + echo " -v Be more verbose" exit ;; + v) VERBOSE=1 STDOUT_DEVNULL='' STDERR_DEVNULL='' ;; + c) CMD_LINKS=1 ;; *) exit ;; esac done shift $((OPTIND-1)) -mm_repo='../mmgen' +wallet_repo='../mmgen-wallet' die() { echo -e ${YELLOW}ERROR: $1$RESET; false; } becho() { echo -e $BLUE$1$RESET; } check_mmgen_repo() { - ( cd $mm_repo; python3 ./setup.py --url | grep -iq 'mmgen' ) + ( cd $wallet_repo; python3 ./setup.py --url | grep -iq 'mmgen' ) } build_mmgen_extmod() { - ( cd $mm_repo; python3 ./setup.py build_ext --inplace ) + ( + cd $wallet_repo + eval "python3 ./setup.py build_ext --inplace $STDOUT_DEVNULL $STDERR_DEVNULL" + ) } create_dir_links() { - for target in 'mmgen' 'scripts'; do - src="$mm_repo/$target" - if [ -e $target ]; then - [ $(realpath --relative-to=. $target) == $src ] || die "'$target' does not point to '$src'" - else - echo "Creating symlink: $target" - ln -s $src + for link_name in 'mmgen' 'scripts'; do + target="$wallet_repo/$link_name" + if [ -L $link_name ]; then + [ "$(realpath --relative-to=. $link_name 2>/dev/null)" == $target ] || { + [ "$VERBOSE" ] && echo "Removing broken symlink '$link_name'" + rm $link_name + } + elif [ -e $link_name ]; then + die "'$link_name' is not a symbolic link. Please remove or relocate it and re-run this script" + fi + if [ ! -e $link_name ]; then + [ "$VERBOSE" ] && echo "Creating symlink: $link_name" + ln -s $target fi done } +delete_old_stuff() { + rm -rf test/unit_tests.py + rm -rf test/cmdtest_d/common.py + rm -rf test/cmdtest_d/ct_base.py + rm -rf test/cmdtest_d/group_mgr.py + rm -rf test/cmdtest_d/runner.py +} + create_test_links() { - sources=' - test/include - test/overlay/__init__.py - test/overlay/fakemods/mmgen - test/__init__.py - test/test.py - test/unit_tests.py - test/test-release.sh - test/test_py_d/common.py - test/test_py_d/ts_base.py - cmds/mmgen-regtest + paths=' + test/include symbolic + test/overlay/__init__.py symbolic + test/overlay/fakemods/mmgen symbolic + test/__init__.py symbolic + test/clean.py symbolic + test/cmdtest.py hard + test/modtest.py hard + test/test-release.sh symbolic + test/cmdtest_d/base.py symbolic + test/cmdtest_d/httpd/__init__.py symbolic + test/cmdtest_d/include/common.py symbolic + test/cmdtest_d/include/runner.py symbolic + test/cmdtest_d/include/group_mgr.py symbolic + test/cmdtest_d/include/pexpect.py symbolic + cmds/mmgen-regtest symbolic ' - for src in $sources; do - pfx=$(echo $src | sed -r 's/[^/]//g' | sed 's/\//..\//g') - if [ ! -e $src ]; then - echo "Creating symlink: $src" - ( cd "$(dirname $src)" && ln -s "$pfx$mm_repo/$src" ) + while read path type; do + [ "$path" ] || continue + pfx=$(echo $path | sed -r 's/[^/]//g' | sed 's/\//..\//g') + symlink_arg=$(if [ $type == 'symbolic' ]; then echo -s; fi) + target="$wallet_repo/$path" + if [ ! -e "$target" ]; then + echo "Target path $target is missing! Cannot proceed" + exit 1 fi - done + fs="%-8s %-16s %s -> %s\n" + if [ $type == 'hard' ]; then + if [ -L $path ]; then + [ "$VERBOSE" ] && printf "$fs" "Deleting" "symbolic link:" $path $target + rm -rf $path + elif [ -e $path ]; then + if [ "$(stat $stat_fmt_opt %i $path)" -ne "$(stat $stat_fmt_opt %i $target)" ]; then + [ "$VERBOSE" ] && printf "$fs" "Deleting" "stale hard link:" $path "?" + rm -rf $path + fi + fi + fi + if [ ! -e $path ]; then # link is either absent or a broken symlink + [ "$VERBOSE" ] && printf "$fs" "Creating" "$type link:" $path $target + ( cd "$(dirname $path)" && ln -f $symlink_arg $pfx$target ) + fi + done <<<$paths } -set -e +create_cmd_links() { + [ "$VERBOSE" ] && becho 'Creating links to mmgen-wallet repo ‘cmds’ subdirectory' + ( + filenames=$(cd $wallet_repo/cmds && ls) + cd cmds + for filename in $filenames; do + [ -e $filename ] || ln -s "../$wallet_repo/cmds/$filename" + done + ) +} becho 'Initializing MMGen Node Tools Test Suite' -check_mmgen_repo || die "MMGen repository not found at $mm_repo!" +delete_old_stuff + +check_mmgen_repo || die "MMGen Wallet repository not found at $wallet_repo!" build_mmgen_extmod +[ "$VERBOSE" ] && becho 'Creating links to mmgen-wallet repo' + create_dir_links create_test_links -becho 'OK' +[ "$CMD_LINKS" ] && create_cmd_links + +[ "$VERBOSE" ] && becho 'OK' + +true diff --git a/test/modtest_d/__init__.py b/test/modtest_d/__init__.py new file mode 100755 index 0000000..86190b5 --- /dev/null +++ b/test/modtest_d/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +""" +test.modtest_d: shared data for module tests for the MMGen Node Tools suite +""" + +altcoin_tests = [] diff --git a/test/unit_tests_d/ut_BlocksInfo.py b/test/modtest_d/ut_BlocksInfo.py similarity index 98% rename from test/unit_tests_d/ut_BlocksInfo.py rename to test/modtest_d/ut_BlocksInfo.py index e65e314..b301a66 100755 --- a/test/unit_tests_d/ut_BlocksInfo.py +++ b/test/modtest_d/ut_BlocksInfo.py @@ -3,11 +3,9 @@ test.unit_tests_d.nt_BlocksInfo: BlocksInfo unit test for the MMGen Node Tools suite """ -from mmgen.common import * -from mmgen.exception import * from mmgen_node_tools.BlocksInfo import BlocksInfo -from ..include.common import cfg,vmsg +from ..include.common import vmsg tip = 50000 range_vecs = ( diff --git a/test/modtest_d/ut_dep.py b/test/modtest_d/ut_dep.py new file mode 100755 index 0000000..641c3c3 --- /dev/null +++ b/test/modtest_d/ut_dep.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +""" +test.unit_tests_d.ut_dep: dependency unit tests for the MMGen Node Tools + + Test whether dependencies are installed and functional. + No data verification is performed. +""" + +from ..include.common import vmsg,imsg +from mmgen.color import yellow + +class unit_tests: + + def yahooquery(self,name,ut): + try: + from yahooquery import Ticker + return True + except ImportError: + imsg(yellow('Unable to import Ticker from yahooquery')) + return False diff --git a/test/overlay/fakemods/mmgen_node_tools/PeerBlocks.py b/test/overlay/fakemods/mmgen_node_tools/PeerBlocks.py index 2484628..444ae08 100644 --- a/test/overlay/fakemods/mmgen_node_tools/PeerBlocks.py +++ b/test/overlay/fakemods/mmgen_node_tools/PeerBlocks.py @@ -5,8 +5,8 @@ # Licensed under the GNU General Public License, Version 3: # https://www.gnu.org/licenses # Public project repositories: -# https://github.com/mmgen/mmgen -# https://gitlab.com/mmgen/mmgen +# https://github.com/mmgen/mmgen-wallet +# https://gitlab.com/mmgen/mmgen-wallet """ fakemods.mmgen_node_tools.PeerBlocks: List blocks in flight, disconnect stalling nodes - test data @@ -294,15 +294,14 @@ class fake_data: 20 7303 7307 7310 7311 7316 7322 7334 7343 7344 7350 7356 7363 7374 7377 7384 7225 21 7310 7311 7316 7322 7334 7343 7344 7350 7356 7363 7374 7377 7384 7225 7317 7386 22 7316 7322 7334 7343 7344 7350 7356 7363 7374 7377 7384 7225 7317 7386 7398 7409 - """ - } + """} def make_data(): def gen_address_data(): for line in fake_data.addresses.strip().split('\n'): data = line.split(maxsplit=2) - yield (data[0], {k:v for k,v in zip(('id','addr','subver'),data)}) + yield (data[0], {k: v for k, v in zip(('id', 'addr', 'subver'), data)}) def gen_iterations_data(): for line in fake_data.iterations.strip().split('\n'): @@ -320,8 +319,7 @@ class fake_data: 'id': int(d['id']), 'addr': d['addr'], 'subver': d['subver'], - 'inflight': [int(n)+830000 for n in blocks[iter_no]], - } + 'inflight': [int(n)+830000 for n in blocks[iter_no]]} def gen_data(): for iter_no in iterations_data: diff --git a/test/overlay/fakemods/mmgen_node_tools/Ticker.py b/test/overlay/fakemods/mmgen_node_tools/Ticker.py new file mode 100644 index 0000000..8d1ede4 --- /dev/null +++ b/test/overlay/fakemods/mmgen_node_tools/Ticker.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# +# MMGen Node Tools, terminal-based programs for Bitcoin and forkcoin nodes +# Copyright (C)2013-2025 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-node-tools +# https://gitlab.com/mmgen/mmgen-node-tools + +""" +fakemods.mmgen_node_tools.Ticker: fake module for Ticker class +""" + +from .Ticker_orig import * + +class overlay_fake_DataSource: + class coinpaprika: + api_host = 'localhost:19900' + api_proto = 'http' + +DataSource.coinpaprika.api_host = overlay_fake_DataSource.coinpaprika.api_host +DataSource.coinpaprika.api_proto = overlay_fake_DataSource.coinpaprika.api_proto diff --git a/test/ref/ticker/ticker-btc.json b/test/ref/ticker/ticker-btc.json index 75c8909..d0549b1 100644 --- a/test/ref/ticker/ticker-btc.json +++ b/test/ref/ticker/ticker-btc.json @@ -1 +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 +{"id":"btc-bitcoin","name":"Bitcoin","symbol":"BTC","rank":"1","circulating_supply":"19109225","total_supply":"19109231","max_supply":"21000000","last_updated":"2022-08-01T09:34:05Z","quotes":{"USD":{"price":23368.859557988893,"percent_change_1h":-0.23,"percent_change_6h":-1.4960000000000002,"percent_change_24h":-1.87,"percent_change_7d":6.05,"percent_change_30d":8.469999999999999,"percent_change_1y":10.285,"volume_24h":24116251608.791744,"market_cap":446560795287}}} diff --git a/test/ref/ticker/ticker-cfg-bad.yaml b/test/ref/ticker/ticker-cfg-bad.yaml new file mode 100644 index 0000000..da670c6 --- /dev/null +++ b/test/ref/ticker/ticker-cfg-bad.yaml @@ -0,0 +1,11 @@ +assets: + coin1: + - btc-bitcoin + - ltc-litecoin + - eth-ethereum + - xmr-monero + - bad-badcoin + commodity: + - gc=f + - si=f + - bz=f diff --git a/test/ref/ticker/ticker-cfg-sort-pchg.yaml b/test/ref/ticker/ticker-cfg-sort-pchg.yaml new file mode 100644 index 0000000..4660ae4 --- /dev/null +++ b/test/ref/ticker/ticker-cfg-sort-pchg.yaml @@ -0,0 +1,14 @@ +sort: d +pchg_unit: btc + +assets: + coin1: + - btc-bitcoin + - ltc-litecoin + - eth-ethereum + - xmr-monero + - bad-badcoin + commodity: + - gc=f + - si=f + - bz=f diff --git a/test/ref/ticker/ticker-cfg-usd.yaml b/test/ref/ticker/ticker-cfg-usd.yaml new file mode 100644 index 0000000..c31f85f --- /dev/null +++ b/test/ref/ticker/ticker-cfg-usd.yaml @@ -0,0 +1,11 @@ +assets: + coin1: + - btc-bitcoin + - eth-ethereum + - xmr-monero + commodity: + - gc=f + - si=f + currency: + - usd-us-dollar + - eurusd=x diff --git a/test/ref/ticker/ticker-cfg.yaml b/test/ref/ticker/ticker-cfg.yaml index 504a7fb..4b40a43 100644 --- a/test/ref/ticker/ticker-cfg.yaml +++ b/test/ref/ticker/ticker-cfg.yaml @@ -12,13 +12,13 @@ assets: - ada-cardano - algo-algorand commodity: - - xau-gold-spot-token - - xag-silver-spot-token - - xbr-brent-crude-oil-spot + - gc=f # gold futures + - si=f # silver futures + - bz=f # Brent futures fiat: - - chf-swiss-franc-token - - eur-euro-token + - chfusd=x # Swiss Franc + - eurusd=x # Euro index: - - dj30-dow-jones-30-token - - spx-sp-500 - - ndx-nasdaq-100-token + - ^dji # Dow Jones Industrials + - ^ixic # Nasdaq 100 + - ^gspc # S&P 500 diff --git a/test/ref/ticker/ticker-finance-history.json b/test/ref/ticker/ticker-finance-history.json new file mode 100644 index 0000000..e8b0bd3 --- /dev/null +++ b/test/ref/ticker/ticker-finance-history.json @@ -0,0 +1 @@ +{"('BZ=F', datetime.date(2021, 8, 2))":{"open":75.1800003052,"high":75.3300018311,"low":69.75,"close":70.6999969482,"volume":192386,"adjclose":70.6999969482},"('BZ=F', datetime.date(2022, 6, 27))":{"open":112.4599990845,"high":120.3799972534,"low":108.0100021362,"close":111.6299972534,"volume":64094,"adjclose":111.6299972534},"('BZ=F', datetime.date(2022, 7, 4))":{"open":111.6100006104,"high":114.6900024414,"low":98.4700012207,"close":107.0199966431,"volume":115623,"adjclose":107.0199966431},"('BZ=F', datetime.date(2022, 7, 11))":{"open":106.7600021362,"high":107.6600036621,"low":94.5,"close":101.1600036621,"volume":109537,"adjclose":101.1600036621},"('BZ=F', datetime.date(2022, 7, 18))":{"open":100.9199981689,"high":107.5999984741,"low":99.4800033569,"close":103.1999969482,"volume":110290,"adjclose":103.1999969482},"('BZ=F', datetime.date(2022, 7, 25))":{"open":103.4400024414,"high":110.4300003052,"low":101.6699981689,"close":110.0100021362,"volume":77572,"adjclose":110.0100021362},"('BZ=F', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":102.4400024414,"high":111.4300003052,"low":101.6699981689,"close":111.0100021362,"volume":77572,"adjclose":111.0100021362},"('CHFUSD=X', datetime.date(2021, 7, 26))":{"open":1.0939244032,"high":1.1064150333,"low":1.0908932686,"close":1.1047636271,"volume":0,"adjclose":1.1047636271},"('CHFUSD=X', datetime.date(2022, 7, 4))":{"open":1.043405652,"high":1.0446264744,"low":1.020783186,"close":1.0241703987,"volume":0,"adjclose":1.0241703987},"('CHFUSD=X', datetime.date(2022, 7, 11))":{"open":1.022777319,"high":1.0247056484,"low":1.0118077993,"close":1.0238809586,"volume":0,"adjclose":1.0238809586},"('CHFUSD=X', datetime.date(2022, 7, 18))":{"open":1.0250102282,"high":1.0417101383,"low":1.0216904879,"close":1.0396090746,"volume":0,"adjclose":1.0396090746},"('CHFUSD=X', datetime.date(2022, 7, 25))":{"open":1.0384216309,"high":1.0522992611,"low":1.0345113277,"close":1.0506275892,"volume":0,"adjclose":1.0506275892},"('CHFUSD=X', datetime.date(2022, 8, 1))":{"open":1.0492409468,"high":1.0547411442,"low":1.0495601892,"close":1.0492409468,"volume":0,"adjclose":1.0492409468},"('CHFUSD=X', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":1.0492409468,"high":1.0547411442,"low":1.0495601892,"close":1.0492409468,"volume":0,"adjclose":1.0492409468},"('EURUSD=X', datetime.date(2021, 7, 26))":{"open":1.1822398901,"high":1.1911001205,"low":1.1785781384,"close":1.1867604256,"volume":0,"adjclose":1.1867604256},"('EURUSD=X', datetime.date(2022, 7, 4))":{"open":1.043394804,"high":1.0463534594,"low":1.0079730749,"close":1.0187449455,"volume":0,"adjclose":1.0187449455},"('EURUSD=X', datetime.date(2022, 7, 11))":{"open":1.0166114569,"high":1.0167768002,"low":0.9953616261,"close":1.008867979,"volume":0,"adjclose":1.008867979},"('EURUSD=X', datetime.date(2022, 7, 18))":{"open":1.0096318722,"high":1.0275379419,"low":1.0082373619,"close":1.0215548277,"volume":0,"adjclose":1.0215548277},"('EURUSD=X', datetime.date(2022, 7, 25))":{"open":1.0200231075,"high":1.0256409645,"low":1.0107442141,"close":1.0227040052,"volume":0,"adjclose":1.0227040052},"('EURUSD=X', datetime.date(2022, 8, 1))":{"open":1.02082479,"high":1.0277491808,"low":1.020960331,"close":1.02082479,"volume":0,"adjclose":1.02082479},"('EURUSD=X', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":1.02082479,"high":1.0277491808,"low":1.020960331,"close":1.02082479,"volume":0,"adjclose":1.02082479},"('GBPUSD=X', datetime.date(2021, 7, 26))":{"open":1.3886767626,"high":1.3984057903,"low":1.3846387863,"close":1.3910005093,"volume":0,"adjclose":1.3910005093},"('GBPUSD=X', datetime.date(2022, 7, 4))":{"open":1.2105804682,"high":1.2165154219,"low":1.1877613068,"close":1.2031002045,"volume":0,"adjclose":1.2031002045},"('GBPUSD=X', datetime.date(2022, 7, 11))":{"open":1.2017786503,"high":1.2019952536,"low":1.1763184071,"close":1.1855996847,"volume":0,"adjclose":1.1855996847},"('GBPUSD=X', datetime.date(2022, 7, 18))":{"open":1.1887921095,"high":1.2064181566,"low":1.1875497103,"close":1.2005999088,"volume":0,"adjclose":1.2005999088},"('GBPUSD=X', datetime.date(2022, 7, 25))":{"open":1.1984229088,"high":1.2242598534,"low":1.1962151527,"close":1.2180001736,"volume":0,"adjclose":1.2180001736},"('GBPUSD=X', datetime.date(2022, 8, 1))":{"open":1.2167818546,"high":1.2291958332,"low":1.2164857388,"close":1.2167373896,"volume":0,"adjclose":1.2167373896},"('GBPUSD=X', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":1.2167818546,"high":1.2291958332,"low":1.2164857388,"close":1.2167373896,"volume":0,"adjclose":1.2167373896},"('GC=F', datetime.date(2021, 7, 26))":{"open":1803.6999511719,"high":1832.5999755859,"low":1799.5,"close":1812.5999755859,"volume":243380,"adjclose":1812.5999755859},"('GC=F', datetime.date(2022, 6, 27))":{"open":1830.5,"high":1830.6999511719,"low":1791.5999755859,"close":1798.9000244141,"volume":1046,"adjclose":1798.9000244141},"('GC=F', datetime.date(2022, 7, 4))":{"open":1805.4000244141,"high":1805.4000244141,"low":1732.0999755859,"close":1740.5999755859,"volume":4032,"adjclose":1740.5999755859},"('GC=F', datetime.date(2022, 7, 11))":{"open":1732.5,"high":1736.6999511719,"low":1701.0999755859,"close":1702.4000244141,"volume":2904,"adjclose":1702.4000244141},"('GC=F', datetime.date(2022, 7, 18))":{"open":1712.1999511719,"high":1735,"low":1679.8000488281,"close":1727.0999755859,"volume":2322,"adjclose":1727.0999755859},"('GC=F', datetime.date(2022, 7, 25))":{"open":1727,"high":1765.6999511719,"low":1717.6999511719,"close":1762.9000244141,"volume":185654,"adjclose":1762.9000244141},"('GC=F', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":1727,"high":1765.6999511719,"low":1717.6999511719,"close":1762.9000244141,"volume":185654,"adjclose":1762.9000244141},"('SI=F', datetime.date(2021, 7, 26))":{"open":25.2000007629,"high":25.8549995422,"low":24.6319999695,"close":25.5279998779,"volume":1000,"adjclose":25.5279998779},"('SI=F', datetime.date(2022, 6, 27))":{"open":21.2950000763,"high":21.3299999237,"low":19.2649993896,"close":19.5970001221,"volume":48988,"adjclose":19.5970001221},"('SI=F', datetime.date(2022, 7, 4))":{"open":19.8099994659,"high":20.0699996948,"low":18.8400001526,"close":19.1669998169,"volume":566,"adjclose":19.1669998169},"('SI=F', datetime.date(2022, 7, 11))":{"open":19.1650009155,"high":19.1749992371,"low":18,"close":18.5480003357,"volume":1108,"adjclose":18.5480003357},"('SI=F', datetime.date(2022, 7, 18))":{"open":18.8099994659,"high":18.9400005341,"low":18.1100006104,"close":18.5849990845,"volume":690,"adjclose":18.5849990845},"('SI=F', datetime.date(2022, 7, 25))":{"open":18.4099998474,"high":20.2900009155,"low":18.1849994659,"close":20.1560001373,"volume":980,"adjclose":20.1560001373},"('SI=F', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":18.4099998474,"high":20.2900009155,"low":18.1849994659,"close":20.1560001373,"volume":980,"adjclose":20.1560001373},"('^DJI', datetime.date(2021, 7, 26))":{"open":35078.8984375,"high":35171.51953125,"low":34871.12890625,"close":34935.46875,"volume":1172870000,"adjclose":34935.46875},"('^DJI', datetime.date(2022, 6, 27))":{"open":31533.599609375,"high":31885.08984375,"low":30431.869140625,"close":31097.259765625,"volume":1634590000,"adjclose":31097.259765625},"('^DJI', datetime.date(2022, 7, 4))":{"open":30903.119140625,"high":31511.4609375,"low":30355.119140625,"close":31338.150390625,"volume":1147620000,"adjclose":31338.150390625},"('^DJI', datetime.date(2022, 7, 11))":{"open":31277.98046875,"high":31367.55078125,"low":30143.9296875,"close":31288.259765625,"volume":1507740000,"adjclose":31288.259765625},"('^DJI', datetime.date(2022, 7, 18))":{"open":31475.98046875,"high":32219.25,"low":30982.970703125,"close":31899.2890625,"volume":1624400000,"adjclose":31899.2890625},"('^DJI', datetime.date(2022, 7, 25))":{"open":31950.9296875,"high":32910.1796875,"low":31705.359375,"close":32845.12890625,"volume":1764330000,"adjclose":32845.12890625},"('^DJI', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":31950.9296875,"high":32910.1796875,"low":31705.359375,"close":32845.12890625,"volume":1764330000,"adjclose":32845.12890625},"('^GSPC', datetime.date(2021, 7, 26))":{"open":4416.3798828125,"high":4429.9702148438,"low":4372.509765625,"close":4395.259765625,"volume":16458580000,"adjclose":4395.259765625},"('^GSPC', datetime.date(2022, 6, 27))":{"open":3920.7600097656,"high":3945.8601074219,"low":3738.669921875,"close":3825.330078125,"volume":21693690000,"adjclose":3825.330078125},"('^GSPC', datetime.date(2022, 7, 4))":{"open":3792.6101074219,"high":3918.5,"low":3742.0600585938,"close":3899.3798828125,"volume":17073700000,"adjclose":3899.3798828125},"('^GSPC', datetime.date(2022, 7, 11))":{"open":3880.9399414062,"high":3880.9399414062,"low":3721.5600585938,"close":3863.1599121094,"volume":19693570000,"adjclose":3863.1599121094},"('^GSPC', datetime.date(2022, 7, 18))":{"open":3883.7900390625,"high":4012.4399414062,"low":3818.6298828125,"close":3961.6298828125,"volume":20385270000,"adjclose":3961.6298828125},"('^GSPC', datetime.date(2022, 7, 25))":{"open":3965.7199707031,"high":4140.1499023438,"low":3910.7399902344,"close":4130.2900390625,"volume":20488830000,"adjclose":4130.2900390625},"('^GSPC', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":3965.7199707031,"high":4140.1499023438,"low":3910.7399902344,"close":4130.2900390625,"volume":20488830000,"adjclose":4130.2900390625},"('^IXIC', datetime.date(2021, 7, 26))":{"open":14807.9501953125,"high":14833.740234375,"low":14503.759765625,"close":14672.6796875,"volume":16171130000,"adjclose":14672.6796875},"('^IXIC', datetime.date(2022, 6, 27))":{"open":11661.01953125,"high":11677.490234375,"low":10850.009765625,"close":11127.849609375,"volume":26652190000,"adjclose":11127.849609375},"('^IXIC', datetime.date(2022, 7, 4))":{"open":10964.1796875,"high":11689.7001953125,"low":10911.4501953125,"close":11635.3095703125,"volume":19116360000,"adjclose":11635.3095703125},"('^IXIC', datetime.date(2022, 7, 11))":{"open":11524.490234375,"high":11541.099609375,"low":11005.9296875,"close":11452.419921875,"volume":21963960000,"adjclose":11452.419921875},"('^IXIC', datetime.date(2022, 7, 18))":{"open":11561.6396484375,"high":12093.01953125,"low":11322.83984375,"close":11834.1103515625,"volume":25230550000,"adjclose":11834.1103515625},"('^IXIC', datetime.date(2022, 7, 25))":{"open":11837.9599609375,"high":12426.259765625,"low":11533.3701171875,"close":12390.6904296875,"volume":23117120000,"adjclose":12390.6904296875},"('^IXIC', datetime.datetime(2022, 8, 2, 7, 7, 7, tzinfo=))":{"open":11837.9599609375,"high":12426.259765625,"low":11533.3701171875,"close":12390.6904296875,"volume":23117120000,"adjclose":12390.6904296875}} diff --git a/test/ref/ticker/ticker-finance.json b/test/ref/ticker/ticker-finance.json new file mode 100644 index 0000000..ac0af29 --- /dev/null +++ b/test/ref/ticker/ticker-finance.json @@ -0,0 +1 @@ +{"GC=F":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.0015419408,"fmt":"-0.15%"},"regularMarketChange":{"raw":-3,"fmt":"-3.00"},"regularMarketTime":1659385900,"priceHint":{"raw":2,"fmt":"2","longFmt":"2"},"regularMarketPrice":{"raw":1942.6,"fmt":"1,942.60"},"regularMarketDayHigh":{"raw":1946.8,"fmt":"1,946.80"},"regularMarketDayLow":{"raw":1940.1,"fmt":"1,940.10"},"regularMarketVolume":{"raw":39539,"fmt":"39.54k","longFmt":"39,539.00"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":1945.6,"fmt":"1,945.60"},"regularMarketSource":"DELAYED","regularMarketOpen":{"raw":1944.7,"fmt":"1,944.70"},"strikePrice":{},"openInterest":{},"exchange":"CMX","exchangeName":"COMEX","exchangeDataDelayedBy":10,"marketState":"REGULAR","quoteType":"FUTURE","symbol":"GC=F","underlyingSymbol":null,"shortName":"Gold Dec 23","longName":null,"currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"SI=F":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.0014259518,"fmt":"-0.14%"},"regularMarketChange":{"raw":-0.034000397,"fmt":"-0.03"},"regularMarketTime":1659385900,"priceHint":{"raw":2,"fmt":"2","longFmt":"2"},"regularMarketPrice":{"raw":23.81,"fmt":"23.81"},"regularMarketDayHigh":{"raw":23.925,"fmt":"23.92"},"regularMarketDayLow":{"raw":23.67,"fmt":"23.67"},"regularMarketVolume":{"raw":11819,"fmt":"11.82k","longFmt":"11,819.00"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":23.844,"fmt":"23.84"},"regularMarketSource":"DELAYED","regularMarketOpen":{"raw":23.81,"fmt":"23.81"},"strikePrice":{},"openInterest":{},"exchange":"CMX","exchangeName":"COMEX","exchangeDataDelayedBy":10,"marketState":"REGULAR","quoteType":"FUTURE","symbol":"SI=F","underlyingSymbol":null,"shortName":"Silver Dec 23","longName":null,"currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"BZ=F":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":0.004610275,"fmt":"0.46%"},"regularMarketChange":{"raw":0.4300003,"fmt":"0.43"},"regularMarketTime":1659385900,"priceHint":{"raw":2,"fmt":"2","longFmt":"2"},"regularMarketPrice":{"raw":93.7,"fmt":"93.70"},"regularMarketDayHigh":{"raw":94.25,"fmt":"94.25"},"regularMarketDayLow":{"raw":93.24,"fmt":"93.24"},"regularMarketVolume":{"raw":2853,"fmt":"2.85k","longFmt":"2,853.00"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":93.27,"fmt":"93.27"},"regularMarketSource":"DELAYED","regularMarketOpen":{"raw":93.75,"fmt":"93.75"},"strikePrice":{},"openInterest":{},"exchange":"NYM","exchangeName":"NY Mercantile","exchangeDataDelayedBy":10,"marketState":"REGULAR","quoteType":"FUTURE","symbol":"BZ=F","underlyingSymbol":null,"shortName":"Brent Crude Oil Last Day Financ","longName":null,"currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"GBPUSD=X":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.00045271934,"fmt":"-0.0453%"},"regularMarketChange":{"raw":-0.0005540848,"fmt":"-0.0006"},"regularMarketTime":1659385900,"priceHint":{"raw":4,"fmt":"4","longFmt":"4"},"regularMarketPrice":{"raw":1.223481,"fmt":"1.2235"},"regularMarketDayHigh":{"raw":1.2263919,"fmt":"1.2264"},"regularMarketDayLow":{"raw":1.2213293,"fmt":"1.2213"},"regularMarketVolume":{"raw":0,"fmt":null,"longFmt":"0.0000"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":1.2240351,"fmt":"1.2240"},"regularMarketSource":"DELAYED","regularMarketOpen":{"raw":1.2240951,"fmt":"1.2241"},"strikePrice":{},"openInterest":{},"exchange":"CCY","exchangeName":"CCY","exchangeDataDelayedBy":0,"marketState":"REGULAR","quoteType":"CURRENCY","symbol":"GBPUSD=X","underlyingSymbol":null,"shortName":"GBP/USD","longName":"GBP/USD","currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"EURUSD=X":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.0005321096,"fmt":"-0.0532%"},"regularMarketChange":{"raw":-0.00056660175,"fmt":"-0.0006"},"regularMarketTime":1659385900,"priceHint":{"raw":4,"fmt":"4","longFmt":"4"},"regularMarketPrice":{"raw":1.0641694,"fmt":"1.0642"},"regularMarketDayHigh":{"raw":1.0658708,"fmt":"1.0659"},"regularMarketDayLow":{"raw":1.0626993,"fmt":"1.0627"},"regularMarketVolume":{"raw":0,"fmt":null,"longFmt":"0.0000"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":1.064736,"fmt":"1.0647"},"regularMarketSource":"DELAYED","regularMarketOpen":{"raw":1.064736,"fmt":"1.0647"},"strikePrice":{},"openInterest":{},"exchange":"CCY","exchangeName":"CCY","exchangeDataDelayedBy":0,"marketState":"REGULAR","quoteType":"CURRENCY","symbol":"EURUSD=X","underlyingSymbol":null,"shortName":"EUR/USD","longName":"EUR/USD","currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"CHFUSD=X":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.0028495365,"fmt":"-0.2850%"},"regularMarketChange":{"raw":-0.0031440258,"fmt":"-0.0031"},"regularMarketTime":1659385900,"priceHint":{"raw":4,"fmt":"4","longFmt":"4"},"regularMarketPrice":{"raw":1.1002069,"fmt":"1.1002"},"regularMarketDayHigh":{"raw":1.1046062,"fmt":"1.1046"},"regularMarketDayLow":{"raw":1.0982735,"fmt":"1.0983"},"regularMarketVolume":{"raw":0,"fmt":null,"longFmt":"0.0000"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":1.1033509,"fmt":"1.1034"},"regularMarketSource":"DELAYED","regularMarketOpen":{"raw":1.1043622,"fmt":"1.1044"},"strikePrice":{},"openInterest":{},"exchange":"CCY","exchangeName":"CCY","exchangeDataDelayedBy":0,"marketState":"REGULAR","quoteType":"CURRENCY","symbol":"CHFUSD=X","underlyingSymbol":null,"shortName":"CHF/USD","longName":"CHF/USD","currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"^DJI":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.0031276005,"fmt":"-0.31%"},"regularMarketChange":{"raw":-106.55859,"fmt":"-106.56"},"regularMarketTime":1659385900,"priceHint":{"raw":2,"fmt":"2","longFmt":"2"},"regularMarketPrice":{"raw":33963.84,"fmt":"33,963.84"},"regularMarketDayHigh":{"raw":34156.15,"fmt":"34,156.15"},"regularMarketDayLow":{"raw":33947.24,"fmt":"33,947.24"},"regularMarketVolume":{"raw":271273859,"fmt":"271.27M","longFmt":"271,273,859.00"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":34070.4,"fmt":"34,070.40"},"regularMarketSource":"FREE_REALTIME","regularMarketOpen":{"raw":34077.08,"fmt":"34,077.08"},"strikePrice":{},"openInterest":{},"exchange":"DJI","exchangeName":"DJI","exchangeDataDelayedBy":0,"marketState":"PRE","quoteType":"INDEX","symbol":"^DJI","underlyingSymbol":null,"shortName":"Dow Jones Industrial Average","longName":"Dow Jones Industrial Average","currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"^IXIC":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.00092206284,"fmt":"-0.09%"},"regularMarketChange":{"raw":-12.193359,"fmt":"-12.19"},"regularMarketTime":1659385900,"priceHint":{"raw":2,"fmt":"2","longFmt":"2"},"regularMarketPrice":{"raw":13211.807,"fmt":"13,211.81"},"regularMarketDayHigh":{"raw":13353.22,"fmt":"13,353.22"},"regularMarketDayLow":{"raw":13200.639,"fmt":"13,200.64"},"regularMarketVolume":{"raw":3887623000,"fmt":"3.89B","longFmt":"3,887,623,000.00"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":13224,"fmt":"13,224.00"},"regularMarketSource":"FREE_REALTIME","regularMarketOpen":{"raw":13287.171,"fmt":"13,287.17"},"strikePrice":{},"openInterest":{},"exchange":"NIM","exchangeName":"Nasdaq GIDS","exchangeDataDelayedBy":0,"marketState":"PRE","quoteType":"INDEX","symbol":"^IXIC","underlyingSymbol":null,"shortName":"NASDAQ Composite","longName":"NASDAQ Composite","currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}},"^GSPC":{"maxAge":1,"preMarketChange":{},"preMarketPrice":{},"postMarketChange":{},"postMarketPrice":{},"regularMarketChangePercent":{"raw":-0.0022955984,"fmt":"-0.23%"},"regularMarketChange":{"raw":-9.939941,"fmt":"-9.94"},"regularMarketTime":1659385900,"priceHint":{"raw":2,"fmt":"2","longFmt":"2"},"regularMarketPrice":{"raw":4320.06,"fmt":"4,320.06"},"regularMarketDayHigh":{"raw":4357.4,"fmt":"4,357.40"},"regularMarketDayLow":{"raw":4316.49,"fmt":"4,316.49"},"regularMarketVolume":{"raw":2135953000,"fmt":"2.14B","longFmt":"2,135,953,000.00"},"averageDailyVolume10Day":{},"averageDailyVolume3Month":{},"regularMarketPreviousClose":{"raw":4330,"fmt":"4,330.00"},"regularMarketSource":"FREE_REALTIME","regularMarketOpen":{"raw":4341.74,"fmt":"4,341.74"},"strikePrice":{},"openInterest":{},"exchange":"SNP","exchangeName":"SNP","exchangeDataDelayedBy":0,"marketState":"PRE","quoteType":"INDEX","symbol":"^GSPC","underlyingSymbol":null,"shortName":"S&P 500","longName":"S&P 500","currency":"USD","quoteSourceName":"Delayed Quote","currencySymbol":"$","fromCurrency":null,"toCurrency":null,"lastMarket":null,"volume24Hr":{},"volumeAllCurrencies":{},"circulatingSupply":{},"marketCap":{}}} diff --git a/test/ref/ticker/ticker-portfolio.yaml b/test/ref/ticker/ticker-portfolio.yaml index 332257f..ca0c2b2 100644 --- a/test/ref/ticker/ticker-portfolio.yaml +++ b/test/ref/ticker/ticker-portfolio.yaml @@ -1,5 +1,10 @@ btc-bitcoin: '1.23456789' eth-ethereum: '2.345678901234567890' -xmr-monero: '4.567890123456' +xmr-monero: '3.333390123456' ada-cardano: '123.45678901' -algo-algorand: '234.5678901' + +wallet2: + algo-algorand: '234.5678901' + +exchange1: + xmr-monero: '1.2345' diff --git a/test/ref/ticker/ticker.json b/test/ref/ticker/ticker.json index 2484e6d..4535b12 100644 --- a/test/ref/ticker/ticker.json +++ b/test/ref/ticker/ticker.json @@ -1 +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"}] +[{"id":"btc-bitcoin","name":"Bitcoin","symbol":"BTC","rank":"1","circulating_supply":"19110612","total_supply":"19110619","max_supply":"21000000","last_updated":"2022-08-02T18:25:59Z","quotes":{"USD":{"price":23250.774053363122,"percent_change_1h":-0.27,"percent_change_6h":0.7120000000000001,"percent_change_24h":0.89,"percent_change_7d":11.15,"percent_change_30d":15.61,"percent_change_1y":18.955000000000002,"volume_24h":28307579008.4866,"market_cap":444336521633}}},{"id":"eth-ethereum","name":"Ethereum","symbol":"ETH","rank":"2","circulating_supply":"121802674","total_supply":"121802721","max_supply":"","last_updated":"2022-08-02T18:25:59Z","quotes":{"USD":{"price":1659.6621665887371,"percent_change_1h":-0.03,"percent_change_6h":1.4560000000000002,"percent_change_24h":1.82,"percent_change_7d":21.42,"percent_change_30d":29.988,"percent_change_1y":36.414,"volume_24h":18216561308.363518,"market_cap":202151289827}}},{"id":"ltc-litecoin","name":"Litecoin","symbol":"LTC","rank":"23","circulating_supply":"70856606","total_supply":"70856631","max_supply":"84000000","last_updated":"2022-08-02T18:25:59Z","quotes":{"USD":{"price":59.013589192027936,"percent_change_1h":-0.43,"percent_change_6h":0.32000000000000006,"percent_change_24h":0.4,"percent_change_7d":12.79,"percent_change_30d":17.906,"percent_change_1y":21.743,"volume_24h":510336800.72056556,"market_cap":4181502638}}},{"id":"xmr-monero","name":"Monero","symbol":"XMR","rank":"30","circulating_supply":"18155770","total_supply":"18155768","max_supply":"","last_updated":"2022-08-02T18:25:59Z","quotes":{"USD":{"price":158.97302629813817,"percent_change_1h":0.21,"percent_change_6h":0.968,"percent_change_24h":1.21,"percent_change_7d":7.28,"percent_change_30d":10.192,"percent_change_1y":12.376,"volume_24h":78159392.30003875,"market_cap":2886277702}}},{"id":"ada-cardano","name":"Cardano","symbol":"ADA","rank":"8","circulating_supply":"33752565071","total_supply":"34277702082","max_supply":"45000000000","last_updated":"2022-08-02T18:25:59Z","quotes":{"USD":{"price":0.5069722536476557,"percent_change_1h":0.05,"percent_change_6h":-0.08800000000000001,"percent_change_24h":-0.11,"percent_change_7d":11.68,"percent_change_30d":16.352,"percent_change_1y":19.855999999999998,"volume_24h":507973898.04662097,"market_cap":17111613980}}},{"id":"algo-algorand","name":"Algorand","symbol":"ALGO","rank":"33","circulating_supply":"6949173585","total_supply":"7350412337","max_supply":"","last_updated":"2022-08-02T18:25:59Z","quotes":{"USD":{"price":0.33196893931152616,"percent_change_1h":-0.06,"percent_change_6h":-0.656,"percent_change_24h":-0.82,"percent_change_7d":9.69,"percent_change_30d":13.565999999999999,"percent_change_1y":16.473,"volume_24h":62207779.35759487,"market_cap":2306909784}}}] diff --git a/test/test-release.d/cfg.sh b/test/test-release.d/cfg.sh index cfe98b7..a34c324 100755 --- a/test/test-release.d/cfg.sh +++ b/test/test-release.d/cfg.sh @@ -18,7 +18,7 @@ # mmnode-ticker OK # mmnode-txfind - -all_tests='unit misc scripts btc btc_rt bch_rt ltc_rt' +all_tests='mod lint misc scripts btc btc_rt bch_rt ltc_rt' groups_desc=" default - All tests minus the extra tests @@ -29,32 +29,40 @@ groups_desc=" " init_groups() { - dfl_tests=$all_tests - extra_tests='' - noalt_tests='unit misc scripts btc btc_rt' - quick_tests='unit misc scripts btc btc_rt' - qskip_tests='bch_rt ltc_rt' + dfl_tests='mod misc scripts btc btc_rt bch_rt ltc_rt' + extra_tests='lint' + noalt_tests='mod misc scripts btc btc_rt' + quick_tests='mod misc scripts btc btc_rt' + qskip_tests='lint bch_rt ltc_rt' } init_tests() { - d_unit="low-level subsystems" - t_unit="- $unit_tests_py" + + d_lint="code errors with static code analyzer" + t_lint=" + - $pylint --errors-only mmgen_node_tools + - $pylint --errors-only test + - $pylint --errors-only --disable=relative-beyond-top-level test/cmdtest_d + " + + d_mod="low-level subsystems" + t_mod="- $modtest_py" d_misc="miscellaneous features" - t_misc="- $test_py helpscreens" + t_misc="- $cmdtest_py helpscreens" d_scripts="scripts not requiring a coin daemon" - t_scripts="- $test_py scripts" + t_scripts="- $cmdtest_py scripts" d_btc="Bitcoin with emulated RPC data" - t_btc="- $test_py main" + t_btc="- $cmdtest_py main" d_btc_rt="Bitcoin regtest" - t_btc_rt="- $test_py regtest" + t_btc_rt="- $cmdtest_py regtest" d_bch_rt="Bitcoin Cash Node (BCH) regtest" - t_bch_rt="- $test_py --coin=bch regtest" + t_bch_rt="- $cmdtest_py --coin=bch regtest" d_ltc_rt="Litecoin regtest" - t_ltc_rt="- $test_py --coin=ltc regtest" + t_ltc_rt="- $cmdtest_py --coin=ltc regtest" } diff --git a/test/test_py_d/cfg.py b/test/test_py_d/cfg.py deleted file mode 100755 index c2c72e3..0000000 --- a/test/test_py_d/cfg.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/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-node-tools -# https://gitlab.com/mmgen/mmgen-node-tools - -""" -test.test_py_d.cfg: configuration data for test.py -""" - -import os - -cmd_groups_dfl = { - 'main': ('TestSuiteMain',{}), - 'helpscreens': ('TestSuiteHelp',{'modname':'misc','full_data':True}), - 'scripts': ('TestSuiteScripts',{'modname':'misc'}), - 'regtest': ('TestSuiteRegtest',{}), -} - -cmd_groups_extra = {} - -cfgs = { - '1': {}, # regtest - '2': {}, # scripts - '3': {}, # main -} - -def fixup_cfgs(): pass diff --git a/test/test_py_d/ts_misc.py b/test/test_py_d/ts_misc.py deleted file mode 100755 index c4536d6..0000000 --- a/test/test_py_d/ts_misc.py +++ /dev/null @@ -1,308 +0,0 @@ -#!/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-node-tools -# https://gitlab.com/mmgen/mmgen-node-tools - -""" -test.test_py_d.ts_misc: Miscellaneous test groups for the test.py test suite -""" - -import shutil -from ..include.common import * -from .common import * -from .ts_base import * - -refdir = os.path.join('test','ref','ticker') - -class TestSuiteHelp(TestSuiteBase): - 'help, info and usage screens' - networks = ('btc','ltc','bch') - tmpdir_nums = [] - passthru_opts = ('daemon_data_dir','rpc_port','coin','testnet') - cmd_group = ( - ('version', (1,'version message',[])), - ('helpscreens', (1,'help screens', [])), - ('longhelpscreens', (1,'help screens (--longhelp)',[])), - ) - color = True - - def version(self): - t = self.spawn(f'mmnode-netrate',['--version']) - t.expect('MMNODE-NETRATE version') - return t - - def helpscreens(self,arg='--help',scripts=(),expect='USAGE:.*OPTIONS:'): - - scripts = list(scripts) or [s for s in os.listdir('cmds') if s.startswith('mmnode-')] - - for s in sorted(scripts): - t = self.spawn(s,[arg],extra_desc=f'({s})') - t.expect(expect,regex=True) - t.read() - t.ok() - t.skip_ok = True - - return t - - def longhelpscreens(self): - return self.helpscreens(arg='--longhelp',expect='USAGE:.*LONG OPTIONS:') - -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]'), - ('ticker16', 'ticker [--cached-data --wide --elapsed -c eur,omr-omani-rial:2.59r'), - ('ticker17', 'ticker [--cached-data --wide --elapsed -c bgn-bulgarian-lev:0.5113r:eur'), - ('ticker18', 'ticker [--cached-data --wide --elapsed -c eur,bgn-bulgarian-lev:0.5113r:eur-euro-token'), - ) - } - - @property - def ticker_args(self): - return [ f'--cachedir={self.tmpdir}', '--proxy=http://asdfzxcv:32459' ] - - @property - def nt_datadir(self): - return os.path.join( cfg.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 cfg.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 1 day 9 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 1 day 9 hours 2 minutes ago ' + - 'US DOLLAR 46,737.71911598 45,000.00000000 1 day 9 hours 2 minutes ago', - ]) - - def ticker16(self): - return self.ticker( - ['--wide','--elapsed','-c','eur,omr-omani-rial:2.59r'], - [ - r'EUR \(EURO TOKEN\) = 1.0186 USD ' + - r'OMR \(OMANI RIAL\) = 2.5900 USD', - 'USD EUR OMR BTC CHG_7d CHG_24h UPDATED', - r'BITCOIN 23,250.77 22,826.6890 8,977.1328 1.00000000 \+11.15 \+0.89 10 minutes ago', - 'OMANI RIAL 2.59 2.5428 1.0000 0.00011139 -- -- just now' - ]) - - def ticker17(self): - # BGN pegged at 0.5113 EUR - return self.ticker( - ['--wide','--elapsed','-c','bgn-bulgarian-lev:0.5113r:eur'], - [ - r'BGN \(BULGARIAN LEV\) = 0.52080 USD', - 'USD BGN BTC CHG_7d CHG_24h UPDATED', - 'BITCOIN 23,250.77 44,644.414 1.00000000', - 'BULGARIAN LEV 0.52 1.000 0.00002240', - ]) - - def ticker18(self): - return self.ticker( - ['--wide','--elapsed','-c','eur,bgn-bulgarian-lev:0.5113r:eur-euro-token'], - [ - r'BGN \(BULGARIAN LEV\) = 0.52080 USD', - 'USD EUR BGN BTC CHG_7d CHG_24h UPDATED', - 'BITCOIN 23,250.77 22,826.6890 44,644.414 1.00000000', - 'BULGARIAN LEV 0.52 0.5113 1.000 0.00002240', - ])