btc-ticker 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  1. #!/usr/bin/python
  2. # -*- coding: UTF-8 -*-
  3. #
  4. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  5. # Copyright (C)2013-2016 Philemon <mmgen-py@yandex.com>
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. #
  20. """
  21. btc-ticker: ticker and alarm clock for mmgen-node-tools
  22. """
  23. import sys,os,time,subprocess
  24. import threading as th
  25. from collections import OrderedDict
  26. from decimal import Decimal
  27. from mmgen.share import Opts
  28. from mmgen.util import msg,msg_r,die,die_pause
  29. from mmgen.node_tools.Global import *
  30. from mmgen.node_tools.Util import *
  31. from mmgen.node_tools.Sound import *
  32. from mmgen.node_tools.Term import *
  33. die_pause(1,'Ticker temporarily disabled') # DEBUG
  34. from mmgen.color import *
  35. init_color()
  36. quit = False
  37. sound_vol = float(100)
  38. audio_host,repeat_spec = '','3s:5m,1m:30m,5m:1h,30m:1d'
  39. # repeat_spec = '1:5m' # DEBUG
  40. valid_loglevels = OrderedDict([(1,'info'),(2,'data'),(3,'debug')])
  41. logfile = 'ticker_log.txt'
  42. alrm_clock = 'off'
  43. num_digits = 6
  44. proxy = None
  45. fx_host= 'https://finance.yahoo.com'
  46. sounds_dir = nt.system_data_dir+'/audio'
  47. sounds = {
  48. 'lo': [sounds_dir+'/ringtone.wav', 95],
  49. 'hi': [sounds_dir+'/Positive.wav', 102],
  50. 'conn_err': [sounds_dir+'/Rhodes.wav', 105],
  51. 'alrm_clock': [sounds_dir+'/Counterpoint.wav', 105],
  52. }
  53. alrm_names = tuple(sounds.keys())
  54. dlock,llock,alock = th.Lock(),th.Lock(),th.Lock()
  55. data_dir = nt.data_dir+'/lib/ticker/'
  56. try: os.makedirs(data_dir)
  57. except: pass
  58. def update_fx(a,b,debug=False):
  59. if debug:
  60. with open('/tmp/ticker.out') as f: text = f.read()
  61. else:
  62. text = get_url('{}/q?s={}{}'.format(fx_host,a,b),gzip_ok=True,proxy=proxy,debug=debug_curl)
  63. if not text: return False
  64. import re
  65. ret = re.split('yfs_l10_{}{}=.+?>'.format(a,b),text,maxsplit=1)[1].split('<',1)[0][:10]
  66. try:
  67. globals()[a+b] = float(ret)
  68. with open(data_dir+'/{}{}.txt'.format(a,b),'wb') as f: f.write(ret+'\n')
  69. return True
  70. except:
  71. return False
  72. def get_fx(a,b):
  73. try:
  74. with open(data_dir+'/{}{}.txt'.format(a,b)) as f:
  75. d = f.read().rstrip()
  76. globals()[a+b] = float(d)
  77. return True
  78. except:
  79. return update_fx(a,b)
  80. with open('/etc/timezone') as f:
  81. os.environ['TZ'] = f.read().rstrip()
  82. class Xch(object): pass
  83. class Src(object): pass
  84. num_xchgs = 2
  85. xchgs = tuple([Xch() for i in range(num_xchgs)])
  86. sources = OrderedDict([('tc','ticker')])
  87. for g in xchgs:
  88. for k in sources:
  89. setattr(g,k,Src())
  90. setattr(getattr(g,k),'desc',sources[k])
  91. for g in [xchgs[0]]:
  92. g.cur = 'USD'
  93. # g.Desc = 'OKCoin USD'
  94. # g.desc = 'okcoin_usd'
  95. # g.desc_short = 'okc'
  96. # g.tc.url = 'https://www.okcoin.com/api/v1/ticker.do?symbol=btc_usd'
  97. g.Desc = 'Gemini'
  98. g.desc = 'gemini'
  99. g.desc_short = 'gem'
  100. g.tc.url = 'https://api.gemini.com/v1/pubticker/btcusd'
  101. g.poll_secs = 60
  102. g.cur_sign = '$'
  103. g.xcur_sign = '¥'
  104. g.fiat_precision = 2
  105. g.hi_alrm = 999999
  106. g.lo_alrm = 1
  107. g.cc_unit = 'BTC'
  108. for g in [xchgs[1]]:
  109. g.cur = 'CNY'
  110. g.Desc = 'OKCoin CNY'
  111. g.desc = 'okcoin_cny'
  112. g.desc_short = 'okc'
  113. g.tc.url = 'https://www.okcoin.cn/api/v1/ticker.do?symbol=btc_cny'
  114. g.poll_secs = 60
  115. g.cur_sign = '¥'
  116. g.xcur_sign = '$'
  117. g.fiat_precision = 1
  118. g.hi_alrm = 999999
  119. g.lo_alrm = 1
  120. g.cc_unit = 'BTC'
  121. # g.cur = 'USD'
  122. # g.Desc = 'BitFinex'
  123. # g.desc = 'bitfinex'
  124. # g.desc_short = 'bfx'
  125. # g.tc.url = 'https://api.bitfinex.com/v1/pubticker/btcusd'
  126. # Gemini - available symbols: btcusd ethbtc ethusd
  127. # g.cur = 'USD'
  128. # g.Desc = 'Gemini'
  129. # g.desc = 'gemini'
  130. # g.desc_short = 'gem'
  131. # g.tc.url = 'https://api.gemini.com/v1/pubticker/btcusd'
  132. opts_data = {
  133. 'prog_name': sys.argv[0].split('/')[-1],
  134. 'desc': 'Price alarm for Bitcoin exchange',
  135. 'usage': '[opts] [<low price alarm> <high price alarm>]',
  136. #-b, --big-number Print big numbers
  137. 'options': """
  138. -h, --help Print this help message
  139. -a, --alarm-clock-only Disable ticker, use as alarm clock only
  140. -c, --cur-ticker-only Display only current exchange's ticker price
  141. -d, --debug Debug mode. Use saved HTTP data from files
  142. -D, --debug-curl Debug curl
  143. -l, --log= l Log all data to file '{lf}' at levels 'l' (comma-separated: {lls})
  144. -n, --num-digits= n Display 'n' number of big digits
  145. -p, --poll-intervals=i1[,i2] Poll servers every 'i' seconds (default: '{pi}')
  146. -P, --proxy= x Connect via proxy 'x' (see PROXY EXAMPLES below for format)
  147. -r, --repeat-spec Sleep interval/duration program for the alarm
  148. (default: '{r}')
  149. (see REPEAT SPEC FORMAT below)
  150. -R, --resize-window Resize window to optimum dimensions
  151. -t, --testing Testing mode. Don't execute shell commands
  152. -u, --utc Show times in UTC rather than local time
  153. -v, --volume= n Adjust sound volume by percentage 'n' (default: {v})
  154. -V, --test-volume Test the alarm volume and exit
  155. -x, --xchgs= x,y[,…] Display only exchanges 'x,y[,…]' (comma-separated
  156. list of integers, see CONFIGURED EXCHANGES below)
  157. """.format(
  158. v=int(sound_vol),
  159. pi = ','.join([str(g.poll_secs) for g in xchgs]),
  160. r=repeat_spec,
  161. lf=logfile,
  162. lls=', '.join(['%s=%s'%(i,j) for i,j in valid_loglevels.items()])
  163. ),
  164. 'notes': '''
  165. REPEAT SPEC FORMAT:
  166. <interval>:<duration>[,<interval>:<duration>[,…]]
  167. For example, '3s:5m,1m:2h,30m:1d' means:
  168. ring alarm every 3 seconds for 5 minutes,
  169. then every minute for 2 hours,
  170. then every 30 minutes for 1 day
  171. PROXY EXAMPLES:
  172. socks5h://localhost:9050 (for Tor running on localhost)
  173. http://192.168.0.4:8118 (for Privoxy running on host 192.168.0.4)
  174. CONFIGURED EXCHANGES:
  175. {x}
  176. '''.format(
  177. x='\n '.join(['%s - %s'%(n,x.Desc) for n,x in enumerate(xchgs,1)])
  178. )
  179. }
  180. opts,args = Opts.parse_opts(sys.argv,opts_data)[:2]
  181. debug_curl = 'debug_curl' in opts
  182. proxy = opts['proxy'] if 'proxy' in opts else None
  183. # Global var: the currently displayed exchange
  184. if 'alarm_clock_only' in opts:
  185. num_digits = 4
  186. mute_alrms = False
  187. ga = None
  188. xchgs = []
  189. else:
  190. mute_alrms = True
  191. ga = xchgs[0]
  192. fx_pairs = [('usd','cny')]
  193. for a,b in fx_pairs:
  194. msg_r('Getting {}/{} exchange rate...'.format(a.upper(),b.upper()))
  195. if get_fx(a,b):
  196. msg('OK')
  197. else:
  198. die_pause(1,'Unable to get {}/{} exchange rate'.format(a.upper(),b.upper()))
  199. if 'xchgs' in opts:
  200. usr_xchgs = [int(i)-1 for i in opts['xchgs'].split(',')]
  201. xchgs = [xchgs[i] for i in usr_xchgs]
  202. # Initialize some variables for active exchanges
  203. for g in xchgs:
  204. g.retry_interval = 30
  205. g.conn_alrm_timeout = 300
  206. g.tc.bal = 0.0,0.0,0.0
  207. if not hasattr(g,'desc_short'): g.desc_short = g.desc
  208. for k in sources:
  209. s = getattr(g,k)
  210. main_thrd_names = tuple([x.desc+'_ticker' for x in xchgs])
  211. kill_flgs = dict([(k,th.Event()) for k in main_thrd_names + ('alrm','clock','log')])
  212. debug = 'debug' in opts
  213. if debug: msg('Debugging mode. Using saved data from files')
  214. def do_errmsg(s,k='ticker'):
  215. if k == 'ticker':
  216. with dlock:
  217. blank_ticker()
  218. msg_r(CUR_HOME)
  219. msgred_r(s)
  220. time.sleep(5)
  221. def toggle_option(k):
  222. with dlock:
  223. if k in opts: del opts[k]
  224. else: opts[k] = True
  225. def get_thrd_names(): return [i.name for i in th.enumerate()]
  226. def start_alrm(name):
  227. kill_flgs['alrm'].clear()
  228. t = th.Thread(
  229. target = play_sound,
  230. name = name,
  231. kwargs = {
  232. 'fn': sounds[name][0], # (fn,vol)
  233. 'repeat_spec': repeat_spec,
  234. 'remote_host': audio_host,
  235. 'vol': sound_vol * sounds[name][1] / 100,
  236. 'kill_flg': kill_flgs['alrm'],
  237. 'testing': False
  238. }
  239. )
  240. t.daemon = True
  241. t.start()
  242. def start_alrm_maybe(my_thrd_name,kill_list=None):
  243. if mute_alrms: return
  244. # Allow for an empty kill list
  245. klist = kill_list if kill_list != None else alrm_names
  246. with alock: # kill alarm thrds except mine
  247. if any([n in klist and n != my_thrd_name for n in get_thrd_names()]):
  248. kill_flgs['alrm'].set()
  249. if not my_thrd_name: return # if thread name 'None', kill first, then return
  250. if not any([n in alrm_names for n in get_thrd_names()]):
  251. start_alrm(my_thrd_name)
  252. def parse_loglevel_arg(s):
  253. m1 = 'Invalid loglevel argument: %s\n' % s
  254. try: ret = [int(i) for i in s.split(',')]
  255. except:
  256. m2 = 'Loglevels must be comma-separated int values'
  257. return False, m1+m2
  258. if not set(ret) <= set(valid_loglevels):
  259. m2 = 'Valid loglevels: %s' % ','.join([str(i) for i in valid_loglevels])
  260. return False, m1+m2
  261. return ret,'OK'
  262. def set_alrm_vals(s):
  263. ret = []
  264. m1 = 'Invalid alarm argument: %s\n' % s
  265. for e in s.split(':'):
  266. try: ret.append([Decimal(i) for i in e.split(',')])
  267. except:
  268. m2 = 'Alarms must be comma-separated decimal values'
  269. return False, m1+m2
  270. if len(ret[-1]) != 2:
  271. m2 = 'Each element of alarm list must have 2 items (lo_alrm,hi_alrm)'
  272. return False, m1+m2
  273. if len(ret) != len(xchgs):
  274. m2 = 'Alarm list must be %s colon-separated comma-separated lists' % len(xchgs)
  275. return False, m1+m2
  276. for g,a in zip(xchgs,ret):
  277. lo,hi = a
  278. if lo > hi:
  279. m2 = 'Low alarm (%s) is greater than high alarm (%s)' % (lo,hi)
  280. return False, m1+m2
  281. setattr(g,'lo_alrm',lo)
  282. setattr(g,'hi_alrm',hi)
  283. return ret,'OK'
  284. def set_poll_intervals(s,xchg=None):
  285. ret = []
  286. m1 = 'Invalid poll interval argument: %s\n' % s
  287. m2 = 'Poll intervals must be comma-separated integer values'
  288. m3 = 'Poll interval must be integer value'
  289. for e in (s.split(','),[s])[bool(xchg)]:
  290. try: ret.append(float(e))
  291. except:
  292. return False, m1+(m2,m3)[bool(xchg)]
  293. if not xchg and len(ret) != len(xchgs):
  294. m2 = 'Poll interval list have %s items' % len(xchgs)
  295. return False, m1+m2
  296. for g,p in zip((xchgs,[xchg])[bool(xchg)],ret):
  297. setattr(g,'poll_secs',float(p))
  298. return ret,'OK'
  299. def set_xch_param(source,param,s,desc):
  300. ret = []
  301. m1 = 'Invalid %s argument: %s\n' % (desc,s)
  302. for e in s.split(':'):
  303. try: ret.append(float(e))
  304. except:
  305. m2 = '%s arg must be colon-separated float values' % desc.capitalize()
  306. return False, m1+m2
  307. if len(ret) != len(xchgs):
  308. m2 = '%s list must have %s colon-separated items' % (
  309. desc.capitalize(),len(xchgs))
  310. return False, m1+m2
  311. for g,p in zip(xchgs,ret):
  312. a = getattr(g,source)
  313. setattr(a,param,float(p))
  314. return ret,'OK'
  315. if 'log' in opts:
  316. loglevels,errmsg = parse_loglevel_arg(opts['log'])
  317. if not loglevels: die_pause(1,errmsg)
  318. msg('Logging at level{} {}'.format(
  319. ('s','')[len(loglevels)==1],
  320. ' '.join([valid_loglevels[i].upper() for i in loglevels])))
  321. else:
  322. loglevels = []
  323. if 'repeat_spec' in opts:
  324. repeat_spec = opts['repeat_spec']
  325. msg("Using program '{}' for alarm".format(repeat_spec))
  326. if 'num_digits' in opts:
  327. n = opts['num_digits']
  328. if len(n) != 1 or n not in '56789':
  329. die_pause(1,"'%s': invalid value for --num-digits option" % n)
  330. num_digits = int(n)
  331. if 'volume' in opts:
  332. sound_vol = int(opts['volume'])
  333. for k in sounds:
  334. sounds[k][1] = float(sounds[k][1] * sound_vol / 100)
  335. msg('Adjusting sound volume by {}%'.format(sound_vol))
  336. if 'test_volume' in opts:
  337. for k in sounds:
  338. msg("Playing '{}' ({})".format(k,sounds[k][0]))
  339. play_sound(
  340. fn=sounds[k][0],
  341. vol=sound_vol * sounds[k][1] / 100,
  342. testing='testing' in opts
  343. )
  344. sys.exit()
  345. if len(args) > 1: Opts.usage(opts_data)
  346. if len(args) == 1:
  347. ret,errmsg = set_alrm_vals(args[0])
  348. if not ret: die_pause(1,'Error: ' + errmsg)
  349. msg('Setting alarms to %s' % ', '.join(['{} {}'.format(i,j) for i,j in ret]))
  350. if 'poll_intervals' in opts:
  351. ret,errmsg = set_poll_intervals(opts['poll_intervals'])
  352. if not ret: die_pause(1,errmsg)
  353. msg('Polling every %s seconds' % ret)
  354. tmux = 'TMUX' in os.environ
  355. if tmux:
  356. subprocess.check_output(['tmux','set','set-titles','on'])
  357. subprocess.check_output(['tmux','set','status','off'])
  358. # dcmd = "date -R%s | cut -d' ' -f2-5" % ('','u')['utc' in opts]
  359. # subprocess.check_output(['tmux','set','status-right','#(%s)' % dcmd])
  360. infoW = 15
  361. bigDigitsW = big_digits['w']*num_digits + big_digits['pw']*1
  362. lPaneW = bigDigitsW+infoW+1
  363. topPaneH,rPaneW = 6,23
  364. def CUR_UP(n): return '\033[%sA' % n
  365. def CUR_DOWN(n): return '\033[%sB' % n
  366. def CUR_RIGHT(n): return '\033[%sC' % n
  367. def CUR_LEFT(n): return '\033[%sD' % n
  368. def WIN_TITLE(s): return '\033]0;%s\033\\' % s
  369. def WIN_RESIZE(w,h): return '\033[8;%s;%st' % (h,w)
  370. def WIN_CORNER(): return '\033[3;200;0t'
  371. CUR_UP1 = '\033[A'
  372. CUR_DN1 = '\033[B'
  373. CUR_SHOW = '\033[?25h'
  374. CUR_HIDE = '\033[?25l'
  375. BLINK = '\033[5m'
  376. RESET = '\033[0m'
  377. CUR_HOME = '\033[H'
  378. ERASE_ALL = '\033[0J'
  379. def draw_rectangle(s,l,h):
  380. msg_r(s + '\n'.join([l] * h))
  381. def blank_ticker():
  382. draw_rectangle(CUR_HOME,' ' * lPaneW,topPaneH)
  383. def blank_big_digits():
  384. draw_rectangle(CUR_HOME,' ' * (bigDigitsW+1),topPaneH)
  385. def park_cursor():
  386. msg_r('\r' + CUR_RIGHT(lPaneW-1))
  387. def colorize_bal(g,n):
  388. d = g.tc
  389. fs = '{:.%sf}' % g.fiat_precision
  390. return (nocolor,green,red)[
  391. (bool(d.save_bal[n]) and
  392. (d.bal[n]!=d.save_bal[n]) + (d.bal[n]<d.save_bal[n]))
  393. ](fs.format(d.bal[n]))
  394. def display_ac_info():
  395. ac_fmt = green('--:--') if alrm_clock == 'off' else yelbg(alrm_clock)
  396. info_lines = (
  397. '{}'.format(ac_fmt),
  398. 'snd vol: {}%'.format(int(sound_vol)),
  399. '{}'.format((yellow('unmuted'),'muted')[mute_alrms]),
  400. '{}'.format(blue(audio_host[:infoW] or 'localhost')),
  401. '',
  402. "'?' for help"
  403. )
  404. r = CUR_RIGHT(bigDigitsW+1)
  405. msg_r(CUR_HOME+r+('\n'+r).join(info_lines))
  406. park_cursor()
  407. ts = 'Alarm Clock: {}{}'.format(alrm_clock.upper(),('',' (m)')[mute_alrms])
  408. if tmux:
  409. subprocess.check_output(['tmux','set','set-titles-string',ts])
  410. else:
  411. msg_r(WIN_TITLE(ts))
  412. def display_ticker(g,called_by_clock=False):
  413. if not g: return
  414. d = g.tc
  415. log(3,'display_ticker(): ' + repr(d.__dict__))
  416. if not hasattr(d,'timestamp'): return # DEBUG
  417. avg = sum((d.bal[i] or d.save_bal[i]) for i in range(3)) / 3
  418. alrm,lb,hb,rst = (
  419. (None,'','',''),
  420. ('lo',BLINK,'',RESET),
  421. ('hi','',BLINK,RESET)
  422. )[(avg<g.lo_alrm)+(avg>g.hi_alrm)*2]
  423. start_alrm_maybe(alrm,['lo','hi'])
  424. if (g is not ga) and not called_by_clock: return
  425. lfmt = '{n:.{p}f}'.format(p=g.fiat_precision,n=d.bal[2])
  426. msg_r(CUR_HOME+lb+hb+display_big_digits(lfmt,pre=' '+rst))
  427. if (g is not ga) and called_by_clock:
  428. park_cursor()
  429. return
  430. hms = get_hms(d.timestamp,utc='utc' in opts)
  431. xcur = '{:.2f}'.format(d.bal[2]/usdcny if g.cur == 'CNY' else d.bal[2]*usdcny)
  432. ac_fmt = green('--:--') if alrm_clock == 'off' else yelbg(alrm_clock)
  433. info_lines = (
  434. '{} {}'.format(hms,('%ss'%int(g.poll_secs))),
  435. '{} bid/ask'.format(cyan(g.desc_short)),
  436. '{} {}'.format(colorize_bal(g,0), colorize_bal(g,1)),
  437. '{g.xcur_sign}{x} {h}'.format(g=g,x=xcur,
  438. h=('',blue(audio_host[:infoW-len(xcur)-2]))[bool(audio_host)]),
  439. '{} {} {}'.format(yellow(('♫','-')[mute_alrms]),
  440. lb+yellow(str(g.lo_alrm))+rst,
  441. hb+yellow(str(g.hi_alrm))+rst),
  442. '{} vol {}%'.format(ac_fmt,int(sound_vol))
  443. )
  444. r = CUR_RIGHT(bigDigitsW+1)
  445. msg_r(CUR_HOME+r+('\n'+r).join(info_lines))
  446. park_cursor()
  447. ccs = ('฿','Ł')[g.cc_unit=='LTC']
  448. if len(xchgs) == 2:
  449. x1,x2 = xchgs
  450. ts = '{}{:.{}f} / {}{:.{}f}'.format(
  451. x1.cur_sign,
  452. x1.tc.bal[2],
  453. x1.fiat_precision,
  454. x2.cur_sign,
  455. x2.tc.bal[2],
  456. x2.fiat_precision,
  457. )
  458. else:
  459. ts = '{}: {}{:.{}f} ({})'.format(
  460. ccs,
  461. g.cur_sign,
  462. d.bal[2],
  463. g.fiat_precision,
  464. g.Desc
  465. )
  466. if tmux:
  467. # subprocess.call(['tmux','rename-session',g.Desc], stderr=subprocess.PIPE)
  468. subprocess.check_output(['tmux','set','set-titles-string',ts])
  469. else:
  470. msg_r(WIN_TITLE(ts))
  471. def log(*args): # [lvl,g,src,data] OR [lvl,data]
  472. if 'log' in opts and args[0] in loglevels + [0]:
  473. if len(args) == 2:
  474. s = '{}: {}\n'.format(get_day_hms(),args[1])
  475. elif len(args) == 4:
  476. s = '{}: {} {} - {}\n'.format(
  477. get_day_hms(),
  478. args[1].desc_short.upper(),
  479. args[2].upper(),
  480. args[3]
  481. )
  482. with llock:
  483. fd = os.open(logfile,os.O_RDWR|os.O_APPEND|os.O_CREAT|os.O_SYNC)
  484. os.write(fd,s)
  485. os.close(fd)
  486. def get_market_data(g,d,connfail_msg='full'):
  487. # , post_data={}
  488. tcd = 'TRADING_CONSOLE_DEBUG_CONNFAIL'
  489. debug_connfail = tcd in os.environ and os.environ[tcd]
  490. null = None # hack for eval'ing Huobi trades data
  491. if debug:
  492. fn = 'debug_market_data/%s_ticker.json' % g.desc
  493. try:
  494. with open(fn) as f: text = f.read()
  495. log(3,'get_market_data(): {} {}'.format(g.desc,text))
  496. return eval(text)
  497. except:
  498. die(2,'Unable to open datafile %s' % fn)
  499. fail_count,conn_begin_time = 0,time.time()
  500. if debug_connfail:
  501. retry_interval,conn_alrm_timeout = 10,0
  502. else:
  503. retry_interval,conn_alrm_timeout = g.retry_interval,g.conn_alrm_timeout
  504. while True:
  505. try:
  506. text = get_url(d.url,proxy=proxy,debug=debug_curl)
  507. log(2,g,d.desc,text)
  508. return eval(text)
  509. except KeyboardInterrupt:
  510. die(1,'\nUser interrupt (get_market_data)\n')
  511. except EOFError:
  512. die(1,'\nEnd of file\n')
  513. except Exception as e:
  514. fail_count += 1
  515. if connfail_msg:
  516. with dlock:
  517. if g is ga:
  518. m = {
  519. 'short':'Connect fail ({})'.format(fail_count),
  520. 'full': 'Connect fail. Retry in {} seconds ({})'.format(
  521. retry_interval,fail_count)
  522. }
  523. dn = CUR_DOWN(topPaneH-1)
  524. blank_big_digits()
  525. msg_r(CUR_HOME+dn+m[connfail_msg]+' \b')
  526. k = '%s_%s' % (g.desc,d.desc)
  527. if time.time() - conn_begin_time > conn_alrm_timeout:
  528. if fail_count == 1:
  529. log(1,'Connect error (%s)' % k)
  530. if d.desc == 'ticker':
  531. with dlock: d.bal = d.bal[:2] + (0.0,) # can't assign to tuple, so this
  532. start_alrm_maybe('conn_err')
  533. # Sleep until user interrupt
  534. if kill_flgs[k].wait(retry_interval):
  535. kill_flgs[k].clear()
  536. return False
  537. def killwait(g,d):
  538. log(3,g,d.desc,'Begin wait')
  539. k = '%s_%s' % (g.desc,d.desc)
  540. if kill_flgs[k].wait(g.poll_secs):
  541. kill_flgs[k].clear()
  542. if quit: return True
  543. log(3,g,d.desc,'End wait')
  544. return False
  545. def log_loop():
  546. while True:
  547. for g in xchgs:
  548. with dlock:
  549. lstr = '{} last: {}{}'.format(g.desc_short.upper(),g.cur_sign,g.tc.bal[2])
  550. log(1,lstr)
  551. if kill_flgs['log'].wait(30):
  552. kill_flgs['log'].clear()
  553. if quit: return True
  554. def ticker_loop(g):
  555. d = g.tc
  556. while True:
  557. ret = get_market_data(g,d)
  558. log(3,'get_market_data() returned: {}'.format(repr(ret)))
  559. if not ret: # kill flag was set
  560. if quit: break
  561. continue
  562. errmsg = 'ticker_loop: HTTP returned bad data'
  563. with dlock:
  564. millisec = False
  565. if g.desc == 'bitfinex':
  566. a = ret
  567. bal = 'bid','ask','last_price'
  568. ts = ret['timestamp']
  569. elif g.desc == 'gemini':
  570. a = ret
  571. bal = 'bid','ask','last'
  572. try:
  573. ts = ret['volume']['timestamp']
  574. except:
  575. log(1,errmsg); continue
  576. millisec = True
  577. elif g.desc in ('okcoin_usd','okcoin_cny','huobi'):
  578. try:
  579. a = ret['ticker']
  580. except:
  581. log(1,errmsg); continue
  582. bal = 'buy','sell','last'
  583. ts = ret[('time','date')[g.desc[:6]=='okcoin']]
  584. else:
  585. die(1,"Can't handle symbol '{}'".format(g.desc))
  586. # okcoin.cn CNY: {"date":"1477932232","ticker":{"buy":"4844.13","high":"4873.36","last":"4844.16","low":"4660.0","sell":"4844.14","vol":"2992115.73393084"}}
  587. # gemini: {"bid":"1025.64","ask":"1026.93","volume":{"BTC":"1710.8752181914","USD":"1734356.065049020336","timestamp":1486377600000},"last":"1026.93"}
  588. # okcoin.com USD {"date":"1486581152","ticker":{"buy":"1057.59","high":"1070.0","last":"1059.63","low":"1002.51","sell":"1058.34","vol":"4118.856"}}
  589. try:
  590. d.timestamp = int(float(ts))
  591. except:
  592. log(1,errmsg); continue
  593. if millisec: d.timestamp /= 1000
  594. d.save_bal = d.bal
  595. try:
  596. d.bal = tuple([float(a[k]) for k in bal])
  597. except:
  598. log(1,errmsg); continue
  599. log(3,'{}: timestamp {}, bal {}'.format(g.desc,d.timestamp,d.bal))
  600. if killwait(g,d): break
  601. def clock_loop():
  602. ac_active = False
  603. ac_prefix=(' ','')['alarm_clock_only' in opts]
  604. def do_ticker(x):
  605. with dlock:
  606. blank_big_digits()
  607. display_ticker(x,called_by_clock=True)
  608. if kill_flgs['clock'].wait(2): sys.exit()
  609. while True:
  610. if 'alarm_clock_only' in opts:
  611. with dlock:
  612. blank_ticker()
  613. display_ac_info()
  614. elif not ac_active:
  615. if 'cur_ticker_only' in opts:
  616. do_ticker(ga)
  617. else:
  618. for x in xchgs: do_ticker(x)
  619. if alrm_clock == 'off' and ac_active:
  620. ac_active = False
  621. if alrm_clock != 'off' or 'alarm_clock_only' in opts:
  622. now = get_hms(no_secs=True)
  623. if now == alrm_clock:
  624. ac_active = True
  625. start_alrm_maybe('alrm_clock',['alrm_clock'])
  626. if not any([(n in alrm_names and n != 'alrm_clock') for n in get_thrd_names()]):
  627. bl,rs = (('',''),(BLINK,RESET))[ac_active]
  628. with dlock:
  629. blank_big_digits()
  630. msg_r(CUR_HOME+bl+display_big_digits(now,pre=ac_prefix)+rs)
  631. park_cursor()
  632. if kill_flgs['clock'].wait(2): return
  633. def input_loop():
  634. global ga,sound_vol,alrm_clock,quit,mute_alrms
  635. from mmgen.node_tools.Term import get_keypress
  636. if 'alarm_clock_only' not in opts:
  637. msg("Type '?' for help after the ticker starts")
  638. ch = get_keypress("Hit 'q' to quit, any other key to continue: ")
  639. if ch == 'q': die(0,'')
  640. if 'resize_window' in opts:
  641. if tmux:
  642. msg('\nWARNING: Window resizing doesn\'t work in tmux')
  643. else:
  644. msg_r(WIN_RESIZE(lPaneW,topPaneH))
  645. msg_r(WIN_CORNER())
  646. time.sleep(0.1)
  647. from mmgen.term import set_terminal_vars
  648. set_terminal_vars()
  649. from mmgen.term import get_terminal_size
  650. twid = get_terminal_size()[0]
  651. if twid < lPaneW:
  652. die_pause(1,'\nTerminal window must be at least %s characters wide' % lPaneW)
  653. msg_r(CUR_HIDE)
  654. # raw_input('\nPress any key to exit'); sys.exit() # DEBUG
  655. blank_ticker()
  656. for k in xchgs:
  657. th.Thread(target=globals()['ticker_loop'],args=[k],name=k.desc+'_ticker').start()
  658. th.Thread(target=clock_loop,name='clock').start()
  659. th.Thread(target=log_loop,name='log').start()
  660. time.sleep(1) # Hack for get_keypress()
  661. def redraw():
  662. blank_ticker()
  663. display_ticker(ga)
  664. help_texts = {
  665. 'tc': '''
  666. a - set alarm clock l - set low alarm
  667. A - set remote audio host m - mute alarms
  668. e - update USD/CNY rate M - write message to log
  669. G - set log levels p - set poll interval
  670. h - set high alarm t - reload ticker
  671. k - kill alarm v - set sound volume
  672. L - log current state x - cycle thru exchanges
  673. c - toggle display of current ticker only
  674. -/+ - adjust sound volume
  675. ''',
  676. 'ac': '''
  677. a - set alarm clock m - mute alarms
  678. A - set remote audio host k - kill alarm
  679. M - write message to log v - set sound volume
  680. -/+ - adjust sound volume
  681. '''
  682. }
  683. help_text = ['{:^{w}}'.format('KEYSTROKE COMMANDS',w=lPaneW)]
  684. help_text += help_texts[('tc','ac')['alarm_clock_only' in opts]].strip().split('\n')
  685. help_text_prompt = 'ESC or ENTER to exit, ↑ and ↓ to scroll'
  686. def do_help():
  687. scrollpos = 0
  688. def fmt_help_txt(pos):
  689. return '\n'.join(help_text[pos:pos+5])+'\n'+help_text_prompt+' '
  690. with dlock:
  691. while True:
  692. blank_ticker()
  693. ch = get_keypress(CUR_HOME+fmt_help_txt(scrollpos),esc_sequences=True)
  694. if ch == CUR_DN1 and scrollpos < len(help_text) - topPaneH + 1:
  695. scrollpos += 1
  696. elif ch == CUR_UP1 and scrollpos != 0:
  697. scrollpos -= 1
  698. elif ch in '\n\033': break
  699. blank_ticker()
  700. display_ticker(ga)
  701. def ks_msg_nolock(s,delay):
  702. blank_ticker()
  703. msg_r(CUR_HOME+s)
  704. time.sleep(delay)
  705. blank_ticker()
  706. display_ticker(ga)
  707. def ks_msg(s,delay):
  708. with dlock:
  709. ks_msg_nolock(s,delay)
  710. def set_value(v,prompt,vtype=int,poll=False,source=None,
  711. all_srcs=False,global_var=False,allow_empty=False):
  712. def type_chk(vtype,val):
  713. try:
  714. vtype(val); return True
  715. except:
  716. return False
  717. errmsg = ''
  718. with dlock:
  719. while True:
  720. blank_ticker()
  721. em = errmsg+'\n' if errmsg else ''
  722. termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_termattrs)
  723. # time.sleep(0.1)
  724. s = raw_input(CUR_HOME+CUR_SHOW+em+prompt)
  725. msg_r(CUR_HIDE)
  726. if not s:
  727. if allow_empty:
  728. ks_msg_nolock('Value unset',0.5)
  729. else:
  730. blank_ticker()
  731. display_ticker(ga)
  732. return False
  733. if v == 'alrm_clock':
  734. if s == 'off':
  735. globals()[v] = s
  736. if v in get_thrd_names():
  737. kill_flgs['alrm'].set()
  738. return s
  739. a = s.split(':')
  740. ok = True
  741. if len(a) != 2: ok = False
  742. if ok:
  743. try: h,m = int(a[0]),int(a[1])
  744. except: ok = False
  745. if ok:
  746. if not (24 >= h >= 0): ok = False
  747. if not (60 >= m >= 0): ok = False
  748. if ok:
  749. globals()[v] = '{:02d}:{:02d}'.format(h,m)
  750. break
  751. else:
  752. errmsg = 'Incorrect alarm clock value'
  753. continue
  754. elif v == 'loglevels':
  755. ret,err = parse_loglevel_arg(s)
  756. if ret:
  757. globals()[v] = ret; break
  758. else:
  759. errmsg = err; continue
  760. elif v == 'poll_secs':
  761. ret,errmsg = set_poll_intervals(s,xchg=ga)
  762. if ret: break
  763. else: continue
  764. elif all_srcs:
  765. vals = s.split(',')
  766. if len(vals) != len(sources):
  767. errmsg = 'Input must be %s comma-separated %s values'%(
  768. len(sources),vtype.__name__)
  769. continue
  770. for k,val in zip(sources,vals):
  771. ret = type_chk(vtype,val)
  772. if ret: setattr(getattr(ga,k),v,vtype(val))
  773. else: break
  774. if ret: break
  775. elif source:
  776. if type_chk(vtype,s):
  777. setattr(getattr(ga,source),v,vtype(s)); break
  778. else:
  779. if type_chk(vtype,s):
  780. if v == 'hi_alrm' and vtype(s) < ga.lo_alrm:
  781. errmsg = 'High alarm must be >= low alarm'
  782. continue
  783. if v == 'lo_alrm' and vtype(s) > ga.hi_alrm:
  784. errmsg = 'Low alarm must be <= high alarm'
  785. continue
  786. if global_var:
  787. globals()[v] = vtype(s); break
  788. else:
  789. setattr(ga,v,vtype(s)); break
  790. errmsg = 'Value%s must be of type %s' % (
  791. ('','s')[all_srcs],vtype.__name__)
  792. redraw()
  793. if poll:
  794. kill_flgs[ga.desc+'_ticker'].set()
  795. return True
  796. while True:
  797. ch = get_keypress()
  798. if ch == 'q':
  799. quit = True
  800. for k in kill_flgs: kill_flgs[k].set()
  801. for i in th.enumerate():
  802. if i.name in main_thrd_names + alrm_names + ('clock',): i.join()
  803. msg('')
  804. break
  805. elif ch == 'a':
  806. m1 = 'Current alarm clock: %s' % alrm_clock
  807. m2 = "Enter alarm clock time or 'off': "
  808. if set_value('alrm_clock',m1+'\n'+m2):
  809. ks_msg("Alarm clock set to %s" % alrm_clock,1.5)
  810. elif ch == 'A':
  811. set_value('audio_host','Enter remote audio host: ',
  812. vtype=str,global_var=True,allow_empty=True)
  813. elif ch in 'H?': do_help()
  814. elif ch == 'k':
  815. if 'alrm_clock' in get_thrd_names(): alrm_clock = 'off'
  816. a = any([i in alrm_names for i in get_thrd_names()])
  817. log(3,'\n alrm_names: {}\n get_thrd_names(): {}'.format(
  818. alrm_names,get_thrd_names()))
  819. if a: kill_flgs['alrm'].set()
  820. ks_msg(('No active alarms','Killing alarm')[a],0.5)
  821. elif ch == 'M':
  822. with dlock:
  823. blank_ticker()
  824. termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_termattrs)
  825. s = raw_input(CUR_HOME+'Enter log message: ')
  826. log(0,'USR_MSG: '+s)
  827. display_ticker(ga)
  828. elif ch == 'm':
  829. mute_alrms = not mute_alrms
  830. if mute_alrms: kill_flgs['alrm'].set()
  831. ks_msg(('Unm','M')[mute_alrms] + 'uting alarms',1)
  832. with dlock:
  833. blank_ticker()
  834. display_ticker(ga)
  835. elif ch == 'v': set_value('sound_vol','Enter sound volume: ',global_var=True)
  836. elif ch in '-+':
  837. with dlock:
  838. sound_vol += (1,-1)[ch=='-']
  839. blank_ticker(); display_ticker(ga)
  840. elif not 'alarm_clock_only' in opts:
  841. if ch == 'c':
  842. toggle_option('cur_ticker_only')
  843. ks_msg('Displaying %s' %
  844. ('all tickers','current ticker only')['cur_ticker_only' in opts],0.5)
  845. elif ch == 'G': set_value('loglevels','Enter log levels: ',global_var=True)
  846. elif ch == 'h': set_value('hi_alrm','Enter high alarm: ',Decimal)
  847. elif ch == 'l': set_value('lo_alrm','Enter low alarm: ',Decimal)
  848. elif ch == 'L':
  849. ks_msg('Logging current ticker at %s' % get_hms(),1.5)
  850. with dlock:
  851. log(0,'{} CUR_STATE\n BID/ASK/LAST {}\n'.format(ga.desc.upper(),ga.tc.bal))
  852. elif ch == 'p': set_value('poll_secs',
  853. 'Enter poll interval: ',float,poll=True)
  854. elif ch == 't':
  855. key = '%s_ticker' % (ga.desc)
  856. if key in kill_flgs:
  857. kill_flgs[key].set()
  858. ks_msg('Reloading ticker data',1)
  859. else:
  860. ks_msg('%s: no such key' % key,1)
  861. elif ch == 'x':
  862. for x in range(len(xchgs)):
  863. if ga is xchgs[x]: break
  864. new_g = xchgs[x+1 if x+1 < len(xchgs) else 0]
  865. ks_msg('Switching to exchange %s' % new_g.Desc,0.5)
  866. with dlock:
  867. ga = new_g
  868. redraw()
  869. elif ch == 'e':
  870. for a,b in fx_pairs:
  871. ks_msg('Updating {}/{} rate'.format(a.upper(),b.upper()),0.5)
  872. ret = update_fx(a,b)
  873. m,d = (('Unable to update',2),('Updated',0.7))[bool(ret)]
  874. ks_msg('{} {}/{} rate'.format(m,a.upper(),b.upper()),d)
  875. def launch(name,*args,**kwargs):
  876. def at_exit():
  877. # msg_r(CUR_HOME+ERASE_ALL) # DEBUG
  878. msg_r(CUR_SHOW)
  879. termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_termattrs)
  880. for k in kill_flgs: kill_flgs[k].set()
  881. for i in th.enumerate():
  882. if i.name in main_thrd_names + alrm_names + ('clock',): i.join()
  883. if 'log' in opts: log(0,'Exiting')
  884. import atexit
  885. atexit.register(at_exit)
  886. log(0,'Starting log')
  887. try:
  888. globals()[name](*args,**kwargs)
  889. except KeyboardInterrupt:
  890. quit = True
  891. for k in kill_flgs: kill_flgs[k].set()
  892. sys.stderr.write('\nUser interrupt\n')
  893. import termios
  894. old_termattrs = termios.tcgetattr(sys.stdin.fileno())
  895. launch('input_loop')