Ticker.py 34 KB

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