Ticker.py 23 KB

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