coinsync 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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE.txt +21 -0
  3. data/README.md +255 -0
  4. data/bin/coinsync +78 -0
  5. data/doc/importers.md +201 -0
  6. data/lib/coinsync.rb +18 -0
  7. data/lib/coinsync/balance.rb +19 -0
  8. data/lib/coinsync/balance_task.rb +65 -0
  9. data/lib/coinsync/build_task.rb +43 -0
  10. data/lib/coinsync/builder.rb +32 -0
  11. data/lib/coinsync/config.rb +94 -0
  12. data/lib/coinsync/crypto_classifier.rb +27 -0
  13. data/lib/coinsync/currencies.rb +35 -0
  14. data/lib/coinsync/currency_converter.rb +65 -0
  15. data/lib/coinsync/currency_converters/all.rb +1 -0
  16. data/lib/coinsync/currency_converters/base.rb +46 -0
  17. data/lib/coinsync/currency_converters/cache.rb +34 -0
  18. data/lib/coinsync/currency_converters/fixer.rb +36 -0
  19. data/lib/coinsync/currency_converters/nbp.rb +38 -0
  20. data/lib/coinsync/formatter.rb +44 -0
  21. data/lib/coinsync/import_task.rb +37 -0
  22. data/lib/coinsync/importers/all.rb +1 -0
  23. data/lib/coinsync/importers/ark_voting.rb +121 -0
  24. data/lib/coinsync/importers/base.rb +35 -0
  25. data/lib/coinsync/importers/binance_api.rb +210 -0
  26. data/lib/coinsync/importers/bitbay20.rb +144 -0
  27. data/lib/coinsync/importers/bitbay_api.rb +224 -0
  28. data/lib/coinsync/importers/bitcurex.rb +71 -0
  29. data/lib/coinsync/importers/bittrex_api.rb +81 -0
  30. data/lib/coinsync/importers/bittrex_csv.rb +75 -0
  31. data/lib/coinsync/importers/changelly.rb +57 -0
  32. data/lib/coinsync/importers/circle.rb +58 -0
  33. data/lib/coinsync/importers/default.rb +90 -0
  34. data/lib/coinsync/importers/etherdelta.rb +93 -0
  35. data/lib/coinsync/importers/kraken_api.rb +134 -0
  36. data/lib/coinsync/importers/kraken_common.rb +137 -0
  37. data/lib/coinsync/importers/kraken_csv.rb +28 -0
  38. data/lib/coinsync/importers/kucoin_api.rb +172 -0
  39. data/lib/coinsync/importers/lisk_voting.rb +110 -0
  40. data/lib/coinsync/outputs/all.rb +1 -0
  41. data/lib/coinsync/outputs/base.rb +32 -0
  42. data/lib/coinsync/outputs/list.rb +123 -0
  43. data/lib/coinsync/outputs/raw.rb +45 -0
  44. data/lib/coinsync/outputs/summary.rb +48 -0
  45. data/lib/coinsync/request.rb +31 -0
  46. data/lib/coinsync/run_command_task.rb +20 -0
  47. data/lib/coinsync/source.rb +43 -0
  48. data/lib/coinsync/source_filter.rb +10 -0
  49. data/lib/coinsync/table_printer.rb +29 -0
  50. data/lib/coinsync/transaction.rb +125 -0
  51. data/lib/coinsync/version.rb +3 -0
  52. metadata +95 -0
