Ticker.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309
  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-wallet https://github.com/mmgen/mmgen-node-tools
  9. # https://gitlab.com/mmgen/mmgen-wallet https://gitlab.com/mmgen/mmgen-node-tools
  10. """
  11. mmgen_node_tools.Ticker: Display price information for cryptocurrency and other assets
  12. """
  13. # v3.2.dev4: switch to new coinpaprika ‘tickers’ API call (supports ‘limit’ parameter, more historical 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 os, re, time, datetime, 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 red, yellow, green, blue, orange, gray, cyan, pink
  23. from mmgen.util import msg, msg_r, rmsg, Msg, Msg_r, die, fmt, fmt_list, fmt_dict, list_gen, suf, is_int
  24. from mmgen.ui import do_pager
  25. homedir = os.getenv('HOME')
  26. dfl_cachedir = os.path.join(homedir, '.cache', 'mmgen-node-tools')
  27. cfg_fn = 'ticker-cfg.yaml'
  28. portfolio_fn = 'ticker-portfolio.yaml'
  29. asset_tuple = namedtuple('asset_tuple', ['symbol', 'id', 'source'])
  30. last_api_host = None
  31. percent_cols = {
  32. 'd': 'day',
  33. 'w': 'week',
  34. 'm': 'month',
  35. 'y': 'year'}
  36. sp = namedtuple('sort_parameter', ['key', 'sort_dfl', 'desc'])
  37. sort_params = {
  38. 'd': sp('percent_change_24h', 0.0, '1-day percent change'),
  39. 'w': sp('percent_change_7d', 0.0, '1-week percent change'),
  40. 'm': sp('percent_change_30d', 0.0, '1-month percent change'),
  41. 'y': sp('percent_change_1y', 0.0, '1-year percent change'),
  42. 'p': sp('price_usd', Decimal(0), 'asset price'),
  43. 'c': sp('market_cap', 0, 'market cap')}
  44. class RowDict(dict):
  45. def __iter__(self):
  46. return (e for v in self.values() for e in v)
  47. class DataSource:
  48. source_groups = [
  49. {
  50. 'cc': 'coinpaprika'
  51. }, {
  52. 'fi': 'yahoospot',
  53. 'hi': 'yahoohist',
  54. }]
  55. @classmethod
  56. def get_sources(cls, randomize=False):
  57. g = random.sample(cls.source_groups, k=len(cls.source_groups)) if randomize else cls.source_groups
  58. return {k: v for a in g for k, v in a.items()}
  59. class base:
  60. def fetch_delay(self):
  61. global last_api_host
  62. if not gcfg.testing and last_api_host and last_api_host != self.api_host:
  63. delay = 1 + random.randrange(1, 5000) / 1000
  64. msg_r(f'Waiting {delay:.3f} seconds...')
  65. time.sleep(delay)
  66. msg('')
  67. last_api_host = self.api_host
  68. def get_data_from_network(self):
  69. curl_cmd = list_gen(
  70. ['curl', '--tr-encoding', '--header', 'Accept: application/json', True],
  71. ['--compressed'], # adds 'Accept-Encoding: gzip'
  72. ['--proxy', cfg.proxy, isinstance(cfg.proxy, str)],
  73. ['--silent', not cfg.verbose],
  74. ['--connect-timeout', str(gcfg.http_timeout), gcfg.http_timeout],
  75. [self.api_url])
  76. if gcfg.testing:
  77. Msg(fmt_list(curl_cmd, fmt='bare'))
  78. return
  79. try:
  80. return run(curl_cmd, check=True, stdout=PIPE).stdout.decode()
  81. except CalledProcessError as e:
  82. msg('')
  83. from .Misc import curl_exit_codes
  84. msg(red(curl_exit_codes[e.returncode]))
  85. msg(red('Command line:\n {}'.format(
  86. ' '.join((repr(i) if ' ' in i else i) for i in e.cmd))))
  87. from mmgen.exception import MMGenCalledProcessError
  88. raise MMGenCalledProcessError(
  89. f'Subprocess returned non-zero exit status {e.returncode}')
  90. def get_data(self):
  91. if not os.path.exists(cfg.cachedir):
  92. os.makedirs(cfg.cachedir)
  93. use_cached_data = cfg.cached_data and not gcfg.download
  94. if use_cached_data:
  95. data_type = 'json'
  96. try:
  97. data_in = open(self.json_fn).read()
  98. except FileNotFoundError:
  99. die(1, f'Cannot use cached data, because {self.json_fn_disp} does not exist')
  100. else:
  101. data_type = self.net_data_type
  102. try:
  103. mtime = os.stat(self.json_fn).st_mtime
  104. except FileNotFoundError:
  105. mtime = 0
  106. if (elapsed := int(time.time() - mtime)) >= self.timeout or gcfg.testing:
  107. if gcfg.testing:
  108. msg('')
  109. self.fetch_delay()
  110. msg_r(f'Fetching {self.data_desc} from {self.api_host}...')
  111. if self.has_verbose and cfg.verbose:
  112. msg('')
  113. data_in = self.get_data_from_network()
  114. msg('done')
  115. if gcfg.testing:
  116. return {}
  117. else:
  118. die(1, self.rate_limit_errmsg(elapsed))
  119. match data_type:
  120. case 'json':
  121. try:
  122. data = json.loads(data_in)
  123. except:
  124. self.json_data_error_msg(data_in)
  125. die(2, 'Retrieved data is not valid JSON, exiting')
  126. json_text = data_in
  127. case 'python':
  128. data = data_in
  129. json_text = json.dumps(data_in)
  130. if not data:
  131. if use_cached_data:
  132. die(1,
  133. f'No cached {self.data_desc}! Run command without the --cached-data option, '
  134. 'or use --download to retrieve data from remote host')
  135. else:
  136. die(2, 'Remote host returned no data!')
  137. elif 'error' in data:
  138. die(1, data['error'])
  139. self.data = self.postprocess_data(data)
  140. if use_cached_data:
  141. self.json_text = None
  142. if not cfg.quiet:
  143. msg(f'Using cached data from {self.json_fn_disp}')
  144. else:
  145. self.json_text = json_text
  146. if cache_data(self, no_overwrite=True):
  147. self.json_text = None
  148. return self
  149. def json_data_error_msg(self, json_text):
  150. pass
  151. def postprocess_data(self, data):
  152. return data
  153. @property
  154. def json_fn_disp(self):
  155. return '~/' + os.path.relpath(self.json_fn, start=homedir)
  156. class coinpaprika(base):
  157. desc = 'CoinPaprika'
  158. data_desc = 'cryptocurrency data'
  159. api_host = 'api.coinpaprika.com'
  160. api_proto = 'https'
  161. ratelimit = 240
  162. btc_ratelimit = 10
  163. net_data_type = 'json'
  164. has_verbose = True
  165. dfl_asset_limit = 2000
  166. max_asset_idx = 1_000_000
  167. def __init__(self):
  168. self.asset_limit = int(cfg.asset_limit) if is_int(cfg.asset_limit) else self.dfl_asset_limit
  169. def rate_limit_errmsg(self, elapsed):
  170. rem = self.timeout - elapsed
  171. return (
  172. f'Rate limit exceeded! Retry in {rem} second{suf(rem)}' +
  173. ('' if cfg.btc_only else ', or use --cached-data or --btc'))
  174. @property
  175. def api_url(self):
  176. return (
  177. f'{self.api_proto}://{self.api_host}/v1/tickers/btc-bitcoin'
  178. if cfg.btc_only else
  179. f'{self.api_proto}://{self.api_host}/v1/tickers?limit={self.asset_limit}'
  180. if self.asset_limit else
  181. f'{self.api_proto}://{self.api_host}/v1/tickers')
  182. @property
  183. def json_fn(self):
  184. return os.path.join(
  185. cfg.cachedir,
  186. 'ticker-btc.json' if cfg.btc_only else 'ticker.json')
  187. @property
  188. def timeout(self):
  189. return 0 if gcfg.test_suite else self.btc_ratelimit if cfg.btc_only else self.ratelimit
  190. def json_data_error_msg(self, json_text):
  191. tor_captcha_msg = f"""
  192. If you’re using Tor, the API request may have failed due to Captcha protection.
  193. A workaround for this issue is to retrieve the JSON data with a browser from
  194. the following URL:
  195. {self.api_url}
  196. and save it to:
  197. ‘{cfg.cachedir}/ticker.json’
  198. Then invoke the program with --cached-data and without --btc
  199. """
  200. msg(json_text[:1024] + '...')
  201. msg(orange(fmt(tor_captcha_msg, strip_char='\t')))
  202. def postprocess_data(self, data):
  203. return [data] if cfg.btc_only else data
  204. @staticmethod
  205. def parse_asset_id(s, require_label=True):
  206. sym, label = (*s.split('-', 1), None)[:2]
  207. if require_label and not label:
  208. die(1, f'{s!r}: asset label is missing')
  209. return asset_tuple(
  210. symbol = sym.upper(),
  211. id = (s.lower() if label else None),
  212. source = 'cc')
  213. class yahoospot(base):
  214. desc = 'Yahoo Finance'
  215. data_desc = 'spot financial data'
  216. api_host = 'finance.yahoo.com'
  217. ratelimit = 30
  218. net_data_type = 'python'
  219. has_verbose = False
  220. asset_id_pat = r'^\^.*|.*=[xf]$'
  221. json_fn_basename = 'ticker-finance.json'
  222. @staticmethod
  223. def get_id(sym, data):
  224. return sym.lower()
  225. @staticmethod
  226. def conv_data(sym, data, btcusd):
  227. price_usd = Decimal(data['regularMarketPrice']['raw'])
  228. return {
  229. 'id': sym,
  230. 'name': data['shortName'],
  231. 'symbol': sym.upper(),
  232. 'price_usd': price_usd,
  233. 'price_btc': price_usd / btcusd,
  234. 'percent_change_1y': data['pct_chg_1y'],
  235. 'percent_change_30d': data['pct_chg_4wks'],
  236. 'percent_change_7d': data['pct_chg_1wk'],
  237. 'percent_change_24h': data['regularMarketChangePercent']['raw'] * 100,
  238. 'market_cap': 0, # dummy - required for sorting
  239. 'last_updated': data['regularMarketTime']}
  240. def rate_limit_errmsg(self, elapsed):
  241. rem = self.timeout - elapsed
  242. return f'Rate limit exceeded! Retry in {rem} second{suf(rem)}, or use --cached-data'
  243. @property
  244. def json_fn(self):
  245. return os.path.join(cfg.cachedir, self.json_fn_basename)
  246. @property
  247. def timeout(self):
  248. return 0 if gcfg.test_suite else self.ratelimit
  249. @property
  250. def symbols(self):
  251. return [r.symbol for r in cfg.rows if r.source == 'fi']
  252. def get_data_from_network(self):
  253. kwargs = {
  254. 'formatted': True,
  255. 'asynchronous': True,
  256. 'proxies': {'https': cfg.proxy2}}
  257. if gcfg.test_suite:
  258. kwargs.update({'timeout': 1, 'retry': 0})
  259. if gcfg.http_timeout:
  260. kwargs.update({'timeout': gcfg.http_timeout})
  261. if gcfg.testing:
  262. Msg('\nyahooquery.Ticker(\n {},\n {}\n)'.format(
  263. self.symbols,
  264. fmt_dict(kwargs, fmt='kwargs')))
  265. return
  266. from yahooquery import Ticker
  267. return self.process_network_data(Ticker(self.symbols,**kwargs))
  268. def process_network_data(self, ticker):
  269. return ticker.price
  270. @staticmethod
  271. def parse_asset_id(s, require_label=True):
  272. return asset_tuple(
  273. symbol = s.upper(),
  274. id = s.lower(),
  275. source = 'fi')
  276. class yahoohist(yahoospot):
  277. json_fn_basename = 'ticker-finance-history.json'
  278. data_desc = 'historical financial data'
  279. net_data_type = 'json'
  280. period = '1y'
  281. interval = '1wk'
  282. def process_network_data(self, ticker):
  283. return ticker.history(
  284. period = self.period,
  285. interval = self.interval).to_json(orient='index')
  286. def postprocess_data(self, data):
  287. def gen():
  288. keys = set()
  289. d = {}
  290. for key, val in data.items():
  291. if m := re.match(r"\('(.*?)', datetime\.date\((.*)\)\)$", key):
  292. date = '{}-{:>02}-{:>02}'.format(*m[2].split(', '))
  293. if (sym := m[1]) in keys:
  294. d[date] = val
  295. else:
  296. keys.add(sym)
  297. d = {date: val}
  298. yield (sym, d)
  299. return dict(gen())
  300. def assets_list_gen(cfg_in):
  301. for k, v in cfg_in.cfg['assets'].items():
  302. yield ''
  303. yield k.upper()
  304. for e in v:
  305. out = e.split('-', 1)
  306. yield ' {:5s} {}'.format(out[0], out[1] if len(out) == 2 else '')
  307. def gen_data(data):
  308. """
  309. Filter the raw data and return it as a dict keyed by the IDs of the assets
  310. we want to display.
  311. Add dummy entry for USD and entry for user-specified asset, if any.
  312. Since symbols in source data are not guaranteed to be unique (e.g. XAG), we
  313. must search the data twice: first for unique IDs, then for symbols while
  314. checking for duplicates.
  315. """
  316. def dup_sym_errmsg(data_type, dup_sym):
  317. return (
  318. f'The symbol {dup_sym!r} is shared by the following assets:\n' +
  319. '\n ' + '\n '.join(d['id'] for d in data[data_type].data if d['symbol'] == dup_sym) +
  320. '\n\nPlease specify the asset by one of the full IDs listed above\n' +
  321. f'instead of {dup_sym!r}')
  322. def check_assets_found(wants, found, keys=['symbol', 'id']):
  323. error = False
  324. for k in keys:
  325. missing = wants[k] - found[k]
  326. if missing:
  327. msg(
  328. ('The following IDs were not found in source data:\n{}' if k == 'id' else
  329. 'The following symbols could not be resolved:\n{}').format(
  330. fmt_list(missing, fmt='col', indent=' ')))
  331. error = True
  332. if error:
  333. die(1, 'Missing data, exiting')
  334. class process_data:
  335. def cc():
  336. nonlocal btcusd
  337. for d in data['cc'].data:
  338. if d['id'] == 'btc-bitcoin':
  339. btcusd = Decimal(str(d['quotes']['USD']['price']))
  340. break
  341. else:
  342. raise ValueError('malformed cryptocurrency data')
  343. for k in ('id', 'symbol'):
  344. for d in data['cc'].data:
  345. if wants[k]:
  346. if d[k] in wants[k]:
  347. if d[k] in found[k]:
  348. die(1, dup_sym_errmsg('cc', d[k]))
  349. if not 'price_usd' in d:
  350. d['price_usd'] = Decimal(str(d['quotes']['USD']['price']))
  351. d['price_btc'] = Decimal(str(d['quotes']['USD']['price'])) / btcusd
  352. d['percent_change_24h'] = d['quotes']['USD']['percent_change_24h']
  353. d['percent_change_7d'] = d['quotes']['USD']['percent_change_7d']
  354. d['percent_change_30d'] = d['quotes']['USD']['percent_change_30d']
  355. d['percent_change_1y'] = d['quotes']['USD']['percent_change_1y']
  356. d['market_cap'] = d['quotes']['USD']['market_cap']
  357. d['last_updated'] = int(datetime.datetime.fromisoformat(
  358. d['last_updated']).timestamp())
  359. yield (d['id'], d)
  360. found[k].add(d[k])
  361. wants[k].remove(d[k])
  362. if d[k] in usr_rate_assets_want[k]:
  363. rate_assets[d['symbol']] = d # NB: using symbol instead of ID for key
  364. else:
  365. break
  366. def fi():
  367. get_id = src_cls['fi'].get_id
  368. conv_func = src_cls['fi'].conv_data
  369. for k, v in data['fi'].data.items():
  370. id = get_id(k, v)
  371. if wants['id']:
  372. if id in wants['id']:
  373. if not isinstance(v, dict):
  374. die(2, str(v))
  375. if id in found['id']:
  376. die(1, dup_sym_errmsg('fi', id))
  377. if hist := hist_close.get(k):
  378. spot = v['regularMarketPrice']['raw']
  379. v['pct_chg_1wk'] = (spot / hist.close_1wk - 1) * 100
  380. v['pct_chg_4wks'] = (spot / hist.close_4wks - 1) * 100 # 4 weeks ≈ 1 month
  381. v['pct_chg_1y'] = (spot / hist.close_1y - 1) * 100
  382. else:
  383. v['pct_chg_1wk'] = v['pct_chg_4wks'] = v['pct_chg_1y'] = None
  384. yield (id, conv_func(id, v, btcusd))
  385. found['id'].add(id)
  386. wants['id'].remove(id)
  387. if id in usr_rate_assets_want['id']: # NB: using symbol instead of ID for key:
  388. rate_assets[k] = conv_func(id, v, btcusd)
  389. else:
  390. break
  391. def hi():
  392. ret = namedtuple('historical_closing_prices', ['close_1wk', 'close_4wks', 'close_1y'])
  393. nonlocal hist_close
  394. for k, v in data['hi'].data.items():
  395. hist = tuple(v.values())
  396. hist_close[k] = ret(hist[-2]['close'], hist[-5]['close'], hist[0]['close'])
  397. return ()
  398. rows_want = {
  399. 'id': {r.id for r in cfg.rows if r.id} - {'usd-us-dollar'},
  400. 'symbol': {r.symbol for r in cfg.rows if r.id is None} - {'USD'}}
  401. usr_rate_assets = tuple(u.rate_asset for u in cfg.usr_rows + cfg.usr_columns if u.rate_asset)
  402. usr_rate_assets_want = {
  403. 'id': {a.id for a in usr_rate_assets if a.id},
  404. 'symbol': {a.symbol for a in usr_rate_assets if not a.id}}
  405. usr_assets = cfg.usr_rows + cfg.usr_columns + tuple(c for c in (cfg.query or ()) if c)
  406. usr_wants = {
  407. 'id': (
  408. {a.id for a in usr_assets + usr_rate_assets if a.id} -
  409. {a.id for a in usr_assets if a.rate and a.id} - {'usd-us-dollar'})
  410. ,
  411. 'symbol': (
  412. {a.symbol for a in usr_assets + usr_rate_assets if not a.id} -
  413. {a.symbol for a in usr_assets if a.rate} - {'USD'})}
  414. found = {'id': set(), 'symbol': set()}
  415. rate_assets = {}
  416. wants = {k: rows_want[k] | usr_wants[k] for k in ('id', 'symbol')}
  417. btcusd = Decimal('1') # dummy
  418. hist_close = {}
  419. parse_fail = False
  420. for data_type in ('cc', 'hi', 'fi'): # 'fi' depends on 'cc' and 'hi' so must go last
  421. if data_type in data:
  422. try:
  423. yield from getattr(process_data, data_type)()
  424. except Exception as e:
  425. rmsg(f'Error in source data {data_type!r}: {e}')
  426. parse_fail = True
  427. else:
  428. cache_data(data[data_type])
  429. if parse_fail:
  430. die(2, 'Invalid data encountered, exiting')
  431. if gcfg.download:
  432. return
  433. check_assets_found(usr_wants, found)
  434. for asset in (cfg.usr_rows + cfg.usr_columns):
  435. if asset.rate:
  436. """
  437. User-supplied rate overrides rate from source data.
  438. """
  439. _id = asset.id or f'{asset.symbol}-user-asset-{asset.symbol}'.lower()
  440. ra_rate = rate_assets[asset.rate_asset.symbol]['price_usd'] if asset.rate_asset else 1
  441. yield (_id, {
  442. 'symbol': asset.symbol,
  443. 'id': _id,
  444. 'name': ' '.join(_id.split('-')[1:]),
  445. 'price_usd': ra_rate / asset.rate,
  446. 'price_btc': ra_rate / asset.rate / btcusd,
  447. 'last_updated': None})
  448. yield ('usd-us-dollar', {
  449. 'symbol': 'USD',
  450. 'id': 'usd-us-dollar',
  451. 'name': 'US Dollar',
  452. 'price_usd': Decimal(1),
  453. 'price_btc': Decimal(1) / btcusd,
  454. 'percent_change_24h': 0.0,
  455. 'percent_change_7d': 0.0,
  456. 'percent_change_30d': 0.0,
  457. 'percent_change_1y': 0.0,
  458. 'last_updated': None})
  459. def cache_data(data_src, no_overwrite=False):
  460. if data_src.json_text:
  461. if os.path.exists(data_src.json_fn):
  462. if no_overwrite:
  463. return False
  464. os.rename(data_src.json_fn, data_src.json_fn + '.bak')
  465. with open(data_src.json_fn, 'w') as fh:
  466. fh.write(data_src.json_text)
  467. if not cfg.quiet:
  468. msg(f'JSON data cached to {data_src.json_fn_disp}')
  469. return True
  470. def main():
  471. def update_sample_file(usr_cfg_file):
  472. usr_data = files('mmgen_node_tools').joinpath('data', os.path.basename(usr_cfg_file)).read_text()
  473. sample_file = usr_cfg_file + '.sample'
  474. sample_data = open(sample_file).read() if os.path.exists(sample_file) else None
  475. if usr_data != sample_data:
  476. os.makedirs(os.path.dirname(sample_file), exist_ok=True)
  477. msg('{} {}'.format(
  478. ('Updating', 'Creating')[sample_data is None],
  479. sample_file))
  480. open(sample_file, 'w').write(usr_data)
  481. try:
  482. from importlib.resources import files # Python 3.9
  483. except ImportError:
  484. from importlib_resources import files
  485. update_sample_file(cfg_in.cfg_file)
  486. update_sample_file(cfg_in.portfolio_file)
  487. if gcfg.portfolio and not cfg_in.portfolio:
  488. die(1, 'No portfolio configured!\nTo configure a portfolio, edit the file ~/{}'.format(
  489. os.path.relpath(cfg_in.portfolio_file, start=homedir)))
  490. if gcfg.list_ids:
  491. src_ids = ['cc']
  492. elif gcfg.download:
  493. if not gcfg.download in DataSource.get_sources():
  494. die(1, f'{gcfg.download!r}: invalid data source')
  495. src_ids = [gcfg.download]
  496. else:
  497. src_ids = DataSource.get_sources(randomize=True)
  498. src_data = {k: src_cls[k]().get_data() for k in src_ids}
  499. if gcfg.testing:
  500. return
  501. if gcfg.list_ids:
  502. do_pager('\n'.join(e['id'] for e in src_data['cc'].data))
  503. return
  504. global cfg
  505. if cfg.asset_range:
  506. n, m = cfg.asset_range
  507. cfg = cfg._replace(rows = RowDict({
  508. 'asset_list':
  509. tuple(
  510. asset_tuple(e['symbol'], e['id'], source='cc')
  511. for e in src_data['cc'].data[n-1:m]),
  512. 'extra':
  513. tuple(
  514. [asset_tuple('BTC', 'btc-bitcoin', source='cc')]
  515. + [r for r in cfg.rows if r.source == 'fi'])}))
  516. global now
  517. now = 1659465400 if gcfg.test_suite else time.time() # 1659524400 1659445900
  518. data = dict(gen_data(src_data))
  519. if gcfg.download:
  520. return
  521. (do_pager if cfg.pager else Msg_r)(
  522. '\n'.join(getattr(Ticker, cfg.clsname)(data).gen_output()) + '\n')
  523. def make_cfg(gcfg_arg):
  524. query_tuple = namedtuple('query', ['asset', 'to_asset'])
  525. asset_data = namedtuple('asset_data', ['symbol', 'id', 'amount', 'rate', 'rate_asset', 'source'])
  526. def parse_asset_id(s, require_label=True):
  527. return src_cls['fi' if re.match(fi_pat, s) else 'cc'].parse_asset_id(s, require_label)
  528. def parse_percent_cols(arg):
  529. if arg is None or arg.lower() in ('none', ''):
  530. return []
  531. res = arg.lower().split(',')
  532. for s in res:
  533. if s not in percent_cols:
  534. die(1, '{!r}: invalid --percent-cols parameter (valid letters: {})'.format(
  535. arg,
  536. fmt_list(percent_cols)))
  537. return res
  538. def parse_usr_asset_arg(key, use_cf_file=False):
  539. """
  540. asset_id[:rate[:rate_asset]]
  541. """
  542. def parse_parm(s):
  543. ss = s.split(':')
  544. assert len(ss) in (1, 2, 3), f'{s}: malformed argument'
  545. asset_id, rate, rate_asset = (*ss, None, None)[:3]
  546. parsed_id = parse_asset_id(asset_id, require_label=False)
  547. return asset_data(
  548. symbol = parsed_id.symbol,
  549. id = parsed_id.id,
  550. amount = None,
  551. rate = (
  552. None if rate is None else
  553. 1 / Decimal(rate[:-1]) if rate.lower().endswith('r') else
  554. Decimal(rate)),
  555. rate_asset = parse_asset_id(rate_asset, require_label=False) if rate_asset else None,
  556. source = parsed_id.source)
  557. cl_opt = getattr(gcfg, key)
  558. if cl_opt is None or cl_opt.lower() in ('none', ''):
  559. return ()
  560. cf_opt = cfg_in.cfg.get(key,[]) if use_cf_file else []
  561. return tuple(parse_parm(s) for s in (cl_opt.split(',') if cl_opt else cf_opt))
  562. def parse_asset_range(s):
  563. max_idx = DataSource.coinpaprika.max_asset_idx
  564. match s.split('-'):
  565. case [a, b] if is_int(a) and is_int(b):
  566. n, m = (int(a), int(b))
  567. case [a] if is_int(a):
  568. n, m = (1, int(a))
  569. case _:
  570. return None
  571. if n < 1 or m < 1 or n > m:
  572. raise ValueError(f'‘{s}’: invalid asset range specifier')
  573. if m > max_idx:
  574. raise ValueError(f'‘{s}’: end of range must be <= {max_idx}')
  575. return (n, m)
  576. def parse_query_arg(s):
  577. """
  578. asset_id:amount[:to_asset_id[:to_amount]]
  579. """
  580. def parse_query_asset(asset_id, amount):
  581. parsed_id = parse_asset_id(asset_id, require_label=False)
  582. return asset_data(
  583. symbol = parsed_id.symbol,
  584. id = parsed_id.id,
  585. amount = None if amount is None else Decimal(amount),
  586. rate = None,
  587. rate_asset = None,
  588. source = parsed_id.source)
  589. ss = s.split(':')
  590. assert len(ss) in (2, 3, 4), f'{s}: malformed argument'
  591. asset_id, amount, to_asset_id, to_amount = (*ss, None, None)[:4]
  592. return query_tuple(
  593. asset = parse_query_asset(asset_id, amount),
  594. to_asset = parse_query_asset(to_asset_id, to_amount) if to_asset_id else None)
  595. def gen_uniq(obj_list, key, preload=None):
  596. found = set([getattr(obj, key) for obj in preload if hasattr(obj, key)] if preload else ())
  597. for obj in obj_list:
  598. id = getattr(obj, key)
  599. if id not in found:
  600. yield obj
  601. found.add(id)
  602. def get_usr_assets():
  603. return (
  604. usr_rows
  605. + (tuple(asset for asset in query if asset) if query else ())
  606. + usr_columns)
  607. def get_portfolio_assets():
  608. if cfg_in.portfolio and gcfg.portfolio:
  609. ret = (parse_asset_id(e) for e in cfg_in.portfolio)
  610. return tuple(e for e in ret if (not gcfg.btc) or e.symbol == 'BTC')
  611. else:
  612. return ()
  613. def get_portfolio():
  614. return tuple((k, Decimal(v)) for k, v in cfg_in.portfolio.items()
  615. if (not gcfg.btc) or k == 'btc-bitcoin')
  616. def parse_add_precision(arg):
  617. if not arg:
  618. return 0
  619. s = str(arg)
  620. if not (s.isdigit() and s.isascii()):
  621. die(1, f'{s}: invalid parameter for --add-precision (not an integer)')
  622. if int(s) > 30:
  623. die(1, f'{s}: invalid parameter for --add-precision (value >30)')
  624. return int(s)
  625. def create_rows():
  626. rows = RowDict(
  627. {'trade_pair': query} if (query and query.to_asset) else
  628. {'bitcoin': [parse_asset_id('btc-bitcoin')]} if gcfg.btc else
  629. {k: tuple(parse_asset_id(e) for e in v) for k, v in cfg_in.cfg['assets'].items()})
  630. for hdr, data in (
  631. ('user_uniq', get_usr_assets()),
  632. ('portfolio_uniq', get_portfolio_assets()),
  633. ('pchg_unit_uniq', [pchg_unit] if pchg_unit else None)):
  634. if data:
  635. if uniq_data := tuple(gen_uniq(data, 'symbol', preload=rows)):
  636. rows[hdr] = uniq_data
  637. else:
  638. rows[hdr] = ()
  639. return rows
  640. def get_cfg_var(name):
  641. if name in gcfg._uopts:
  642. return getattr(gcfg, name)
  643. else:
  644. return getattr(gcfg, name) or cfg_in.cfg.get(name)
  645. def get_proxy(name):
  646. proxy = getattr(gcfg, name)
  647. return (
  648. '' if proxy == '' else 'none' if (proxy and proxy.lower() == 'none')
  649. else (proxy or cfg_in.cfg.get(name)))
  650. def get_sort_opt():
  651. match get_cfg_var('sort'):
  652. case None:
  653. return None
  654. case s if s in sort_params:
  655. return (s, True)
  656. case s if s in ['r' + ch for ch in sort_params]:
  657. return (s[1], False)
  658. case s:
  659. die(1,
  660. f'{s!r}: invalid parameter for --sort option (must be one of {fmt_list(sort_params)})'
  661. '\nTo reverse the sort, prefix the code letter with ‘r’')
  662. cfg_tuple = namedtuple('global_cfg',[
  663. 'rows',
  664. 'usr_rows',
  665. 'usr_columns',
  666. 'query',
  667. 'asset_range',
  668. 'adjust',
  669. 'clsname',
  670. 'btc_only',
  671. 'add_prec',
  672. 'cachedir',
  673. 'proxy',
  674. 'proxy2',
  675. 'portfolio',
  676. 'sort',
  677. 'percent_cols',
  678. 'pchg_unit',
  679. 'asset_limit',
  680. 'cached_data',
  681. 'elapsed',
  682. 'name_labels',
  683. 'pager',
  684. 'thousands_comma',
  685. 'update_time',
  686. 'quiet',
  687. 'verbose'])
  688. global gcfg, cfg_in, src_cls, cfg
  689. gcfg = gcfg_arg
  690. src_cls = {k: getattr(DataSource, v) for k, v in DataSource.get_sources().items()}
  691. fi_pat = src_cls['fi'].asset_id_pat
  692. cfg_in = get_cfg_in()
  693. if cmd_args := gcfg._args:
  694. if len(cmd_args) > 1:
  695. die(1, 'Only one command-line argument is allowed')
  696. asset_range = parse_asset_range(cmd_args[0])
  697. query = None if asset_range else parse_query_arg(cmd_args[0])
  698. else:
  699. asset_range = None
  700. query = None
  701. usr_rows = parse_usr_asset_arg('add_rows')
  702. usr_columns = parse_usr_asset_arg('add_columns', use_cf_file=True)
  703. proxy = get_proxy('proxy')
  704. proxy = None if proxy == 'none' else proxy
  705. proxy2 = get_proxy('proxy2')
  706. portfolio = (
  707. get_portfolio() if cfg_in.portfolio and get_cfg_var('portfolio') and not query
  708. else None)
  709. if portfolio and asset_range:
  710. die(1, '--portfolio not supported in market cap view')
  711. pchg_unit = (lambda s: parse_asset_id(s, require_label=False) if s else None)(
  712. get_cfg_var('pchg_unit'))
  713. cfg = cfg_tuple(
  714. rows = create_rows(),
  715. usr_rows = usr_rows,
  716. usr_columns = usr_columns,
  717. query = query,
  718. asset_range = asset_range,
  719. adjust = (lambda x: (100 + x) / 100 if x else 1)(Decimal(gcfg.adjust or 0)),
  720. clsname = 'trading' if query else 'overview',
  721. btc_only = get_cfg_var('btc'),
  722. add_prec = parse_add_precision(get_cfg_var('add_precision')),
  723. cachedir = get_cfg_var('cachedir') or dfl_cachedir,
  724. proxy = proxy,
  725. proxy2 = None if proxy2 == 'none' else '' if proxy2 == '' else (proxy2 or proxy),
  726. portfolio = portfolio,
  727. sort = get_sort_opt(),
  728. percent_cols = parse_percent_cols(get_cfg_var('percent_cols')),
  729. pchg_unit = pchg_unit,
  730. asset_limit = get_cfg_var('asset_limit'),
  731. cached_data = get_cfg_var('cached_data'),
  732. elapsed = get_cfg_var('elapsed'),
  733. name_labels = get_cfg_var('name_labels'),
  734. pager = get_cfg_var('pager'),
  735. thousands_comma = get_cfg_var('thousands_comma'),
  736. update_time = get_cfg_var('update_time'),
  737. quiet = get_cfg_var('quiet'),
  738. verbose = get_cfg_var('verbose'))
  739. return (src_cls, cfg_in)
  740. def get_cfg_in():
  741. ret = namedtuple('cfg_in_data', ['cfg', 'portfolio', 'cfg_file', 'portfolio_file'])
  742. cfg_file, portfolio_file = (
  743. [os.path.join(gcfg.data_dir_root, 'node_tools', fn)
  744. for fn in (cfg_fn, portfolio_fn)])
  745. cfg_data, portfolio_data = (
  746. [yaml.safe_load(open(fn).read()) if os.path.exists(fn) else None
  747. for fn in (cfg_file, portfolio_file)])
  748. return ret(
  749. cfg = cfg_data or {
  750. 'assets': {
  751. 'coin': [ 'btc-bitcoin', 'eth-ethereum', 'xmr-monero' ],
  752. # gold futures, silver futures, Brent futures
  753. 'commodity': [ 'gc=f', 'si=f', 'bz=f' ],
  754. # Pound Sterling, Euro, Swiss Franc
  755. 'fiat': [ 'gbpusd=x', 'eurusd=x', 'chfusd=x' ],
  756. # Dow Jones Industrials, Nasdaq 100, S&P 500
  757. 'index': [ '^dji', '^ixic', '^gspc' ]},
  758. 'proxy': 'http://vpn-gw:8118'},
  759. portfolio = portfolio_data,
  760. cfg_file = cfg_file,
  761. portfolio_file = portfolio_file)
  762. class Ticker:
  763. class base:
  764. offer = None
  765. to_asset = None
  766. hidden_groups = ('extra', 'pchg_unit_uniq')
  767. def __init__(self, data):
  768. global cfg
  769. self.comma = ',' if cfg.thousands_comma else ''
  770. self.col1_wid = max(len('TOTAL'), (
  771. max(len(self.create_label(d['id'])) for d in data.values()) if cfg.name_labels else
  772. max(len(d['symbol']) for d in data.values())))
  773. self.rows = RowDict(
  774. {k: tuple(row._replace(id=self.get_id(row)) for row in v) for k, v in cfg.rows.items()})
  775. if cfg.asset_range:
  776. self.max_rank = 0
  777. for group, rows in self.rows.items():
  778. if group not in self.hidden_groups:
  779. for row in rows:
  780. self.max_rank = max(self.max_rank, int(data[row.id]['rank']))
  781. if cfg.sort:
  782. code, reverse = cfg.sort
  783. key = sort_params[code].key
  784. sort_dfl = sort_params[code].sort_dfl
  785. sort_func = lambda row: data.get(row.id, {key: sort_dfl})[key]
  786. pf_sort_func = lambda row: data.get(row[0], {key: sort_dfl})[key]
  787. for group in self.rows.keys():
  788. if group not in self.hidden_groups:
  789. self.rows[group] = sorted(self.rows[group], key=sort_func, reverse=reverse)
  790. if cfg.portfolio:
  791. cfg = cfg._replace(
  792. portfolio = sorted(cfg.portfolio, key=pf_sort_func, reverse=reverse))
  793. if cfg.pchg_unit:
  794. self.pchg_data = self.data[self.get_id(cfg.pchg_unit)]
  795. self.pchg_factors = {k: (self.pchg_data[k] / 100) + 1 for k in (
  796. 'percent_change_24h',
  797. 'percent_change_7d',
  798. 'percent_change_30d',
  799. 'percent_change_1y')}
  800. self.col_usd_prices = {k: self.data[k]['price_usd'] for k in self.col_ids}
  801. self.prices = {row.id: self.get_row_prices(row.id) for row in self.rows if row.id in data}
  802. self.prices['usd-us-dollar'] = self.get_row_prices('usd-us-dollar')
  803. def format_last_updated_col(self, cross_assets=()):
  804. if cfg.elapsed:
  805. from mmgen.util2 import format_elapsed_hr
  806. fmt_func = format_elapsed_hr
  807. else:
  808. fmt_func = lambda t, now: time.strftime('%F %X', time.gmtime(t))
  809. d = self.data
  810. max_w = 0
  811. if cross_assets:
  812. last_updated_x = [d[a.id]['last_updated'] for a in cross_assets]
  813. min_t = min((int(n) for n in last_updated_x if isinstance(n, int)), default=None)
  814. else:
  815. min_t = None
  816. for row in self.rows:
  817. try:
  818. t = int(d[row.id]['last_updated'])
  819. except TypeError as e:
  820. d[row.id]['last_updated_fmt'] = gray('--' if 'NoneType' in str(e) else str(e))
  821. except KeyError as e:
  822. msg(str(e))
  823. pass
  824. else:
  825. t_fmt = d[row.id]['last_updated_fmt'] = fmt_func(
  826. (min(t, min_t) if min_t else t),
  827. now = now)
  828. max_w = max(len(t_fmt), max_w)
  829. self.upd_w = max_w
  830. def init_prec(self):
  831. exp = [(a.id, self.prices[a.id]['usd-us-dollar'].adjusted()) for a in self.usr_col_assets]
  832. self.uprec = {k: max(0, v+4) + cfg.add_prec for k, v in exp}
  833. self.uwid = {k: 12 + max(0, abs(v)-6) + cfg.add_prec for k, v in exp}
  834. def get_id(self, asset):
  835. if asset.id:
  836. return asset.id
  837. else:
  838. m = asset.symbol
  839. for d in self.data.values():
  840. if m == d['symbol']:
  841. return d['id']
  842. def create_label(self, id):
  843. return self.data[id]['name'].upper()
  844. def gen_output(self):
  845. def process_rows(rows):
  846. yield '-' * self.hl_wid
  847. for row in rows:
  848. try:
  849. yield self.fmt_row(self.data[row.id])
  850. except KeyError:
  851. yield gray(f'(no data for {row.id})')
  852. yield 'Current time: {}'.format(cyan(time.strftime('%F %X', time.gmtime(now)) + ' UTC'))
  853. if cfg.sort:
  854. text = sort_params[cfg.sort[0]].desc + ('' if cfg.sort[1] else ' [reversed]')
  855. yield f'Sort order: {pink(text.upper())}'
  856. if cfg.pchg_unit:
  857. yield 'Percent change unit: {}'.format(orange('{} ({})'.format(
  858. self.pchg_data['symbol'],
  859. self.pchg_data['name'].upper())))
  860. for asset in self.usr_col_assets:
  861. if asset.symbol != 'USD':
  862. usdprice = self.data[asset.id]['price_usd']
  863. yield '{} ({}) = {:{}.{}f} USD'.format(
  864. asset.symbol,
  865. self.create_label(asset.id),
  866. usdprice,
  867. self.comma,
  868. max(2, 4-usdprice.adjusted()))
  869. if hasattr(self, 'subhdr'):
  870. yield self.subhdr
  871. if self.show_adj:
  872. yield (
  873. ('Offered price differs from spot' if self.offer else 'Adjusting prices')
  874. + ' by '
  875. + yellow('{:+.2f}%'.format((self.adjust-1) * 100)))
  876. yield ''
  877. if cfg.portfolio:
  878. yield blue('PRICES')
  879. if self.table_hdr:
  880. yield self.table_hdr
  881. if cfg.asset_range:
  882. yield from process_rows(self.rows['asset_list'])
  883. else:
  884. for group, rows in self.rows.items():
  885. if rows and group not in self.hidden_groups:
  886. yield from process_rows(rows)
  887. yield '-' * self.hl_wid
  888. if cfg.portfolio:
  889. self.fs_num = self.fs_num2
  890. self.fs_str = self.fs_str2
  891. yield ''
  892. yield blue('PORTFOLIO')
  893. yield self.table_hdr
  894. yield '-' * self.hl_wid
  895. for sym, amt in cfg.portfolio:
  896. try:
  897. yield self.fmt_row(self.data[sym], amt=amt)
  898. except KeyError:
  899. yield gray(f'(no data for {sym})')
  900. yield '-' * self.hl_wid
  901. if not cfg.btc_only:
  902. yield self.fs_num.format(
  903. lbl = 'TOTAL', pc3='', pc4='', pc1='', pc2='', upd='', amt='',
  904. **{k.replace('-', '_'): v for k, v in self.prices['total'].items()})
  905. class overview(base):
  906. def __init__(self, data):
  907. self.data = data
  908. self.adjust = cfg.adjust
  909. self.show_adj = self.adjust != 1
  910. self.usr_col_assets = [asset._replace(id=self.get_id(asset)) for asset in cfg.usr_columns]
  911. self.col_ids = ('usd-us-dollar', 'btc-bitcoin') + tuple(a.id for a in self.usr_col_assets)
  912. super().__init__(data)
  913. self.format_last_updated_col()
  914. if cfg.portfolio:
  915. pf_dict = dict(cfg.portfolio)
  916. self.prices['total'] = {col_id: sum(self.prices[row.id][col_id] * pf_dict[row.id]
  917. for row in self.rows
  918. if row.id in pf_dict and row.id in data)
  919. for col_id in self.col_ids}
  920. self.init_prec()
  921. self.init_fs()
  922. def get_row_prices(self, id):
  923. if id in self.data:
  924. d = self.data[id]
  925. return {k: (
  926. d['price_btc'] if k == 'btc-bitcoin' else
  927. d['price_usd'] / self.col_usd_prices[k]
  928. ) * self.adjust for k in self.col_ids}
  929. def fmt_row(self, d, amt=None, amt_fmt=None):
  930. def fmt_pct(d, key, wid=7):
  931. if (n := d.get(key)) is None:
  932. return gray(' --')
  933. if cfg.pchg_unit:
  934. n = ((((n / 100) + 1) / self.pchg_factors[key]) - 1) * 100
  935. return (red, green)[n>=0](f'{n:+{wid}.2f}')
  936. p = self.prices[d['id']]
  937. if amt is not None:
  938. amt_fmt = f'{amt:{19+cfg.add_prec}{self.comma}.{8+cfg.add_prec}f}'
  939. if '.' in amt_fmt:
  940. amt_fmt = amt_fmt.rstrip('0').rstrip('.')
  941. return self.fs_num.format(
  942. idx = int(d['rank']) if cfg.asset_range else None,
  943. mcap = d.get('market_cap') / 1_000_000_000 if cfg.asset_range else None,
  944. lbl = self.create_label(d['id']) if cfg.name_labels else d['symbol'],
  945. pc1 = fmt_pct(d, 'percent_change_7d'),
  946. pc2 = fmt_pct(d, 'percent_change_24h'),
  947. pc3 = fmt_pct(d, 'percent_change_1y', wid=8),
  948. pc4 = fmt_pct(d, 'percent_change_30d'),
  949. upd = d.get('last_updated_fmt'),
  950. amt = amt_fmt,
  951. **{k.replace('-', '_'): v * (1 if amt is None else amt) for k, v in p.items()})
  952. def init_fs(self):
  953. col_prec = {'usd-us-dollar': 2+cfg.add_prec, 'btc-bitcoin': 8+cfg.add_prec} | self.uprec
  954. max_row = max(
  955. ((k, v['btc-bitcoin']) for k, v in self.prices.items()),
  956. key = lambda a: a[1])
  957. widths = {k: len('{:{}.{}f}'.format(self.prices[max_row[0]][k], self.comma, col_prec[k]))
  958. for k in self.col_ids}
  959. fd = namedtuple('format_str_data', ['fs_str', 'fs_num', 'wid'])
  960. col_fs_data = {
  961. 'label': fd(f'{{lbl:{self.col1_wid}}}', f'{{lbl:{self.col1_wid}}}', self.col1_wid),
  962. 'pct1y': fd(' {pc3:8}', ' {pc3:8}', 9),
  963. 'pct1m': fd(' {pc4:7}', ' {pc4:7}', 8),
  964. 'pct1w': fd(' {pc1:7}', ' {pc1:7}', 8),
  965. 'pct1d': fd(' {pc2:7}', ' {pc2:7}', 8),
  966. 'update_time': fd(' {upd}', ' {upd}',
  967. max((19 if cfg.portfolio else 0), self.upd_w) + 2),
  968. 'amt': fd(' {amt}', ' {amt}', 21)
  969. } | {k: fd(
  970. ' {{{}:>{}}}'.format(k.replace('-', '_'), widths[k]),
  971. ' {{{}:{}{}.{}f}}'.format(k.replace('-', '_'), widths[k], self.comma, col_prec[k]),
  972. widths[k] + 2
  973. ) for k in self.col_ids}
  974. cols = (
  975. ['label', 'usd-us-dollar']
  976. + [asset.id for asset in self.usr_col_assets]
  977. + [a for a, b in (
  978. ('btc-bitcoin', not cfg.btc_only),
  979. ('pct1y', 'y' in cfg.percent_cols),
  980. ('pct1m', 'm' in cfg.percent_cols),
  981. ('pct1w', 'w' in cfg.percent_cols),
  982. ('pct1d', 'd' in cfg.percent_cols),
  983. ('update_time', cfg.update_time))
  984. if b])
  985. if cfg.asset_range:
  986. num_w = len(str(self.max_rank))
  987. col_fs_data.update({
  988. 'idx': fd(' ' * (num_w + 2), f'{{idx:{num_w}}}) ', num_w + 2),
  989. 'mcap': fd('{mcap:>12}', '{mcap:12.5f}', 12)})
  990. cols = ['idx', 'label', 'mcap'] + cols[1:]
  991. cols2 = list(cols)
  992. if cfg.update_time:
  993. cols2.pop()
  994. cols2.append('amt')
  995. self.fs_str = ''.join(col_fs_data[c].fs_str for c in cols)
  996. self.fs_num = ''.join(col_fs_data[c].fs_num for c in cols)
  997. self.hl_wid = sum(col_fs_data[c].wid for c in cols)
  998. self.fs_str2 = ''.join(col_fs_data[c].fs_str for c in cols2)
  999. self.fs_num2 = ''.join(col_fs_data[c].fs_num for c in cols2)
  1000. self.hl_wid2 = sum(col_fs_data[c].wid for c in cols2)
  1001. @property
  1002. def table_hdr(self):
  1003. return self.fs_str.format(
  1004. lbl = '',
  1005. mcap = 'MarketCap(B)',
  1006. pc1 = ' CHG_7d',
  1007. pc2 = 'CHG_24h',
  1008. pc3 = ' CHG_1y',
  1009. pc4 = 'CHG_30d',
  1010. upd = 'UPDATED',
  1011. amt = ' AMOUNT',
  1012. usd_us_dollar = 'USD',
  1013. btc_bitcoin = ' BTC',
  1014. **{a.id.replace('-', '_'): a.symbol for a in self.usr_col_assets})
  1015. class trading(base):
  1016. def __init__(self, data):
  1017. self.data = data
  1018. self.asset = cfg.query.asset._replace(id=self.get_id(cfg.query.asset))
  1019. self.to_asset = (
  1020. cfg.query.to_asset._replace(id=self.get_id(cfg.query.to_asset))
  1021. if cfg.query.to_asset else None)
  1022. self.col_ids = [self.asset.id]
  1023. self.adjust = cfg.adjust
  1024. if self.to_asset:
  1025. self.offer = self.to_asset.amount
  1026. if self.offer:
  1027. real_price = (
  1028. self.asset.amount
  1029. * data[self.asset.id]['price_usd']
  1030. / data[self.to_asset.id]['price_usd'])
  1031. if self.adjust != 1:
  1032. die(1,
  1033. 'the --adjust option may not be combined with TO_AMOUNT '
  1034. 'in the trade specifier')
  1035. self.adjust = self.offer / real_price
  1036. self.hl_ids = [self.asset.id, self.to_asset.id]
  1037. else:
  1038. self.hl_ids = [self.asset.id]
  1039. self.show_adj = self.adjust != 1 or self.offer
  1040. super().__init__(data)
  1041. self.usr_col_assets = [self.asset] + ([self.to_asset] if self.to_asset else [])
  1042. for a in self.usr_col_assets:
  1043. self.prices[a.id]['usd-us-dollar'] = data[a.id]['price_usd']
  1044. self.format_last_updated_col(cross_assets=self.usr_col_assets)
  1045. self.init_prec()
  1046. self.init_fs()
  1047. def get_row_prices(self, id):
  1048. if id in self.data:
  1049. d = self.data[id]
  1050. return {k: self.col_usd_prices[self.asset.id] / d['price_usd'] for k in self.col_ids}
  1051. def init_fs(self):
  1052. self.max_wid = max(
  1053. len('{:{}{}.{}f}'.format(
  1054. v[self.asset.id] * self.asset.amount,
  1055. 16 + cfg.add_prec,
  1056. self.comma,
  1057. 8 + cfg.add_prec))
  1058. for v in self.prices.values())
  1059. self.fs_str = '{lbl:%s} {p_spot}' % self.col1_wid
  1060. self.hl_wid = self.col1_wid + self.max_wid + 1
  1061. if self.show_adj:
  1062. self.fs_str += ' {p_adj}'
  1063. self.hl_wid += self.max_wid + 1
  1064. if cfg.update_time:
  1065. self.fs_str += ' {upd}'
  1066. self.hl_wid += self.upd_w + 2
  1067. def fmt_row(self, d):
  1068. id = d['id']
  1069. p = self.prices[id][self.asset.id] * self.asset.amount
  1070. p_spot = '{:{}{}.{}f}'.format(p, self.max_wid, self.comma, 8+cfg.add_prec)
  1071. p_adj = (
  1072. '{:{}{}.{}f}'.format(p*self.adjust, self.max_wid, self.comma, 8+cfg.add_prec)
  1073. if self.show_adj else '')
  1074. return self.fs_str.format(
  1075. lbl = self.create_label(id) if cfg.name_labels else d['symbol'],
  1076. p_spot = green(p_spot) if id in self.hl_ids else p_spot,
  1077. p_adj = yellow(p_adj) if id in self.hl_ids else p_adj,
  1078. upd = d.get('last_updated_fmt'))
  1079. @property
  1080. def table_hdr(self):
  1081. return self.fs_str.format(
  1082. lbl = '',
  1083. p_spot = '{t:>{w}}'.format(
  1084. t = 'SPOT PRICE',
  1085. w = self.max_wid),
  1086. p_adj = '{t:>{w}}'.format(
  1087. t = ('OFFERED' if self.offer else 'ADJUSTED') + ' PRICE',
  1088. w = self.max_wid),
  1089. upd = 'UPDATED')
  1090. @property
  1091. def subhdr(self):
  1092. return (
  1093. '{a}: {b:{c}} {d}'.format(
  1094. a = 'Offer' if self.offer else 'Amount',
  1095. b = self.asset.amount,
  1096. c = self.comma,
  1097. d = self.asset.symbol
  1098. ) + (
  1099. (
  1100. ' =>' +
  1101. (' {:{}}'.format(self.offer, self.comma) if self.offer else '') +
  1102. ' {} ({})'.format(
  1103. self.to_asset.symbol,
  1104. self.create_label(self.to_asset.id))
  1105. ) if self.to_asset else ''))