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.
- checksums.yaml +5 -5
- data/.gitignore +1 -0
- data/.travis.yml +32 -3
- data/CHANGELOG.md +7 -0
- data/Gemfile +9 -1
- data/LICENSE.txt +555 -21
- data/README.md +250 -16
- data/Rakefile +4 -1
- data/coinpare.gemspec +22 -6
- data/exe/coinpare +18 -0
- data/lib/coinpare.rb +0 -1
- data/lib/coinpare/cli.rb +148 -0
- data/lib/coinpare/command.rb +133 -0
- data/lib/coinpare/commands/.gitkeep +1 -0
- data/lib/coinpare/commands/coins.rb +116 -0
- data/lib/coinpare/commands/holdings.rb +266 -0
- data/lib/coinpare/commands/markets.rb +113 -0
- data/lib/coinpare/fetcher.rb +72 -0
- data/lib/coinpare/version.rb +1 -1
- metadata +230 -9
@@ -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
|