coinpare 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coinpare
4
+ class Command
5
+ SYMBOLS = {
6
+ down_arrow: '▼',
7
+ up_arrow: '▲'
8
+ }.freeze
9
+
10
+ # The default interval for auto updating data
11
+ DEFAULT_INTERVAL = 5
12
+
13
+ trap('SIGINT') { exit }
14
+
15
+ # Main configuration
16
+ # @api public
17
+ def config
18
+ @config ||= begin
19
+ config = TTY::Config.new
20
+ config.filename = 'coinpare'
21
+ config.extname = '.toml'
22
+ config.append_path Dir.pwd
23
+ config.append_path Dir.home
24
+ config
25
+ end
26
+ end
27
+
28
+ # Time for when the data was fetched
29
+ # @api public
30
+ def timestamp
31
+ "#{Time.now.strftime("%d %B %Y")} at #{Time.now.strftime("%I:%M:%S %p %Z")}"
32
+ end
33
+
34
+ # The exchange, currency & time banner
35
+ # @api public
36
+ def banner(settings)
37
+ "\n#{add_color('Exchange', :yellow)} #{settings['exchange']} " \
38
+ "#{add_color('Currency', :yellow)} #{settings['base'].upcase} " \
39
+ "#{add_color('Time', :yellow)} #{timestamp}\n\n"
40
+ end
41
+
42
+ # Provide arrow for marking value growth or decline
43
+ # @api public
44
+ def pick_arrow(change)
45
+ return if change.zero?
46
+ change > 0 ? SYMBOLS[:up_arrow] : SYMBOLS[:down_arrow]
47
+ end
48
+
49
+ def add_color(str, color)
50
+ @options["no-color"] || color == :none ? str : @pastel.decorate(str, color)
51
+ end
52
+
53
+ def pick_color(change)
54
+ return :none if change.zero?
55
+ change > 0 ? :green : :red
56
+ end
57
+
58
+ def percent(value)
59
+ (value * 100).round(2)
60
+ end
61
+
62
+ def percent_change(before, after)
63
+ (after - before) / before.to_f * 100
64
+ end
65
+
66
+ def shorten_currency(value)
67
+ if value > 10 ** 9
68
+ (value / 10 ** 9).to_f.round(2).to_s + ' B'
69
+ elsif value > 10 ** 6
70
+ (value / 10 ** 6).to_f.round(2).to_s + ' M'
71
+ else
72
+ value
73
+ end
74
+ end
75
+
76
+ def precision(value, decimals = 2)
77
+ part = value.to_s.split('.')[1]
78
+ return 0 if part.nil?
79
+ value.between?(0, 1) ? (part.index(/[^0]/) + decimals) : decimals
80
+ end
81
+
82
+ def round_to(value, prec = nil)
83
+ prec = precision(value) if prec.nil?
84
+ "%.#{prec}f" % value
85
+ end
86
+
87
+ def number_to_currency(value)
88
+ whole, part = value.to_s.split('.')
89
+ part = '.' + part unless part.nil?
90
+ "#{whole.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')}#{part}"
91
+ end
92
+
93
+ # Execute this command
94
+ #
95
+ # @api public
96
+ def execute(*)
97
+ raise(
98
+ NotImplementedError,
99
+ "#{self.class}##{__method__} must be implemented"
100
+ )
101
+ end
102
+
103
+ # The cursor movement
104
+ #
105
+ # @see http://www.rubydoc.info/gems/tty-cursor
106
+ #
107
+ # @api public
108
+ def cursor
109
+ require 'tty-cursor'
110
+ TTY::Cursor
111
+ end
112
+
113
+ # Open a file or text in the user's preferred editor
114
+ #
115
+ # @see http://www.rubydoc.info/gems/tty-editor
116
+ #
117
+ # @api public
118
+ def editor
119
+ require 'tty-editor'
120
+ TTY::Editor
121
+ end
122
+
123
+ # Get terminal screen properties
124
+ #
125
+ # @see http://www.rubydoc.info/gems/tty-screen
126
+ #
127
+ # @api public
128
+ def screen
129
+ require 'tty-screen'
130
+ TTY::Screen
131
+ end
132
+ end # Command
133
+ end # Coinpare
@@ -0,0 +1 @@
1
+ #
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pastel'
4
+ require 'tty-pager'
5
+ require 'tty-spinner'
6
+ require 'tty-table'
7
+ require 'timers'
8
+
9
+ require_relative '../command'
10
+ require_relative '../fetcher'
11
+
12
+ module Coinpare
13
+ module Commands
14
+ class Coins < Coinpare::Command
15
+ def initialize(names, options)
16
+ @names = names
17
+ @options = options
18
+ @pastel = Pastel.new
19
+ @timers = Timers::Group.new
20
+ @spinner = TTY::Spinner.new(':spinner Fetching data...',
21
+ format: :dots, clear: true)
22
+ end
23
+
24
+ def execute(output: $stdout)
25
+ pager = TTY::Pager::BasicPager.new(output: output)
26
+ @spinner.auto_spin
27
+
28
+ if @options['watch']
29
+ output.print cursor.hide
30
+ interval = @options['watch'].to_f > 0 ? @options['watch'].to_f : DEFAULT_INTERVAL
31
+ @timers.now_and_every(interval) { display_coins(output, pager) }
32
+ loop { @timers.wait }
33
+ else
34
+ display_coins(output, pager)
35
+ end
36
+ ensure
37
+ @spinner.stop
38
+ if @options['watch']
39
+ @timers.cancel
40
+ output.print cursor.clear_screen_down
41
+ output.print cursor.show
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def display_coins(output, pager)
48
+ if @names.empty? # no coins provided
49
+ @names = setup_top_coins
50
+ end
51
+ response = Fetcher.fetch_prices(@names.map(&:upcase).join(','),
52
+ @options['base'].upcase, @options)
53
+ return unless response
54
+ table = setup_table(response['RAW'], response['DISPLAY'])
55
+ @spinner.stop
56
+
57
+ lines = banner(@options).lines.size + 1 + table.rows_size + 3
58
+ clear_output(output, lines) { print_results(table, output, pager) }
59
+ end
60
+
61
+ def clear_output(output, lines)
62
+ output.print cursor.clear_screen_down if @options['watch']
63
+ yield if block_given?
64
+ output.print cursor.up(lines) if @options['watch']
65
+ end
66
+
67
+ def print_results(table, output, pager)
68
+ output.puts banner(@options)
69
+ pager.page(table.render(:unicode, padding: [0, 1], alignment: :right))
70
+ output.puts
71
+ end
72
+
73
+ def setup_top_coins
74
+ response = Fetcher.fetch_top_coins_by_volume(@options['base'].upcase,
75
+ @options)
76
+ return unless response
77
+ response['Data'].map { |coin| coin['CoinInfo']['Name'] }[0...@options['top']]
78
+ end
79
+
80
+ def setup_table(raw_data, display_data)
81
+ table = TTY::Table.new(header: [
82
+ { value: 'Coin', alignment: :left },
83
+ 'Price',
84
+ 'Chg. 24H',
85
+ 'Chg.% 24H',
86
+ 'Open 24H',
87
+ 'High 24H',
88
+ 'Low 24H',
89
+ 'Direct Vol. 24H',
90
+ 'Total Vol. 24H',
91
+ 'Market Cap'
92
+ ])
93
+
94
+ @names.each do |name|
95
+ coin_data = display_data[name.upcase][@options['base'].upcase]
96
+ coin_raw_data = raw_data[name.upcase][@options['base'].upcase]
97
+ change24h = coin_raw_data['CHANGE24HOUR']
98
+ coin_details = [
99
+ { value: add_color(name.upcase, :yellow), alignment: :left },
100
+ add_color(coin_data['PRICE'], pick_color(change24h)),
101
+ add_color("#{pick_arrow(change24h)} #{coin_data['CHANGE24HOUR']}", pick_color(change24h)),
102
+ add_color("#{pick_arrow(change24h)} #{coin_data['CHANGEPCT24HOUR']}%", pick_color(change24h)),
103
+ coin_data['OPEN24HOUR'],
104
+ coin_data['HIGH24HOUR'],
105
+ coin_data['LOW24HOUR'],
106
+ coin_data['VOLUME24HOURTO'],
107
+ coin_data['TOTALVOLUME24HTO'],
108
+ coin_data['MKTCAP']
109
+ ]
110
+ table << coin_details
111
+ end
112
+ table
113
+ end
114
+ end # Coins
115
+ end # Commands
116
+ end # Coinpare
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'toml'
4
+ require 'pastel'
5
+ require 'tty-config'
6
+ require 'tty-prompt'
7
+ require 'tty-spinner'
8
+ require 'tty-table'
9
+ require 'timers'
10
+
11
+ require_relative '../command'
12
+ require_relative '../fetcher'
13
+
14
+ module Coinpare
15
+ module Commands
16
+ class Holdings < Coinpare::Command
17
+ def initialize(options)
18
+ @options = options
19
+ @pastel = Pastel.new
20
+ @timers = Timers::Group.new
21
+ @spinner = TTY::Spinner.new(":spinner Fetching data...",
22
+ format: :dots, clear: true)
23
+ @interval = @options.fetch('watch', DEFAULT_INTERVAL).to_f
24
+ config.set('settings', 'color', value: !@options['no-color'])
25
+ end
26
+
27
+ def execute(input: $stdin, output: $stdout)
28
+ config_saved = config.persisted?
29
+ if config_saved && @options['edit']
30
+ editor.open(config.source_file)
31
+ return
32
+ elsif @options['edit']
33
+ output.puts "Sorry, no holdings configuration found."
34
+ output.print "Run \""
35
+ output.print "$ #{add_color('coinpare holdings', :yellow)}\" "
36
+ output.puts "to setup new altfolio."
37
+ return
38
+ end
39
+
40
+ config.read if config_saved
41
+
42
+ holdings = config.fetch('holdings')
43
+ if holdings.nil? || (holdings && holdings.empty?)
44
+ info = setup_portfolio(input, output)
45
+ config.merge(info)
46
+ elsif @options['add']
47
+ coin_info = add_coin(input, output)
48
+ config.append(coin_info, to: ['holdings'])
49
+ elsif @options['remove']
50
+ coin_info = remove_coin(input, output)
51
+ config.remove(*coin_info, from: ['holdings'])
52
+ elsif @options['clear']
53
+ prompt = create_prompt(input, output)
54
+ answer = prompt.yes?('Do you want to remove all holdings?')
55
+ if answer
56
+ config.delete('holdings')
57
+ output.puts add_color("All holdings removed", :red)
58
+ end
59
+ end
60
+
61
+ holdings = config.fetch('holdings')
62
+ no_holdings_left = holdings.nil? || (holdings && holdings.empty?)
63
+ if no_holdings_left
64
+ config.delete('holdings')
65
+ end
66
+
67
+ # Persist current configuration
68
+ home_file = ::File.join(Dir.home, "#{config.filename}#{config.extname}")
69
+ file = config.source_file
70
+ config.write(file.nil? ? home_file : file, force: true)
71
+ if no_holdings_left
72
+ output.puts add_color("Please add holdings to your altfolio!", :green)
73
+ exit
74
+ end
75
+
76
+ @spinner.auto_spin
77
+ settings = config.fetch('settings')
78
+ # command options take precedence over config settings
79
+ overridden_settings = {}
80
+ overridden_settings['exchange'] = @options.fetch('exchange', settings.fetch('exchange'))
81
+ overridden_settings['base'] = @options.fetch('base', settings.fetch('base'))
82
+ holdings = config.fetch('holdings') { [] }
83
+ names = holdings.map { |c| c['name'] }
84
+
85
+ if @options['watch']
86
+ output.print cursor.hide
87
+ @timers.now_and_every(@interval) do
88
+ display_coins(output, names, overridden_settings)
89
+ end
90
+ loop { @timers.wait }
91
+ else
92
+ display_coins(output, names, overridden_settings)
93
+ end
94
+ ensure
95
+ @spinner.stop
96
+ if @options['watch']
97
+ @timers.cancel
98
+ output.print cursor.clear_screen_down
99
+ output.print cursor.show
100
+ end
101
+ end
102
+
103
+ def display_coins(output, names, overridden_settings)
104
+ response = Fetcher.fetch_prices(names.join(','),
105
+ overridden_settings['base'].upcase,
106
+ overridden_settings)
107
+ return unless response
108
+ table = setup_table(response['RAW'], response['DISPLAY'])
109
+
110
+ @spinner.stop
111
+
112
+ lines = banner(overridden_settings).lines.size + 1 + table.rows_size + 3
113
+ clear_output(output, lines) do
114
+ output.puts banner(overridden_settings)
115
+ output.puts table.render(:unicode, padding: [0, 1], alignment: :right)
116
+ end
117
+ end
118
+
119
+ def clear_output(output, lines)
120
+ output.print cursor.clear_screen_down if @options['watch']
121
+ yield if block_given?
122
+ output.print cursor.up(lines) if @options['watch']
123
+ end
124
+
125
+ def create_prompt(input, output)
126
+ prompt = TTY::Prompt.new(
127
+ prefix: "[#{add_color('c', :yellow)}] ",
128
+ input: input, output: output,
129
+ interrupt: -> { puts; exit 1 },
130
+ enable_color: !@options['no-color'], clear: true)
131
+ prompt.on(:keypress) { |e| prompt.trigger(:keydown) if e.value == 'j' }
132
+ prompt
133
+ end
134
+
135
+ def ask_coin
136
+ -> (prompt) do
137
+ key('name').ask('What coin do you own?') do |q|
138
+ q.required true
139
+ q.default 'BTC'
140
+ q.validate(/\w{2,}/, 'Currency can only be chars.')
141
+ q.convert ->(coin) { coin.upcase }
142
+ end
143
+ key('amount').ask('What amount?') do |q|
144
+ q.required true
145
+ q.validate(/[\d.]+/, 'Invalid amount provided')
146
+ q.convert ->(am) { am.to_f }
147
+ end
148
+ key('price').ask('At what price per coin?') do |q|
149
+ q.required true
150
+ q.validate(/[\d.]+/, 'Invalid prince provided')
151
+ q.convert ->(p) { p.to_f }
152
+ end
153
+ end
154
+ end
155
+
156
+ def add_coin(input, output)
157
+ prompt = create_prompt(input, output)
158
+ context = self
159
+ data = prompt.collect(&context.ask_coin)
160
+ output.print cursor.up(3)
161
+ output.print cursor.clear_screen_down
162
+ data
163
+ end
164
+
165
+ def remove_coin(input, output)
166
+ prompt = create_prompt(input, output)
167
+ holdings = config.fetch('holdings')
168
+ data = prompt.multi_select('Which hodlings to remove?') do |menu|
169
+ holdings.each do |holding|
170
+ menu.choice "#{holding['name']} (#{holding['amount']})", holding
171
+ end
172
+ end
173
+ output.print cursor.up(1)
174
+ output.print cursor.clear_line
175
+ data
176
+ end
177
+
178
+ def setup_portfolio(input, output)
179
+ output.print "\nCurrently you have no investments setup...\n" \
180
+ "Let's change that and create your altfolio!\n\n"
181
+
182
+ prompt = create_prompt(input, output)
183
+ context = self
184
+ data = prompt.collect do
185
+ key('settings') do
186
+ key('base').ask('What base currency to convert holdings to?') do |q|
187
+ q.default "USD"
188
+ q.convert ->(b) { b.upcase }
189
+ q.validate(/\w{3}/, 'Currency code needs to be 3 chars long')
190
+ end
191
+ key('exchange').ask('What exchange would you like to use?') do |q|
192
+ q.default "CCCAGG"
193
+ q.required true
194
+ end
195
+ end
196
+
197
+ while prompt.yes?("Do you want to add coin to your altfolio?")
198
+ key('holdings').values(&context.ask_coin)
199
+ end
200
+ end
201
+
202
+ lines = 4 + # intro
203
+ 2 + # base + exchange
204
+ data['holdings'].size * 4 + 1
205
+ output.print cursor.up(lines)
206
+ output.print cursor.clear_screen_down
207
+
208
+ data
209
+ end
210
+
211
+ def setup_table(raw_data, display_data)
212
+ base = @options.fetch('base', config.fetch('settings', 'base')).upcase
213
+ total_buy = 0
214
+ total = 0
215
+ to_symbol = nil
216
+ table = TTY::Table.new(header: [
217
+ { value: 'Coin', alignment: :left },
218
+ 'Amount',
219
+ 'Price',
220
+ 'Total Price',
221
+ 'Cur. Price',
222
+ 'Total Cur. Price',
223
+ 'Change',
224
+ 'Change%'
225
+ ])
226
+
227
+ config.fetch('holdings').each do |coin|
228
+ coin_data = raw_data[coin['name']][base]
229
+ coin_display_data = display_data[coin['name']][base]
230
+ past_price = coin['amount'] * coin['price']
231
+ curr_price = coin['amount'] * coin_data['PRICE']
232
+ to_symbol = coin_display_data['TOSYMBOL']
233
+ change = curr_price - past_price
234
+ arrow = pick_arrow(change)
235
+ total_buy += past_price
236
+ total += curr_price
237
+
238
+ coin_details = [
239
+ { value: add_color(coin['name'], :yellow), alignment: :left },
240
+ coin['amount'],
241
+ "#{to_symbol} #{number_to_currency(round_to(coin['price']))}",
242
+ "#{to_symbol} #{number_to_currency(round_to(past_price))}",
243
+ add_color("#{to_symbol} #{number_to_currency(round_to(coin_data['PRICE']))}", pick_color(change)),
244
+ add_color("#{to_symbol} #{number_to_currency(round_to(curr_price))}", pick_color(change)),
245
+ add_color("#{arrow} #{to_symbol} #{number_to_currency(round_to(change))}", pick_color(change)),
246
+ add_color("#{arrow} #{round_to(percent_change(past_price, curr_price))}%", pick_color(change))
247
+ ]
248
+ table << coin_details
249
+ end
250
+
251
+ total_change = total - total_buy
252
+ arrow = pick_arrow(total_change)
253
+
254
+ table << [
255
+ { value: add_color('ALL', :cyan), alignment: :left}, '-', '-',
256
+ "#{to_symbol} #{number_to_currency(round_to(total_buy))}", '-',
257
+ add_color("#{to_symbol} #{number_to_currency(round_to(total))}", pick_color(total_change)),
258
+ add_color("#{arrow} #{to_symbol} #{number_to_currency(round_to(total_change))}", pick_color(total_change)),
259
+ add_color("#{arrow} #{round_to(percent_change(total_buy, total))}%", pick_color(total_change))
260
+ ]
261
+
262
+ table
263
+ end
264
+ end # Holdings
265
+ end # Commands
266
+ end # Coinpare