coinpare 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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