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,144 @@
1
+ require 'bigdecimal'
2
+ require 'csv'
3
+ require 'time'
4
+
5
+ require_relative 'base'
6
+ require_relative '../currencies'
7
+ require_relative '../transaction'
8
+
9
+ module CoinSync
10
+ module Importers
11
+ class BitBay20 < Base
12
+ register_importer :bitbay20
13
+
14
+ OP_PAY_BUYING = 'Pay for buying currency'
15
+ OP_PAY_SELLING = 'Pay for selling currency'
16
+ OP_PURCHASE = 'Currency purchase'
17
+ OP_SALE = 'Currency sale'
18
+ OP_FEE = 'Transaction fee'
19
+
20
+ MAX_TIME_DIFFERENCE = 5.0
21
+
22
+ TRANSACTION_TYPES = [OP_PAY_BUYING, OP_PAY_SELLING, OP_PURCHASE, OP_SALE, OP_FEE]
23
+
24
+ class HistoryEntry
25
+ attr_accessor :date, :accounting_date, :type, :amount, :currency
26
+
27
+ def initialize(line)
28
+ # TODO: force parsing in Polish timezone
29
+ @date = Time.parse(line[0]) unless line[0] == '-'
30
+ @accounting_date = Time.parse(line[1]) unless line[1] == '-'
31
+ @type = line[2]
32
+
33
+ amount, currency = line[3].split(' ')
34
+ @amount = BigDecimal.new(amount.gsub(/,/, ''))
35
+ @currency = parse_currency(currency)
36
+ end
37
+
38
+ def crypto?
39
+ @currency.crypto?
40
+ end
41
+
42
+ def fiat?
43
+ @currency.fiat?
44
+ end
45
+
46
+ def parse_currency(code)
47
+ case code
48
+ when 'BTC' then CryptoCurrency.new('BTC')
49
+ when 'ETH' then CryptoCurrency.new('ETH')
50
+ when 'LSK' then CryptoCurrency.new('LSK')
51
+ when 'LTC' then CryptoCurrency.new('LTC')
52
+ when 'PLN' then FiatCurrency.new('PLN')
53
+ else raise "Unknown currency: #{code}"
54
+ end
55
+ end
56
+ end
57
+
58
+ def read_transaction_list(source)
59
+ csv = CSV.new(source, col_sep: ';')
60
+
61
+ matching = []
62
+ transactions = []
63
+
64
+ csv.each do |line|
65
+ next if line.empty?
66
+ next if line[0] !~ /^\d/
67
+
68
+ entry = HistoryEntry.new(line)
69
+
70
+ next unless TRANSACTION_TYPES.include?(entry.type)
71
+
72
+ if !matching.empty? && matching.any? { |e| (e.date - entry.date).abs > MAX_TIME_DIFFERENCE }
73
+ if matching.any? { |e| e.type != OP_FEE }
74
+ raise "BitBay importer error: Couldn't match some history lines"
75
+ else
76
+ matching.clear
77
+ end
78
+ end
79
+
80
+ matching << entry
81
+
82
+ if matching.length == 3
83
+ matching.sort_by!(&:type)
84
+ types = matching.map(&:type)
85
+ time = matching.map(&:date).sort.last
86
+
87
+ if types == [OP_PURCHASE, OP_PAY_BUYING, OP_FEE] &&
88
+ matching[0].crypto? && matching[0].amount > 0 &&
89
+ matching[1].fiat? && matching[1].amount < 0 &&
90
+ matching[2].crypto? && matching[2].amount <= 0 &&
91
+ matching[0].currency == matching[2].currency
92
+ transactions << Transaction.new(
93
+ exchange: 'BitBay',
94
+ bought_currency: matching[0].currency,
95
+ sold_currency: matching[1].currency,
96
+ time: time,
97
+ bought_amount: matching[0].amount + matching[2].amount,
98
+ sold_amount: -matching[1].amount
99
+ )
100
+ elsif types == [OP_SALE, OP_PAY_SELLING, OP_FEE] &&
101
+ matching[0].crypto? && matching[0].amount < 0 &&
102
+ matching[1].fiat? && matching[1].amount > 0 &&
103
+ matching[2].fiat? && matching[2].amount <= 0 &&
104
+ matching[1].currency == matching[2].currency
105
+ transactions << Transaction.new(
106
+ exchange: 'BitBay',
107
+ bought_currency: matching[1].currency,
108
+ sold_currency: matching[0].currency,
109
+ time: time,
110
+ bought_amount: matching[1].amount + matching[2].amount,
111
+ sold_amount: -matching[0].amount
112
+ )
113
+ elsif types == [OP_PURCHASE, OP_SALE, OP_FEE] &&
114
+ matching[0].fiat? && matching[0].amount > 0 &&
115
+ matching[1].crypto? && matching[1].amount < 0 &&
116
+ matching[2].fiat? && matching[2].amount <= 0 &&
117
+ matching[0].currency == matching[2].currency
118
+ transactions << Transaction.new(
119
+ exchange: 'BitBay',
120
+ bought_currency: matching[0].currency,
121
+ sold_currency: matching[1].currency,
122
+ time: time,
123
+ bought_amount: matching[0].amount + matching[2].amount,
124
+ sold_amount: -matching[1].amount
125
+ )
126
+ else
127
+ raise "BitBay importer error: Couldn't match some history lines"
128
+ end
129
+
130
+ matching.clear
131
+ end
132
+ end
133
+
134
+ if !matching.empty?
135
+ if matching.any? { |l| l.type != OP_FEE }
136
+ raise "BitBay importer error: Couldn't match some history lines"
137
+ end
138
+ end
139
+
140
+ transactions.reverse
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,224 @@
1
+ require 'bigdecimal'
2
+ require 'json'
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'time'
6
+ require 'uri'
7
+
8
+ require_relative 'base'
9
+ require_relative '../balance'
10
+ require_relative '../currencies'
11
+ require_relative '../request'
12
+ require_relative '../transaction'
13
+
14
+ module CoinSync
15
+ module Importers
16
+ class BitBayAPI < Base
17
+ register_importer :bitbay_api
18
+
19
+ BASE_URL = "https://bitbay.net/API/Trading/tradingApi.php"
20
+
21
+ OP_PURCHASE = '+currency_transaction'
22
+ OP_SALE = '-pay_for_currency'
23
+ OP_FEE = '-fee'
24
+
25
+ MAX_TIME_DIFFERENCE = 5.0
26
+ TRANSACTION_TYPES = [OP_PURCHASE, OP_SALE, OP_FEE]
27
+
28
+ class HistoryEntry
29
+ attr_accessor :date, :amount, :type, :currency
30
+
31
+ def initialize(hash)
32
+ @date = Time.parse(hash['time']) # TODO: these times are all fucked up
33
+ @amount = BigDecimal.new(hash['amount'])
34
+ @type = hash['operation_type']
35
+ @currency = parse_currency(hash['currency'])
36
+ end
37
+
38
+ def crypto?
39
+ @currency.crypto?
40
+ end
41
+
42
+ def fiat?
43
+ @currency.fiat?
44
+ end
45
+
46
+ def parse_currency(code)
47
+ case code.upcase
48
+ when 'BTC' then CryptoCurrency.new('BTC')
49
+ when 'ETH' then CryptoCurrency.new('ETH')
50
+ when 'LTC' then CryptoCurrency.new('LTC')
51
+ when 'LSK' then CryptoCurrency.new('LSK')
52
+ when 'BCC' then CryptoCurrency.new('BCH')
53
+ when 'BTG' then CryptoCurrency.new('BTG')
54
+ when 'GAME' then CryptoCurrency.new('GAME')
55
+ when 'DASH' then CryptoCurrency.new('DASH')
56
+ when 'PLN' then FiatCurrency.new('PLN')
57
+ when 'EUR' then FiatCurrency.new('EUR')
58
+ when 'USD' then FiatCurrency.new('USD')
59
+ else raise "Unknown currency: #{code}"
60
+ end
61
+ end
62
+ end
63
+
64
+ def initialize(config, params = {})
65
+ super
66
+
67
+ # required permissions:
68
+ # * for balance checks:
69
+ # - "Crypto deposit" (shown as "Get and create cryptocurrency addresses" + "Funds deposit")
70
+ # - "Updating a wallets list" (shown as "Pobieranie rachunków")
71
+ # * for transaction history:
72
+ # - "History" (shown as "Fetch history of transactions")
73
+
74
+ @public_key = params['api_public_key']
75
+ @secret_key = params['api_private_key']
76
+ end
77
+
78
+ def can_import?(type)
79
+ @public_key && @secret_key && [:balances, :transactions].include?(type)
80
+ end
81
+
82
+ def import_transactions(filename)
83
+ info = fetch_info
84
+
85
+ currencies = info['balances'].keys
86
+ transactions = []
87
+
88
+ currencies.each do |currency|
89
+ sleep 1 # rate limiting
90
+
91
+ response = make_request('history', currency: currency, limit: 10000) # TODO: does this limit really work?
92
+
93
+ case response
94
+ when Net::HTTPSuccess
95
+ json = JSON.parse(response.body)
96
+
97
+ if !json.is_a?(Array)
98
+ raise "BitBay API importer: Invalid response: #{response.body}"
99
+ end
100
+
101
+ transactions.concat(json)
102
+ when Net::HTTPBadRequest
103
+ raise "BitBay API importer: Bad request: #{response}"
104
+ else
105
+ raise "BitBay API importer: Bad response: #{response}"
106
+ end
107
+ end
108
+
109
+ transactions.each_with_index { |tx, i| tx['i'] = i }
110
+ transactions.sort_by! { |tx| [tx['time'], -tx['i']] }
111
+ transactions.each_with_index { |tx, i| tx.delete('i') }
112
+
113
+ File.write(filename, JSON.pretty_generate(transactions))
114
+ end
115
+
116
+ def import_balances
117
+ info = fetch_info
118
+
119
+ info['balances'].select { |k, v|
120
+ v['available'].to_f > 0 || v['locked'].to_f > 0
121
+ }.map { |k, v|
122
+ Balance.new(
123
+ CryptoCurrency.new(k),
124
+ available: BigDecimal.new(v['available']),
125
+ locked: BigDecimal.new(v['locked'])
126
+ )
127
+ }
128
+ end
129
+
130
+ def read_transaction_list(source)
131
+ json = JSON.parse(source.read)
132
+
133
+ matching = []
134
+ transactions = []
135
+
136
+ json.each do |hash|
137
+ entry = HistoryEntry.new(hash)
138
+
139
+ next unless TRANSACTION_TYPES.include?(entry.type)
140
+
141
+ if !matching.empty? && matching.any? { |e| (e.date - entry.date).abs > MAX_TIME_DIFFERENCE }
142
+ transactions << process_matched(matching)
143
+ end
144
+
145
+ matching << entry
146
+ end
147
+
148
+ if !matching.empty?
149
+ transactions << process_matched(matching)
150
+ end
151
+
152
+ transactions
153
+ end
154
+
155
+
156
+ private
157
+
158
+ def process_matched(matching)
159
+ if matching.length % 3 == 0
160
+ purchases = matching.select { |tx| tx.type == OP_PURCHASE }
161
+ sales = matching.select { |tx| tx.type == OP_SALE }
162
+ fees = matching.select { |tx| tx.type == OP_FEE }
163
+
164
+ if purchases.length == sales.length && purchases.length == fees.length
165
+ bought_currency = (purchases + fees).map(&:currency).uniq
166
+ sold_currency = sales.map(&:currency).uniq
167
+
168
+ if bought_currency.length == 1 && sold_currency.length == 1
169
+ matching.clear
170
+
171
+ return Transaction.new(
172
+ exchange: 'BitBay',
173
+ bought_currency: bought_currency.first,
174
+ sold_currency: sold_currency.first,
175
+ time: (purchases + sales + fees).map(&:date).last,
176
+ bought_amount: (purchases + fees).map(&:amount).reduce(&:+),
177
+ sold_amount: -sales.map(&:amount).reduce(&:+)
178
+ )
179
+ end
180
+ end
181
+ end
182
+
183
+ raise "BitBay API importer error: Couldn't match some history lines: #{matching}"
184
+ end
185
+
186
+ def make_request(method, params = {})
187
+ (@public_key && @secret_key) or raise "Public and secret API keys must be provided"
188
+
189
+ url = URI(BASE_URL)
190
+
191
+ params['method'] = method
192
+ params['moment'] = Time.now.to_i
193
+
194
+ param_string = URI.encode_www_form(params)
195
+ hmac = OpenSSL::HMAC.hexdigest('sha512', @secret_key, param_string)
196
+
197
+ Request.post(url) do |request|
198
+ request.body = param_string
199
+ request['API-Key'] = @public_key
200
+ request['API-Hash'] = hmac
201
+ end
202
+ end
203
+
204
+ def fetch_info
205
+ response = make_request('info')
206
+
207
+ case response
208
+ when Net::HTTPSuccess
209
+ json = JSON.parse(response.body)
210
+
211
+ if json['success'] != 1 || json['code'] || json['balances'].nil?
212
+ raise "BitBay API importer: Invalid response: #{response.body}"
213
+ end
214
+
215
+ json
216
+ when Net::HTTPBadRequest
217
+ raise "BitBay API importer: Bad request: #{response}"
218
+ else
219
+ raise "BitBay API importer: Bad response: #{response}"
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,71 @@
1
+ require 'bigdecimal'
2
+ require 'csv'
3
+ require 'time'
4
+
5
+ require_relative 'base'
6
+ require_relative '../currencies'
7
+ require_relative '../transaction'
8
+
9
+ module CoinSync
10
+ module Importers
11
+ class Bitcurex < Base
12
+ register_importer :bitcurex
13
+
14
+ class HistoryEntry
15
+ attr_accessor :lp, :type, :date, :market, :amount, :price, :total, :fee, :fee_currency, :id
16
+
17
+ def initialize(line)
18
+ # TODO: force parsing in Polish timezone
19
+ @lp = line[0].to_i
20
+ @type = line[1]
21
+ @date = Time.parse(line[2])
22
+ @market = FiatCurrency.new(line[3])
23
+ @amount = BigDecimal.new(line[4])
24
+ @price = BigDecimal.new(line[5].split(' ').first)
25
+ @total = BigDecimal.new(line[6].split(' ').first)
26
+ @fee = BigDecimal.new(line[7].split(' ').first)
27
+ @fee_currency = line[7].split(' ').last
28
+ @id = line[8].to_i
29
+ end
30
+ end
31
+
32
+ def read_transaction_list(source)
33
+ csv = CSV.new(source, col_sep: ',')
34
+
35
+ transactions = []
36
+ bitcoin = CryptoCurrency.new('BTC')
37
+
38
+ csv.each do |line|
39
+ next if line.empty?
40
+ next if line[0] == 'LP'
41
+
42
+ entry = HistoryEntry.new(line)
43
+
44
+ if entry.type == 'Kup'
45
+ transactions << Transaction.new(
46
+ exchange: 'Bitcurex',
47
+ bought_currency: bitcoin,
48
+ sold_currency: entry.market,
49
+ time: entry.date,
50
+ bought_amount: entry.amount,
51
+ sold_amount: entry.total
52
+ )
53
+ elsif entry.type == 'Sprzedaj'
54
+ transactions << Transaction.new(
55
+ exchange: 'Bitcurex',
56
+ bought_currency: entry.market,
57
+ sold_currency: bitcoin,
58
+ time: entry.date,
59
+ bought_amount: entry.total,
60
+ sold_amount: entry.amount
61
+ )
62
+ else
63
+ raise "Bitcurex importer error: unexpected entry type '#{entry.type}'"
64
+ end
65
+ end
66
+
67
+ transactions.reverse
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,81 @@
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
+
12
+ module CoinSync
13
+ module Importers
14
+ class BittrexAPI < Base
15
+ register_importer :bittrex_api
16
+
17
+ BASE_URL = "https://bittrex.com/api/v1.1"
18
+
19
+ def initialize(config, params = {})
20
+ super
21
+
22
+ # only "Read Info" permission is required for the key
23
+ @api_key = params['api_key']
24
+ @api_secret = params['api_secret']
25
+ end
26
+
27
+ def can_import?(type)
28
+ @api_key && @api_secret && [:balances].include?(type)
29
+ end
30
+
31
+ def can_build?
32
+ false
33
+ end
34
+
35
+ def import_balances
36
+ response = make_request('/account/getbalances')
37
+
38
+ case response
39
+ when Net::HTTPSuccess
40
+ json = JSON.parse(response.body)
41
+
42
+ if json['success'] != true || !json['result']
43
+ raise "Bittrex importer: Invalid response: #{response.body}"
44
+ end
45
+
46
+ return json['result'].select { |b|
47
+ b['Balance'] > 0
48
+ }.map { |b|
49
+ Balance.new(
50
+ CryptoCurrency.new(b['Currency']),
51
+ available: BigDecimal.new(b['Available'], 0),
52
+ locked: BigDecimal.new(b['Balance'], 0) - BigDecimal.new(b['Available'], 0)
53
+ )
54
+ }
55
+ when Net::HTTPBadRequest
56
+ raise "Bittrex importer: Bad request: #{response}"
57
+ else
58
+ raise "Bittrex importer: Bad response: #{response}"
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def make_request(path, params = {})
65
+ (@api_key && @api_secret) or raise "Public and secret API keys must be provided"
66
+
67
+ params['apikey'] = @api_key
68
+ params['nonce'] = (Time.now.to_f * 1000).to_i
69
+
70
+ url = URI(BASE_URL + path)
71
+ url.query = URI.encode_www_form(params)
72
+
73
+ hmac = OpenSSL::HMAC.hexdigest('sha512', @api_secret, url.to_s)
74
+
75
+ Request.get(url) do |request|
76
+ request['apisign'] = hmac
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end