@@ -0,0 +1,34 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+
4
+ module CoinSync
5
+ module CurrencyConverters
6
+ class Cache
7
+ def initialize(name)
8
+ @name = name
9
+ @filename = "caches/#{name}.json"
10
+
11
+ if File.exist?(@filename)
12
+ @rates = JSON.parse(File.read(@filename))
13
+ else
14
+ @rates = {}
15
+ end
16
+ end
17
+
18
+ def [](from, to, date)
19
+ @rates["#{from.code}:#{to.code}"] ||= {}
20
+ @rates["#{from.code}:#{to.code}"][date.to_s]
21
+ end
22
+
23
+ def []=(from, to, date, amount)
24
+ @rates["#{from.code}:#{to.code}"] ||= {}
25
+ @rates["#{from.code}:#{to.code}"][date.to_s] = amount
26
+ end
27
+
28
+ def save
29
+ FileUtils.mkdir_p(File.dirname(@filename))
30
+ File.write(@filename, JSON.generate(@rates))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,36 @@
1
+ require 'json'
2
+ require 'net/http'
3
+
4
+ require_relative 'base'
5
+ require_relative '../request'
6
+
7
+ module CoinSync
8
+ module CurrencyConverters
9
+ class Fixer < Base
10
+ register_converter :fixer
11
+
12
+ BASE_URL = "https://api.fixer.io"
13
+
14
+ class Exception < StandardError; end
15
+ class NoDataException < Exception; end
16
+ class BadRequestException < Exception; end
17
+
18
+ def fetch_conversion_rate(from:, to:, date:)
19
+ response = Request.get("#{BASE_URL}/#{date}?base=#{from.code}")
20
+
21
+ case response
22
+ when Net::HTTPSuccess
23
+ json = JSON.parse(response.body)
24
+ rate = json['rates'][to.code.upcase]
25
+ raise NoDataException.new("No exchange rate found for #{to.code.upcase}") if rate.nil?
26
+
27
+ return rate
28
+ when Net::HTTPBadRequest
29
+ raise BadRequestException.new(response)
30
+ else
31
+ raise Exception.new(response)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ require 'json'
2
+ require 'net/http'
3
+
4
+ require_relative 'base'
5
+ require_relative '../request'
6
+
7
+ module CoinSync
8
+ module CurrencyConverters
9
+ class NBP < Base
10
+ register_converter :nbp
11
+
12
+ BASE_URL = "https://api.nbp.pl/api"
13
+
14
+ class Exception < StandardError; end
15
+ class NoDataException < Exception; end
16
+ class BadRequestException < Exception; end
17
+
18
+ def fetch_conversion_rate(from:, to:, date:)
19
+ raise "Only conversions to PLN are supported" if to.code != 'PLN'
20
+
21
+ response = Request.get("#{BASE_URL}/exchangerates/rates/a/#{from.code}/#{date - 8}/#{date - 1}/?format=json")
22
+
23
+ case response
24
+ when Net::HTTPSuccess
25
+ json = JSON.parse(response.body)
26
+ rate = json['rates'] && json['rates'].last && json['rates'].last['mid']
27
+ raise NoDataException.new("No exchange rate found for #{from.code.upcase}") if rate.nil?
28
+
29
+ return rate
30
+ when Net::HTTPBadRequest
31
+ raise BadRequestException.new(response)
32
+ else
33
+ raise Exception.new(response)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ require 'bigdecimal'
2
+
3
+ module CoinSync
4
+ class Formatter
5
+ def initialize(config)
6
+ @config = config
7
+ @decimal_separator = config.custom_decimal_separator
8
+ end
9
+
10
+ def format_decimal(value, precision: nil)
11
+ v = precision ? value.round(precision) : value
12
+ s = v.to_s('F').gsub(/\.0$/, '')
13
+ s = s.gsub(/\./, @decimal_separator) if @decimal_separator
14
+ s
15
+ end
16
+
17
+ def format_float(value, precision:)
18
+ s = sprintf("%.#{precision}f", value)
19
+ s = s.gsub(/\./, @decimal_separator) if @decimal_separator
20
+ s
21
+ end
22
+
23
+ def format_fiat(amount)
24
+ format_float(amount, precision: 2)
25
+ end
26
+
27
+ def format_fiat_price(amount)
28
+ format_float(amount, precision: (amount < 10 ? 4 : 2))
29
+ end
30
+
31
+ def format_crypto(amount)
32
+ format_decimal(amount, precision: 8)
33
+ end
34
+
35
+ def format_time(time)
36
+ time.strftime(@config.time_format || '%Y-%m-%d %H:%M:%S')
37
+ end
38
+
39
+ def parse_decimal(string)
40
+ string = string.gsub(@decimal_separator, '.') if @decimal_separator
41
+ BigDecimal.new(string)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,37 @@
1
+ require 'fileutils'
2
+
3
+ require_relative 'importers/all'
4
+
5
+ module CoinSync
6
+ class ImportTask
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ def run(selected = nil, except = nil)
12
+ @config.filtered_sources(selected, except).each do |key, source|
13
+ importer = source.importer
14
+ filename = source.filename
15
+
16
+ if importer.respond_to?(:can_import?)
17
+ if importer.can_import?(:transactions)
18
+ if filename.nil?
19
+ raise "No filename specified for '#{key}', please add a 'file' parameter."
20
+ end
21
+
22
+ print "[#{key}] Importing transactions... "
23
+
24
+ FileUtils.mkdir_p(File.dirname(filename))
25
+ importer.import_transactions(filename)
26
+
27
+ puts "√"
28
+ else
29
+ puts "[#{key}] Skipping import"
30
+ end
31
+ end
32
+ end
33
+
34
+ puts "Done."
35
+ end
36
+ end
37
+ end
@@ -0,0 +1 @@
1
+ Dir[File.join(File.dirname(__FILE__), '*.rb')].each { |f| require(f) }
@@ -0,0 +1,121 @@
1
+ require 'bigdecimal'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'time'
5
+ require 'uri'
6
+
7
+ require_relative 'base'
8
+ require_relative '../balance'
9
+ require_relative '../currencies'
10
+ require_relative '../request'
11
+ require_relative '../transaction'
12
+
13
+ module CoinSync
14
+ module Importers
15
+ class ArkVoting < Base
16
+ register_importer :ark_voting
17
+
18
+ BASE_URL = "https://explorer.dafty.net/api"
19
+ EPOCH_TIME = Time.parse('2017-03-21 13:00 UTC')
20
+ ARK = CryptoCurrency.new('ARK')
21
+
22
+ class HistoryEntry
23
+ attr_accessor :timestamp, :amount
24
+
25
+ def initialize(hash)
26
+ @timestamp = EPOCH_TIME + hash['timestamp']
27
+ @amount = BigDecimal.new(hash['amount']) / 100_000_000
28
+ end
29
+ end
30
+
31
+ def initialize(config, params = {})
32
+ super
33
+ @address = params['address']
34
+ end
35
+
36
+ def can_import?(type)
37
+ @address && [:balances, :transactions].include?(type)
38
+ end
39
+
40
+ def import_transactions(filename)
41
+ offset = 0
42
+ limit = 50
43
+ transactions = []
44
+
45
+ loop do
46
+ response = make_request('/getTransactionsByAddress', address: @address, limit: limit, offset: offset)
47
+
48
+ case response
49
+ when Net::HTTPSuccess
50
+ json = JSON.parse(response.body)
51
+
52
+ if json['success'] != true || !json['transactions']
53
+ raise "Ark importer: Invalid response: #{response.body}"
54
+ end
55
+
56
+ break if json['transactions'].empty?
57
+
58
+ rewards = json['transactions'].select { |tx| tx['senderDelegate'] }
59
+ transactions.concat(rewards)
60
+
61
+ offset += limit
62
+ when Net::HTTPBadRequest
63
+ raise "Ark importer: Bad request: #{response}"
64
+ else
65
+ raise "Ark importer: Bad response: #{response}"
66
+ end
67
+ end
68
+
69
+ File.write(filename, JSON.pretty_generate(transactions) + "\n")
70
+ end
71
+
72
+ def import_balances
73
+ response = make_request('/getAccount', address: @address)
74
+
75
+ case response
76
+ when Net::HTTPSuccess
77
+ json = JSON.parse(response.body)
78
+
79
+ if json['success'] != true || !json['balance']
80
+ raise "Ark importer: Invalid response: #{response.body}"
81
+ end
82
+
83
+ [Balance.new(ARK, available: BigDecimal.new(json['balance']) / 100_000_000)]
84
+ when Net::HTTPBadRequest
85
+ raise "Ark importer: Bad request: #{response}"
86
+ else
87
+ raise "Ark importer: Bad response: #{response}"
88
+ end
89
+ end
90
+
91
+ def read_transaction_list(source)
92
+ json = JSON.parse(source.read)
93
+ transactions = []
94
+
95
+ json.each do |hash|
96
+ entry = HistoryEntry.new(hash)
97
+
98
+ transactions << Transaction.new(
99
+ exchange: 'Ark voting',
100
+ time: entry.timestamp,
101
+ bought_amount: entry.amount,
102
+ bought_currency: ARK,
103
+ sold_amount: BigDecimal.new(0),
104
+ sold_currency: FiatCurrency.new(nil)
105
+ )
106
+ end
107
+
108
+ transactions.reverse
109
+ end
110
+
111
+ private
112
+
113
+ def make_request(path, params = {})
114
+ url = URI(BASE_URL + path)
115
+ url.query = URI.encode_www_form(params)
116
+
117
+ Request.get(url)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,35 @@
1
+ module CoinSync
2
+ module Importers
3
+ def self.registered
4
+ @importers ||= {}
5
+ end
6
+
7
+ class Base
8
+ def self.register_importer(key)
9
+ if Importers.registered[key]
10
+ raise "Importer has already been registered at '#{key}'"
11
+ else
12
+ Importers.registered[key] = self
13
+ end
14
+ end
15
+
16
+ def self.register_commands(*commands)
17
+ @commands ||= []
18
+ @commands += commands.map(&:to_sym)
19
+ end
20
+
21
+ def self.registered_commands
22
+ @commands || []
23
+ end
24
+
25
+ def initialize(config, params = {})
26
+ @config = config
27
+ @params = params
28
+ end
29
+
30
+ def can_build?
31
+ true
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,210 @@
1
+ require 'bigdecimal'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ require_relative 'base'
8
+ require_relative '../balance'
9
+ require_relative '../currencies'
10
+ require_relative '../request'
11
+ require_relative '../transaction'
12
+
13
+ module CoinSync
14
+ module Importers
15
+ class BinanceAPI < Base
16
+ register_importer :binance_api
17
+ register_commands :find_all_pairs
18
+
19
+ BASE_URL = "https://api.binance.com/api"
20
+ BASE_COINS = ['BTC', 'ETH', 'BNB', 'USDT']
21
+
22
+ class HistoryEntry
23
+ attr_accessor :quantity, :commission, :commission_asset, :price, :time, :buyer, :asset, :currency
24
+
25
+ def initialize(hash)
26
+ @quantity = BigDecimal.new(hash['qty'])
27
+ @commission = BigDecimal.new(hash['commission'])
28
+ @commission_asset = CryptoCurrency.new(hash['commissionAsset'])
29
+ @price = BigDecimal.new(hash['price'])
30
+ @time = Time.at(hash['time'] / 1000)
31
+ @buyer = hash['isBuyer']
32
+
33
+ @asset, @currency = parse_coins(hash['symbol'])
34
+
35
+ if (@buyer && @commission_asset != @asset) || (!@buyer && @commission_asset != @currency)
36
+ raise "Binance API: Unexpected fee: #{hash}"
37
+ end
38
+ end
39
+
40
+ def parse_coins(symbol)
41
+ BASE_COINS.each do |coin|
42
+ if symbol.end_with?(coin)
43
+ asset = symbol.gsub(/#{coin}$/, '')
44
+ return [CryptoCurrency.new(asset), CryptoCurrency.new(coin)]
45
+ end
46
+ end
47
+
48
+ raise "Binance API: Unexpected trade symbol: #{symbol}"
49
+ end
50
+ end
51
+
52
+ def initialize(config, params = {})
53
+ super
54
+
55
+ # only "Read Info" permission is required for the key
56
+ @api_key = params['api_key']
57
+ @secret_key = params['secret_key']
58
+ @traded_pairs = params['traded_pairs']
59
+ end
60
+
61
+ def can_import?(type)
62
+ @api_key && @secret_key && [:balances, :transactions].include?(type)
63
+ end
64
+
65
+ def import_transactions(filename)
66
+ @traded_pairs or raise "Please add a traded_pairs parameter"
67
+
68
+ transactions = []
69
+
70
+ @traded_pairs.uniq.each do |pair|
71
+ response = make_request('/v3/myTrades', limit: 500, symbol: pair) # TODO: paging
72
+
73
+ case response
74
+ when Net::HTTPSuccess
75
+ json = JSON.parse(response.body)
76
+
77
+ if json.is_a?(Hash)
78
+ raise "Binance importer: Invalid response: #{response.body}"
79
+ end
80
+
81
+ json.each { |tx| tx['symbol'] = pair }
82
+
83
+ transactions.concat(json)
84
+ when Net::HTTPBadRequest
85
+ raise "Binance importer: Bad request: #{response} (#{response.body})"
86
+ else
87
+ raise "Binance importer: Bad response: #{response}"
88
+ end
89
+ end
90
+
91
+ File.write(filename, JSON.pretty_generate(transactions.sort_by { |tx| [tx['time'], tx['id']] }))
92
+ end
93
+
94
+ def import_balances
95
+ response = make_request('/v3/account')
96
+
97
+ case response
98
+ when Net::HTTPSuccess
99
+ json = JSON.parse(response.body)
100
+
101
+ if json['code'] || !json['balances']
102
+ raise "Binance importer: Invalid response: #{response.body}"
103
+ end
104
+
105
+ return json['balances'].select { |b|
106
+ b['free'].to_f > 0 || b['locked'].to_f > 0
107
+ }.map { |b|
108
+ Balance.new(
109
+ CryptoCurrency.new(b['asset']),
110
+ available: BigDecimal.new(b['free']),
111
+ locked: BigDecimal.new(b['locked'])
112
+ )
113
+ }
114
+ when Net::HTTPBadRequest
115
+ raise "Binance importer: Bad request: #{response}"
116
+ else
117
+ raise "Binance importer: Bad response: #{response}"
118
+ end
119
+ end
120
+
121
+ def find_all_pairs
122
+ info_response = make_request('/v1/exchangeInfo', {}, false)
123
+
124
+ if !info_response.is_a?(Net::HTTPSuccess)
125
+ raise "Binance importer: Bad response: #{info_response.body}"
126
+ end
127
+
128
+ info_json = JSON.parse(info_response.body)
129
+
130
+ found = []
131
+
132
+ info_json['symbols'].each do |data|
133
+ symbol = data['symbol']
134
+ trades_response = make_request('/v3/myTrades', limit: 1, symbol: symbol)
135
+
136
+ case trades_response
137
+ when Net::HTTPSuccess
138
+ trades_json = JSON.parse(trades_response.body)
139
+
140
+ if trades_json.length > 0
141
+ print '*'
142
+ found << symbol
143
+ else
144
+ print '.'
145
+ end
146
+ else
147
+ raise "Binance importer: Bad response: #{trades_response.body}"
148
+ end
149
+ end
150
+
151
+ puts
152
+ puts "Trading pairs found:"
153
+ puts found.sort
154
+ end
155
+
156
+ def read_transaction_list(source)
157
+ json = JSON.parse(source.read)
158
+ transactions = []
159
+
160
+ json.each do |hash|
161
+ entry = HistoryEntry.new(hash)
162
+
163
+ if entry.buyer
164
+ transactions << Transaction.new(
165
+ exchange: 'Binance',
166
+ time: entry.time,
167
+ bought_amount: entry.quantity - entry.commission,
168
+ bought_currency: entry.asset,
169
+ sold_amount: entry.price * entry.quantity,
170
+ sold_currency: entry.currency
171
+ )
172
+ else
173
+ transactions << Transaction.new(
174
+ exchange: 'Binance',
175
+ time: entry.time,
176
+ bought_amount: entry.price * entry.quantity - entry.commission,
177
+ bought_currency: entry.currency,
178
+ sold_amount: entry.quantity,
179
+ sold_currency: entry.asset
180
+ )
181
+ end
182
+ end
183
+
184
+ transactions
185
+ end
186
+
187
+ private
188
+
189
+ def make_request(path, params = {}, signed = true)
190
+ if signed
191
+ (@api_key && @secret_key) or raise "Public and secret API keys must be provided"
192
+
193
+ params['timestamp'] = (Time.now.to_f * 1000).to_i
194
+ end
195
+
196
+ url = URI(BASE_URL + path)
197
+ url.query = URI.encode_www_form(params)
198
+
199
+ if signed
200
+ hmac = OpenSSL::HMAC.hexdigest('sha256', @secret_key, url.query)
201
+ url.query += "&signature=#{hmac}"
202
+ end
203
+
204
+ Request.get(url) do |request|
205
+ request['X-MBX-APIKEY'] = @api_key
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end