Ticker.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, a command-line cryptocurrency wallet
  4. # Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
  5. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen https://github.com/mmgen/mmgen-node-tools
  9. # https://gitlab.com/mmgen/mmgen https://gitlab.com/mmgen/mmgen-node-tools
  10. """
  11. mmgen_node_tools.Ticker: Display price information for cryptocurrency and other assets
  12. """
  13. # We use deprecated coinpaprika ‘ticker’ API for now because it returns ~45% less data.
  14. # Old ‘ticker’ API (/v1/ticker): data['BTC']['price_usd']
  15. # New ‘tickers’ API (/v1/tickers): data['BTC']['quotes']['USD']['price']
  16. # Possible alternatives:
  17. # - https://min-api.cryptocompare.com/data/pricemultifull?fsyms=BTC,LTC&tsyms=USD,EUR
  18. import sys,os,re,time,json,yaml,random
  19. from subprocess import run,PIPE,CalledProcessError
  20. from decimal import Decimal
  21. from collections import namedtuple
  22. from mmgen.color import *
  23. from mmgen.util import msg,msg_r,Msg,die,Die,suf,fmt,fmt_list,fmt_dict,list_gen
  24. homedir = os.getenv('HOME')
  25. dfl_cachedir = os.path.join(homedir,'.cache','mmgen-node-tools')
  26. cfg_fn = 'ticker-cfg.yaml'
  27. portfolio_fn = 'ticker-portfolio.yaml'
  28. asset_tuple = namedtuple('asset_tuple',['symbol','id','source'])
  29. def fetch_delay(fetched_data=[]):
  30. if not gcfg.testing:
  31. if fetched_data:
  32. delay = 1 + random.randrange(1,5000) / 1000
  33. msg(f'Waiting {delay:.3f} seconds...')
  34. time.sleep(delay)
  35. else:
  36. fetched_data.append(None)
  37. class DataSource:
  38. sources = {
  39. 'cc': 'coinpaprika',
  40. }
  41. class base:
  42. def get_data_from_network(self):
  43. curl_cmd = list_gen(
  44. ['curl', '--tr-encoding', '--header', 'Accept: application/json',True],
  45. ['--compressed'], # adds 'Accept-Encoding: gzip'
  46. ['--proxy', cfg.proxy, isinstance(cfg.proxy,str)],
  47. ['--silent', not gcfg.verbose],
  48. [self.api_url]
  49. )
  50. if gcfg.testing:
  51. Msg(fmt_list(curl_cmd,fmt='bare'))
  52. return
  53. try:
  54. return run(curl_cmd,check=True,stdout=PIPE).stdout.decode()
  55. except CalledProcessError as e:
  56. msg('')
  57. from .Misc import curl_exit_codes
  58. msg(red(curl_exit_codes[e.returncode]))
  59. msg(red('Command line:\n {}'.format( ' '.join((repr(i) if ' ' in i else i) for i in e.cmd) )))
  60. from mmgen.exception import MMGenCalledProcessError
  61. raise MMGenCalledProcessError(f'Subprocess returned non-zero exit status {e.returncode}')
  62. def get_data(self):
  63. if not os.path.exists(cfg.cachedir):
  64. os.makedirs(cfg.cachedir)
  65. if not os.path.exists(self.json_fn):
  66. open(self.json_fn,'w').write('{}')
  67. if gcfg.cached_data:
  68. data_type = 'json'
  69. data_in = open(self.json_fn).read()
  70. else:
  71. data_type = self.net_data_type
  72. elapsed = int(time.time() - os.stat(self.json_fn).st_mtime)
  73. if elapsed >= self.timeout:
  74. if gcfg.testing:
  75. msg('')
  76. fetch_delay()
  77. msg_r(f'Fetching data from {self.api_host}...')
  78. if self.has_verbose:
  79. gcfg._util.vmsg('')
  80. data_in = self.get_data_from_network()
  81. msg('done')
  82. if gcfg.testing:
  83. return {}
  84. else:
  85. die(1,self.rate_limit_errmsg(elapsed))
  86. if data_type == 'json':
  87. try:
  88. data = json.loads(data_in)
  89. except:
  90. self.json_data_error_msg(data_in)
  91. die(2,'Retrieved data is not valid JSON, exiting')
  92. json_text = data_in
  93. elif data_type == 'python':
  94. data = data_in
  95. json_text = json.dumps(data_in)
  96. if not data:
  97. if gcfg.cached_data:
  98. die(1,'No cached data! Run command without --cached-data option to retrieve data from remote host')
  99. else:
  100. die(2,'Remote host returned no data!')
  101. elif 'error' in data:
  102. die(1,data['error'])
  103. if gcfg.cached_data:
  104. msg(f'Using cached data from ~/{self.json_fn_rel}')
  105. else:
  106. open(self.json_fn,'w').write(json_text)
  107. msg(f'JSON data cached to ~/{self.json_fn_rel}')
  108. if gcfg.download:
  109. sys.exit(0)
  110. return self.postprocess_data(data)
  111. def json_data_error_msg(self,json_text):
  112. pass
  113. def postprocess_data(self,data):
  114. return data
  115. @property
  116. def json_fn_rel(self):
  117. return os.path.relpath(self.json_fn,start=homedir)
  118. class coinpaprika(base):
  119. desc = 'CoinPaprika'
  120. api_host = 'api.coinpaprika.com'
  121. ratelimit = 240
  122. btc_ratelimit = 10
  123. net_data_type = 'json'
  124. has_verbose = True
  125. def rate_limit_errmsg(self,elapsed):
  126. return (
  127. f'Rate limit exceeded! Retry in {self.timeout-elapsed} seconds' +
  128. ('' if cfg.btc_only else ', or use --cached-data or --btc')
  129. )
  130. @property
  131. def api_url(self):
  132. return f'https://{self.api_host}/v1/ticker' + ('/btc-bitcoin' if cfg.btc_only else '')
  133. @property
  134. def json_fn(self):
  135. return os.path.join(
  136. cfg.cachedir,
  137. 'ticker-btc.json' if cfg.btc_only else 'ticker.json' )
  138. @property
  139. def timeout(self):
  140. return 5 if gcfg.test_suite else self.btc_ratelimit if cfg.btc_only else self.ratelimit
  141. def json_data_error_msg(self,json_text):
  142. tor_captcha_msg = f"""
  143. If you’re using Tor, the API request may have failed due to Captcha protection.
  144. A workaround for this issue is to retrieve the JSON data with a browser from
  145. the following URL:
  146. {self.api_url}
  147. and save it to:
  148. ‘{cfg.cachedir}/ticker.json’
  149. Then invoke the program with --cached-data and without --btc
  150. """
  151. msg(json_text[:1024] + '...')
  152. msg(orange(fmt(tor_captcha_msg,strip_char='\t')))
  153. def postprocess_data(self,data):
  154. return [data] if cfg.btc_only else data
  155. @staticmethod
  156. def parse_asset_id(s,require_label):
  157. sym,label = (*s.split('-',1),None)[:2]
  158. if require_label and not label:
  159. die(1,f'{s!r}: asset label is missing')
  160. return asset_tuple(
  161. symbol = sym.upper(),
  162. id = (s.lower() if label else None),
  163. source = 'cc' )
  164. def assets_list_gen(cfg_in):
  165. for k,v in cfg_in.cfg['assets'].items():
  166. yield('')
  167. yield(k.upper())
  168. for e in v:
  169. out = e.split('-',1)
  170. yield(' {:5s} {}'.format(out[0],out[1] if len(out) == 2 else ''))
  171. def gen_data(data):
  172. """
  173. Filter the raw data and return it as a dict keyed by the IDs of the assets
  174. we want to display.
  175. Add dummy entry for USD and entry for user-specified asset, if any.
  176. Since symbols in source data are not guaranteed to be unique (e.g. XAG), we
  177. must search the data twice: first for unique IDs, then for symbols while
  178. checking for duplicates.
  179. """
  180. def dup_sym_errmsg(dup_sym):
  181. return (
  182. f'The symbol {dup_sym!r} is shared by the following assets:\n' +
  183. '\n ' + '\n '.join(d['id'] for d in data['cc'] if d['symbol'] == dup_sym) +
  184. '\n\nPlease specify the asset by one of the full IDs listed above\n' +
  185. f'instead of {dup_sym!r}'
  186. )
  187. def check_assets_found(wants,found,keys=['symbol','id']):
  188. error = False
  189. for k in keys:
  190. missing = wants[k] - found[k]
  191. if missing:
  192. msg(
  193. ('The following IDs were not found in source data:\n{}' if k == 'id' else
  194. 'The following symbols could not be resolved:\n{}').format(
  195. fmt_list(missing,fmt='col',indent=' ')
  196. ))
  197. error = True
  198. if error:
  199. die(1,'Missing data, exiting')
  200. rows_want = {
  201. 'id': {r.id for r in cfg.rows if isinstance(r,tuple) and r.id} - {'usd-us-dollar'},
  202. 'symbol': {r.symbol for r in cfg.rows if isinstance(r,tuple) and r.id is None} - {'USD'},
  203. }
  204. usr_rate_assets = tuple(u.rate_asset for u in cfg.usr_rows + cfg.usr_columns if u.rate_asset)
  205. usr_rate_assets_want = {
  206. 'id': {a.id for a in usr_rate_assets if a.id},
  207. 'symbol': {a.symbol for a in usr_rate_assets if not a.id}
  208. }
  209. usr_assets = cfg.usr_rows + cfg.usr_columns + tuple(c for c in (cfg.query or ()) if c)
  210. usr_wants = {
  211. 'id': (
  212. {a.id for a in usr_assets + usr_rate_assets if a.id} -
  213. {a.id for a in usr_assets if a.rate and a.id} - {'usd-us-dollar'} )
  214. ,
  215. 'symbol': (
  216. {a.symbol for a in usr_assets + usr_rate_assets if not a.id} -
  217. {a.symbol for a in usr_assets if a.rate} - {'USD'} ),
  218. }
  219. found = { 'id': set(), 'symbol': set() }
  220. rate_assets = {}
  221. wants = {k:rows_want[k] | usr_wants[k] for k in ('id','symbol')}
  222. for d in data['cc']:
  223. if d['id'] == 'btc-bitcoin':
  224. btcusd = Decimal(d['price_usd'])
  225. break
  226. for k in ('id','symbol'):
  227. for d in data['cc']:
  228. if wants[k]:
  229. if d[k] in wants[k]:
  230. if d[k] in found[k]:
  231. die(1,dup_sym_errmsg(d[k]))
  232. yield (d['id'],d)
  233. found[k].add(d[k])
  234. wants[k].remove(d[k])
  235. if d[k] in usr_rate_assets_want[k]:
  236. rate_assets[d['symbol']] = d # NB: using symbol instead of ID for key
  237. else:
  238. break
  239. check_assets_found(usr_wants,found)
  240. for asset in (cfg.usr_rows + cfg.usr_columns):
  241. if asset.rate:
  242. """
  243. User-supplied rate overrides rate from source data.
  244. """
  245. _id = asset.id or f'{asset.symbol}-user-asset-{asset.symbol}'.lower()
  246. ra_rate = Decimal(rate_assets[asset.rate_asset.symbol]['price_usd']) if asset.rate_asset else 1
  247. yield ( _id, {
  248. 'symbol': asset.symbol,
  249. 'id': _id,
  250. 'name': ' '.join(_id.split('-')[1:]),
  251. 'price_usd': str(Decimal(ra_rate/asset.rate)),
  252. 'price_btc': str(Decimal(ra_rate/asset.rate/btcusd)),
  253. 'last_updated': None,
  254. })
  255. yield ('usd-us-dollar', {
  256. 'symbol': 'USD',
  257. 'id': 'usd-us-dollar',
  258. 'name': 'US Dollar',
  259. 'price_usd': '1.0',
  260. 'price_btc': str(Decimal(1/btcusd)),
  261. 'last_updated': None,
  262. })
  263. def main():
  264. def update_sample_file(usr_cfg_file):
  265. usr_data = files('mmgen_node_tools').joinpath('data',os.path.basename(usr_cfg_file)).read_text()
  266. sample_file = usr_cfg_file + '.sample'
  267. sample_data = open(sample_file).read() if os.path.exists(sample_file) else None
  268. if usr_data != sample_data:
  269. os.makedirs(os.path.dirname(sample_file),exist_ok=True)
  270. msg('{} {}'.format(
  271. ('Updating','Creating')[sample_data is None],
  272. sample_file ))
  273. open(sample_file,'w').write(usr_data)
  274. try:
  275. from importlib.resources import files # Python 3.9
  276. except ImportError:
  277. from importlib_resources import files
  278. update_sample_file(cfg_in.cfg_file)
  279. update_sample_file(cfg_in.portfolio_file)
  280. if gcfg.portfolio and not cfg_in.portfolio:
  281. die(1,'No portfolio configured!\nTo configure a portfolio, edit the file ~/{}'.format(
  282. os.path.relpath(cfg_in.portfolio_file,start=homedir)))
  283. if gcfg.list_ids:
  284. src_ids = ['cc']
  285. elif gcfg.download:
  286. assert gcfg.download in DataSource.sources, f'{gcfg.download!r}: invalid data source'
  287. src_ids = [gcfg.download]
  288. else:
  289. src_ids = DataSource.sources
  290. ids = random.sample( list(src_ids), k=len(src_ids) ) # shuffle the ids
  291. src_data = { k: src_cls[k]().get_data() for k in ids }
  292. if gcfg.testing:
  293. return
  294. if gcfg.list_ids:
  295. from mmgen.ui import do_pager
  296. do_pager('\n'.join(e['id'] for e in src_data['cc']))
  297. return
  298. global now
  299. now = 1659465400 if gcfg.test_suite else time.time() # 1659524400 1659445900
  300. data = dict(gen_data(src_data))
  301. gcfg._util.stdout_or_pager(
  302. '\n'.join(getattr(Ticker,cfg.clsname)(data).gen_output()) + '\n'
  303. )
  304. def make_cfg():
  305. def get_rows_from_cfg(add_data=None):
  306. def gen():
  307. for n,(k,v) in enumerate(cfg_in.cfg['assets'].items()):
  308. yield(k)
  309. if add_data and k in add_data:
  310. v += tuple(add_data[k])
  311. for e in v:
  312. yield parse_asset_id(e,require_label=True)
  313. return tuple(gen())
  314. def parse_asset_id(s,require_label=False):
  315. return src_cls['cc'].parse_asset_id(s,require_label)
  316. def parse_usr_asset_arg(key,use_cf_file=False):
  317. """
  318. asset_id[:rate[:rate_asset]]
  319. """
  320. def parse_parm(s):
  321. ss = s.split(':')
  322. assert len(ss) in (1,2,3), f'{s}: malformed argument'
  323. asset_id,rate,rate_asset = (*ss,None,None)[:3]
  324. parsed_id = parse_asset_id(asset_id)
  325. return asset_data(
  326. symbol = parsed_id.symbol,
  327. id = parsed_id.id,
  328. amount = None,
  329. rate = (
  330. None if rate is None else
  331. 1 / Decimal(rate[:-1]) if rate.lower().endswith('r') else
  332. Decimal(rate) ),
  333. rate_asset = parse_asset_id(rate_asset) if rate_asset else None,
  334. source = parsed_id.source )
  335. cl_opt = getattr(gcfg,key)
  336. cf_opt = cfg_in.cfg.get(key,[]) if use_cf_file else []
  337. return tuple( parse_parm(s) for s in (cl_opt.split(',') if cl_opt else cf_opt) )
  338. def parse_query_arg(s):
  339. """
  340. asset_id:amount[:to_asset_id[:to_amount]]
  341. """
  342. def parse_query_asset(asset_id,amount):
  343. parsed_id = parse_asset_id(asset_id)
  344. return asset_data(
  345. symbol = parsed_id.symbol,
  346. id = parsed_id.id,
  347. amount = None if amount is None else Decimal(amount),
  348. rate = None,
  349. rate_asset = None,
  350. source = parsed_id.source )
  351. ss = s.split(':')
  352. assert len(ss) in (2,3,4), f'{s}: malformed argument'
  353. asset_id,amount,to_asset_id,to_amount = (*ss,None,None)[:4]
  354. return query_tuple(
  355. asset = parse_query_asset(asset_id,amount),
  356. to_asset = parse_query_asset(to_asset_id,to_amount) if to_asset_id else None
  357. )
  358. def gen_uniq(obj_list,key,preload=None):
  359. found = set([getattr(obj,key) for obj in preload if hasattr(obj,key)] if preload else ())
  360. for obj in obj_list:
  361. id = getattr(obj,key)
  362. if id not in found:
  363. yield obj
  364. found.add(id)
  365. def get_usr_assets():
  366. return (
  367. 'user_added',
  368. usr_rows +
  369. (tuple(asset for asset in query if asset) if query else ()) +
  370. usr_columns )
  371. def get_portfolio_assets(ret=()):
  372. if cfg_in.portfolio and gcfg.portfolio:
  373. ret = (parse_asset_id(e,require_label=True) for e in cfg_in.portfolio)
  374. return ( 'portfolio', tuple(e for e in ret if (not gcfg.btc) or e.symbol == 'BTC') )
  375. def get_portfolio():
  376. return {k:Decimal(v) for k,v in cfg_in.portfolio.items() if (not gcfg.btc) or k == 'btc-bitcoin'}
  377. def parse_add_precision(s):
  378. if not s:
  379. return 0
  380. if not (s.isdigit() and s.isascii()):
  381. die(1,f'{s}: invalid parameter for --add-precision (not an integer)')
  382. if int(s) > 30:
  383. die(1,f'{s}: invalid parameter for --add-precision (value >30)')
  384. return int(s)
  385. def create_rows():
  386. rows = (
  387. ('trade_pair',) + query if (query and query.to_asset) else
  388. ('bitcoin',parse_asset_id('btc-bitcoin')) if gcfg.btc else
  389. get_rows_from_cfg( add_data={'fiat':['usd-us-dollar']} if gcfg.add_columns else None )
  390. )
  391. for hdr,data in (
  392. (get_usr_assets(),) if query else
  393. (get_usr_assets(), get_portfolio_assets())
  394. ):
  395. if data:
  396. uniq_data = tuple(gen_uniq(data,'symbol',preload=rows))
  397. if uniq_data:
  398. rows += (hdr,) + uniq_data
  399. return rows
  400. cfg_tuple = namedtuple('global_cfg',[
  401. 'rows',
  402. 'usr_rows',
  403. 'usr_columns',
  404. 'query',
  405. 'adjust',
  406. 'clsname',
  407. 'btc_only',
  408. 'add_prec',
  409. 'cachedir',
  410. 'proxy',
  411. 'portfolio' ])
  412. global cfg_in,src_cls,cfg
  413. src_cls = { k: getattr(DataSource,v) for k,v in DataSource.sources.items() }
  414. cmd_args = gcfg._args
  415. cfg_in = get_cfg_in()
  416. query_tuple = namedtuple('query',['asset','to_asset'])
  417. asset_data = namedtuple('asset_data',['symbol','id','amount','rate','rate_asset','source'])
  418. usr_rows = parse_usr_asset_arg('add_rows')
  419. usr_columns = parse_usr_asset_arg('add_columns',use_cf_file=True)
  420. query = parse_query_arg(cmd_args[0]) if cmd_args else None
  421. def get_proxy(name):
  422. proxy = getattr(gcfg,name)
  423. return (
  424. '' if proxy == '' else 'none' if (proxy and proxy.lower() == 'none')
  425. else (proxy or cfg_in.cfg.get(name))
  426. )
  427. proxy = get_proxy('proxy')
  428. proxy = None if proxy == 'none' else proxy
  429. cfg = cfg_tuple(
  430. rows = create_rows(),
  431. usr_rows = usr_rows,
  432. usr_columns = usr_columns,
  433. query = query,
  434. adjust = ( lambda x: (100 + x) / 100 if x else 1 )( Decimal(gcfg.adjust or 0) ),
  435. clsname = 'trading' if query else 'overview',
  436. btc_only = gcfg.btc,
  437. add_prec = parse_add_precision(gcfg.add_precision),
  438. cachedir = gcfg.cachedir or cfg_in.cfg.get('cachedir') or dfl_cachedir,
  439. proxy = proxy,
  440. portfolio = get_portfolio() if cfg_in.portfolio and gcfg.portfolio and not query else None
  441. )
  442. def get_cfg_in():
  443. ret = namedtuple('cfg_in_data',['cfg','portfolio','cfg_file','portfolio_file'])
  444. cfg_file,portfolio_file = (
  445. [os.path.join(gcfg.data_dir_root,'node_tools',fn) for fn in (cfg_fn,portfolio_fn)]
  446. )
  447. cfg_data,portfolio_data = (
  448. [yaml.safe_load(open(fn).read()) if os.path.exists(fn) else None for fn in (cfg_file,portfolio_file)]
  449. )
  450. return ret(
  451. cfg = cfg_data or {
  452. 'assets': {
  453. 'coin': [ 'btc-bitcoin', 'eth-ethereum', 'xmr-monero' ],
  454. 'commodity': [ 'xau-gold-spot-token', 'xag-silver-spot-token', 'xbr-brent-crude-oil-spot' ],
  455. 'fiat': [ 'gbp-pound-sterling-token', 'eur-euro-token' ],
  456. 'index': [ 'dj30-dow-jones-30-token', 'spx-sp-500', 'ndx-nasdaq-100-token' ],
  457. },
  458. 'proxy': 'http://vpn-gw:8118'
  459. },
  460. portfolio = portfolio_data,
  461. cfg_file = cfg_file,
  462. portfolio_file = portfolio_file,
  463. )
  464. class Ticker:
  465. class base:
  466. offer = None
  467. to_asset = None
  468. def __init__(self,data):
  469. self.comma = ',' if gcfg.thousands_comma else ''
  470. self.col1_wid = max(len('TOTAL'),(
  471. max(len(self.create_label(d['id'])) for d in data.values()) if gcfg.name_labels else
  472. max(len(d['symbol']) for d in data.values())
  473. )) + 1
  474. self.rows = [row._replace(id=self.get_id(row)) if isinstance(row,tuple) else row for row in cfg.rows]
  475. self.col_usd_prices = {k:Decimal(self.data[k]['price_usd']) for k in self.col_ids}
  476. self.prices = {row.id:self.get_row_prices(row.id)
  477. for row in self.rows if isinstance(row,tuple) and row.id in data}
  478. self.prices['usd-us-dollar'] = self.get_row_prices('usd-us-dollar')
  479. def format_last_update_col(self,cross_assets=()):
  480. if gcfg.elapsed:
  481. from mmgen.util2 import format_elapsed_hr
  482. fmt_func = format_elapsed_hr
  483. else:
  484. fmt_func = lambda t,now: time.strftime('%F %X',time.gmtime(t)) # ticker API
  485. # t.replace('T',' ').replace('Z','') # tickers API
  486. d = self.data
  487. max_w = 0
  488. if cross_assets:
  489. last_updated_x = [d[a.id]['last_updated'] for a in cross_assets]
  490. min_t = min( (int(n) for n in last_updated_x if isinstance(n,int) ), default=None )
  491. else:
  492. min_t = None
  493. for row in self.rows:
  494. if isinstance(row,tuple):
  495. try:
  496. t = int( d[row.id]['last_updated'] )
  497. except TypeError as e:
  498. d[row.id]['last_updated_fmt'] = gray('--' if 'NoneType' in str(e) else str(e))
  499. except KeyError as e:
  500. msg(str(e))
  501. pass
  502. else:
  503. t_fmt = d[row.id]['last_updated_fmt'] = fmt_func( (min(t,min_t) if min_t else t), now )
  504. max_w = max(len(t_fmt),max_w)
  505. self.upd_w = max_w
  506. def init_prec(self):
  507. exp = [(a.id,Decimal.adjusted(self.prices[a.id]['usd-us-dollar'])) for a in self.usr_col_assets]
  508. self.uprec = { k: max(0,v+4) + cfg.add_prec for k,v in exp }
  509. self.uwid = { k: 12 + max(0, abs(v)-6) + cfg.add_prec for k,v in exp }
  510. def get_id(self,asset):
  511. if asset.id:
  512. return asset.id
  513. else:
  514. for d in self.data.values():
  515. if d['symbol'] == asset.symbol:
  516. return d['id']
  517. def create_label(self,id):
  518. return self.data[id]['name'].upper()
  519. def gen_output(self):
  520. yield 'Current time: {} UTC'.format(time.strftime('%F %X',time.gmtime(now)))
  521. for asset in self.usr_col_assets:
  522. if asset.symbol != 'USD':
  523. usdprice = Decimal(self.data[asset.id]['price_usd'])
  524. yield '{} ({}) = {:{}.{}f} USD'.format(
  525. asset.symbol,
  526. self.create_label(asset.id),
  527. usdprice,
  528. self.comma,
  529. max(2,int(-usdprice.adjusted())+4) )
  530. if hasattr(self,'subhdr'):
  531. yield self.subhdr
  532. if self.show_adj:
  533. yield (
  534. ('Offered price differs from spot' if self.offer else 'Adjusting prices')
  535. + ' by '
  536. + yellow('{:+.2f}%'.format( (self.adjust-1) * 100 ))
  537. )
  538. yield ''
  539. if cfg.portfolio:
  540. yield blue('PRICES')
  541. if self.table_hdr:
  542. yield self.table_hdr
  543. for row in self.rows:
  544. if isinstance(row,str):
  545. yield ('-' * self.hl_wid)
  546. else:
  547. try:
  548. yield self.fmt_row(self.data[row.id])
  549. except KeyError:
  550. yield gray(f'(no data for {row.id})')
  551. yield '-' * self.hl_wid
  552. if cfg.portfolio:
  553. self.fs_num = self.fs_num2
  554. self.fs_str = self.fs_str2
  555. yield ''
  556. yield blue('PORTFOLIO')
  557. yield self.table_hdr
  558. yield '-' * self.hl_wid
  559. for sym,amt in cfg.portfolio.items():
  560. try:
  561. yield self.fmt_row(self.data[sym],amt=amt)
  562. except KeyError:
  563. yield gray(f'(no data for {sym})')
  564. yield '-' * self.hl_wid
  565. if not cfg.btc_only:
  566. yield self.fs_num.format(
  567. lbl = 'TOTAL', pc1='', pc2='', upd='', amt='',
  568. **{ k.replace('-','_'): v for k,v in self.prices['total'].items() }
  569. )
  570. class overview(base):
  571. def __init__(self,data):
  572. self.data = data
  573. self.adjust = cfg.adjust
  574. self.show_adj = self.adjust != 1
  575. self.usr_col_assets = [asset._replace(id=self.get_id(asset)) for asset in cfg.usr_columns]
  576. self.col_ids = ('usd-us-dollar',) + tuple(a.id for a in self.usr_col_assets) + ('btc-bitcoin',)
  577. super().__init__(data)
  578. self.format_last_update_col()
  579. if cfg.portfolio:
  580. self.prices['total'] = { col_id: sum(self.prices[row.id][col_id] * cfg.portfolio[row.id]
  581. for row in self.rows if isinstance(row,tuple) and row.id in cfg.portfolio and row.id in data)
  582. for col_id in self.col_ids }
  583. self.init_prec()
  584. self.init_fs()
  585. def get_row_prices(self,id):
  586. if id in self.data:
  587. d = self.data[id]
  588. return { k: (
  589. Decimal(d['price_btc']) if k == 'btc-bitcoin' else
  590. Decimal(d['price_usd']) / self.col_usd_prices[k]
  591. ) * self.adjust for k in self.col_ids }
  592. def fmt_row(self,d,amt=None,amt_fmt=None):
  593. def fmt_pct(d):
  594. if d in ('',None):
  595. return gray(' --')
  596. n = Decimal(d)
  597. return (red,green)[n>=0](f'{n:+7.2f}')
  598. p = self.prices[d['id']]
  599. if amt is not None:
  600. amt_fmt = f'{amt:{19+cfg.add_prec}{self.comma}.{8+cfg.add_prec}f}'
  601. if '.' in amt_fmt:
  602. amt_fmt = amt_fmt.rstrip('0').rstrip('.')
  603. return self.fs_num.format(
  604. lbl = (self.create_label(d['id']) if gcfg.name_labels else d['symbol']),
  605. pc1 = fmt_pct(d.get('percent_change_7d')),
  606. pc2 = fmt_pct(d.get('percent_change_24h')),
  607. upd = d.get('last_updated_fmt'),
  608. amt = amt_fmt,
  609. **{ k.replace('-','_'): v * (1 if amt is None else amt) for k,v in p.items() }
  610. )
  611. def init_fs(self):
  612. col_prec = {'usd-us-dollar':2+cfg.add_prec,'btc-bitcoin':8+cfg.add_prec } # | self.uprec # Python 3.9
  613. col_prec.update(self.uprec)
  614. col_wid = {'usd-us-dollar':8+cfg.add_prec,'btc-bitcoin':12+cfg.add_prec } # """
  615. col_wid.update(self.uwid)
  616. max_row = max(
  617. ( (k,v['btc-bitcoin']) for k,v in self.prices.items() ),
  618. key = lambda a: a[1]
  619. )
  620. widths = { k: len('{:{}.{}f}'.format( self.prices[max_row[0]][k], self.comma, col_prec[k] ))
  621. for k in self.col_ids }
  622. fd = namedtuple('format_str_data',['fs_str','fs_num','wid'])
  623. col_fs_data = {
  624. 'label': fd(f'{{lbl:{self.col1_wid}}}',f'{{lbl:{self.col1_wid}}}',self.col1_wid),
  625. 'pct7d': fd(' {pc1:7}', ' {pc1:7}', 8),
  626. 'pct24h': fd(' {pc2:7}', ' {pc2:7}', 8),
  627. 'update_time': fd(' {upd}', ' {upd}', max((19 if cfg.portfolio else 0),self.upd_w) + 2),
  628. 'amt': fd(' {amt}', ' {amt}', 21),
  629. }
  630. # } | { k: fd( # Python 3.9
  631. col_fs_data.update({ k: fd(
  632. ' {{{}:>{}}}'.format( k.replace('-','_'), widths[k] ),
  633. ' {{{}:{}{}.{}f}}'.format( k.replace('-','_'), widths[k], self.comma, col_prec[k] ),
  634. widths[k]+2
  635. ) for k in self.col_ids
  636. })
  637. cols = (
  638. ['label','usd-us-dollar'] +
  639. [asset.id for asset in self.usr_col_assets] +
  640. [a for a,b in (
  641. ( 'btc-bitcoin', not cfg.btc_only ),
  642. ( 'pct7d', gcfg.percent_change ),
  643. ( 'pct24h', gcfg.percent_change ),
  644. ( 'update_time', gcfg.update_time ),
  645. ) if b]
  646. )
  647. cols2 = list(cols)
  648. if gcfg.update_time:
  649. cols2.pop()
  650. cols2.append('amt')
  651. self.fs_str = ''.join(col_fs_data[c].fs_str for c in cols)
  652. self.fs_num = ''.join(col_fs_data[c].fs_num for c in cols)
  653. self.hl_wid = sum(col_fs_data[c].wid for c in cols)
  654. self.fs_str2 = ''.join(col_fs_data[c].fs_str for c in cols2)
  655. self.fs_num2 = ''.join(col_fs_data[c].fs_num for c in cols2)
  656. self.hl_wid2 = sum(col_fs_data[c].wid for c in cols2)
  657. @property
  658. def table_hdr(self):
  659. return self.fs_str.format(
  660. lbl = '',
  661. pc1 = ' CHG_7d',
  662. pc2 = 'CHG_24h',
  663. upd = 'UPDATED',
  664. amt = ' AMOUNT',
  665. usd_us_dollar = 'USD',
  666. btc_bitcoin = ' BTC',
  667. **{ a.id.replace('-','_'): a.symbol for a in self.usr_col_assets }
  668. )
  669. class trading(base):
  670. def __init__(self,data):
  671. self.data = data
  672. self.asset = cfg.query.asset._replace(id=self.get_id(cfg.query.asset))
  673. self.to_asset = (
  674. cfg.query.to_asset._replace(id=self.get_id(cfg.query.to_asset))
  675. if cfg.query.to_asset else None )
  676. self.col_ids = [self.asset.id]
  677. self.adjust = cfg.adjust
  678. if self.to_asset:
  679. self.offer = self.to_asset.amount
  680. if self.offer:
  681. real_price = (
  682. self.asset.amount
  683. * Decimal(data[self.asset.id]['price_usd'])
  684. / Decimal(data[self.to_asset.id]['price_usd'])
  685. )
  686. if self.adjust != 1:
  687. die(1,'the --adjust option may not be combined with TO_AMOUNT in the trade specifier')
  688. self.adjust = self.offer / real_price
  689. self.hl_ids = [self.asset.id,self.to_asset.id]
  690. else:
  691. self.hl_ids = [self.asset.id]
  692. self.show_adj = self.adjust != 1 or self.offer
  693. super().__init__(data)
  694. self.usr_col_assets = [self.asset] + ([self.to_asset] if self.to_asset else [])
  695. for a in self.usr_col_assets:
  696. self.prices[a.id]['usd-us-dollar'] = Decimal(data[a.id]['price_usd'])
  697. self.format_last_update_col(cross_assets=self.usr_col_assets)
  698. self.init_prec()
  699. self.init_fs()
  700. def get_row_prices(self,id):
  701. if id in self.data:
  702. d = self.data[id]
  703. return { k: self.col_usd_prices[self.asset.id] / Decimal(d['price_usd']) for k in self.col_ids }
  704. def init_fs(self):
  705. self.max_wid = max(
  706. len('{:{}{}.{}f}'.format(
  707. v[self.asset.id] * self.asset.amount,
  708. 16 + cfg.add_prec,
  709. self.comma,
  710. 8 + cfg.add_prec
  711. ))
  712. for v in self.prices.values()
  713. )
  714. self.fs_str = '{lbl:%s} {p_spot}' % self.col1_wid
  715. self.hl_wid = self.col1_wid + self.max_wid + 1
  716. if self.show_adj:
  717. self.fs_str += ' {p_adj}'
  718. self.hl_wid += self.max_wid + 1
  719. if gcfg.update_time:
  720. self.fs_str += ' {upd}'
  721. self.hl_wid += self.upd_w + 2
  722. def fmt_row(self,d):
  723. id = d['id']
  724. p = self.prices[id][self.asset.id] * self.asset.amount
  725. p_spot = '{:{}{}.{}f}'.format( p, self.max_wid, self.comma, 8+cfg.add_prec )
  726. p_adj = (
  727. '{:{}{}.{}f}'.format( p*self.adjust, self.max_wid, self.comma, 8+cfg.add_prec )
  728. if self.show_adj else '' )
  729. return self.fs_str.format(
  730. lbl = (self.create_label(id) if gcfg.name_labels else d['symbol']),
  731. p_spot = green(p_spot) if id in self.hl_ids else p_spot,
  732. p_adj = yellow(p_adj) if id in self.hl_ids else p_adj,
  733. upd = d.get('last_updated_fmt'),
  734. )
  735. @property
  736. def table_hdr(self):
  737. return self.fs_str.format(
  738. lbl = '',
  739. p_spot = '{t:>{w}}'.format(
  740. t = 'SPOT PRICE',
  741. w = self.max_wid ),
  742. p_adj = '{t:>{w}}'.format(
  743. t = ('OFFERED' if self.offer else 'ADJUSTED') + ' PRICE',
  744. w = self.max_wid ),
  745. upd = 'UPDATED'
  746. )
  747. @property
  748. def subhdr(self):
  749. return (
  750. '{a}: {b:{c}} {d}'.format(
  751. a = 'Offer' if self.offer else 'Amount',
  752. b = self.asset.amount,
  753. c = self.comma,
  754. d = self.asset.symbol
  755. ) + (
  756. (
  757. ' =>' +
  758. (' {:{}}'.format(self.offer,self.comma) if self.offer else '') +
  759. ' {} ({})'.format(
  760. self.to_asset.symbol,
  761. self.create_label(self.to_asset.id) )
  762. ) if self.to_asset else '' )
  763. )