Ticker.py 34 KB

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