Ticker.py 32 KB

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