coinsync 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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