Ticker.py 30 KB

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