coinsync 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +255 -0
- data/bin/coinsync +78 -0
- data/doc/importers.md +201 -0
- data/lib/coinsync.rb +18 -0
- data/lib/coinsync/balance.rb +19 -0
- data/lib/coinsync/balance_task.rb +65 -0
- data/lib/coinsync/build_task.rb +43 -0
- data/lib/coinsync/builder.rb +32 -0
- data/lib/coinsync/config.rb +94 -0
- data/lib/coinsync/crypto_classifier.rb +27 -0
- data/lib/coinsync/currencies.rb +35 -0
- data/lib/coinsync/currency_converter.rb +65 -0
- data/lib/coinsync/currency_converters/all.rb +1 -0
- data/lib/coinsync/currency_converters/base.rb +46 -0
- data/lib/coinsync/currency_converters/cache.rb +34 -0
- data/lib/coinsync/currency_converters/fixer.rb +36 -0
- data/lib/coinsync/currency_converters/nbp.rb +38 -0
- data/lib/coinsync/formatter.rb +44 -0
- data/lib/coinsync/import_task.rb +37 -0
- data/lib/coinsync/importers/all.rb +1 -0
- data/lib/coinsync/importers/ark_voting.rb +121 -0
- data/lib/coinsync/importers/base.rb +35 -0
- data/lib/coinsync/importers/binance_api.rb +210 -0
- data/lib/coinsync/importers/bitbay20.rb +144 -0
- data/lib/coinsync/importers/bitbay_api.rb +224 -0
- data/lib/coinsync/importers/bitcurex.rb +71 -0
- data/lib/coinsync/importers/bittrex_api.rb +81 -0
- data/lib/coinsync/importers/bittrex_csv.rb +75 -0
- data/lib/coinsync/importers/changelly.rb +57 -0
- data/lib/coinsync/importers/circle.rb +58 -0
- data/lib/coinsync/importers/default.rb +90 -0
- data/lib/coinsync/importers/etherdelta.rb +93 -0
- data/lib/coinsync/importers/kraken_api.rb +134 -0
- data/lib/coinsync/importers/kraken_common.rb +137 -0
- data/lib/coinsync/importers/kraken_csv.rb +28 -0
- data/lib/coinsync/importers/kucoin_api.rb +172 -0
- data/lib/coinsync/importers/lisk_voting.rb +110 -0
- data/lib/coinsync/outputs/all.rb +1 -0
- data/lib/coinsync/outputs/base.rb +32 -0
- data/lib/coinsync/outputs/list.rb +123 -0
- data/lib/coinsync/outputs/raw.rb +45 -0
- data/lib/coinsync/outputs/summary.rb +48 -0
- data/lib/coinsync/request.rb +31 -0
- data/lib/coinsync/run_command_task.rb +20 -0
- data/lib/coinsync/source.rb +43 -0
- data/lib/coinsync/source_filter.rb +10 -0
- data/lib/coinsync/table_printer.rb +29 -0
- data/lib/coinsync/transaction.rb +125 -0
- data/lib/coinsync/version.rb +3 -0
- metadata +95 -0
data/lib/coinsync.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require "coinsync/balance"
|
2
|
+
require "coinsync/balance_task"
|
3
|
+
require "coinsync/build_task"
|
4
|
+
require "coinsync/builder"
|
5
|
+
require "coinsync/config"
|
6
|
+
require "coinsync/crypto_classifier"
|
7
|
+
require "coinsync/currencies"
|
8
|
+
require "coinsync/currency_converter"
|
9
|
+
require "coinsync/formatter"
|
10
|
+
require "coinsync/import_task"
|
11
|
+
require "coinsync/request"
|
12
|
+
require "coinsync/run_command_task"
|
13
|
+
require "coinsync/source"
|
14
|
+
require "coinsync/transaction"
|
15
|
+
require "coinsync/version"
|
16
|
+
|
17
|
+
module CoinSync
|
18
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module CoinSync
|
2
|
+
class Balance
|
3
|
+
attr_reader :currency, :available, :locked
|
4
|
+
|
5
|
+
def initialize(currency, available: BigDecimal(0), locked: BigDecimal(0))
|
6
|
+
@currency = currency
|
7
|
+
@available = available
|
8
|
+
@locked = locked
|
9
|
+
end
|
10
|
+
|
11
|
+
def +(balance)
|
12
|
+
return Balance.new(
|
13
|
+
@currency,
|
14
|
+
available: @available + balance.available,
|
15
|
+
locked: @locked + balance.locked
|
16
|
+
)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require_relative 'balance'
|
2
|
+
require_relative 'formatter'
|
3
|
+
require_relative 'importers/all'
|
4
|
+
|
5
|
+
module CoinSync
|
6
|
+
class BalanceTask
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
@formatter = Formatter.new(config)
|
10
|
+
end
|
11
|
+
|
12
|
+
def run(selected = nil, except = nil)
|
13
|
+
balances = {}
|
14
|
+
columns = []
|
15
|
+
rows = []
|
16
|
+
|
17
|
+
@config.filtered_sources(selected, except).each do |key, source|
|
18
|
+
importer = source.importer
|
19
|
+
|
20
|
+
if importer.respond_to?(:can_import?)
|
21
|
+
if importer.can_import?(:balances)
|
22
|
+
print "[#{key}] Importing balances... "
|
23
|
+
|
24
|
+
columns << key
|
25
|
+
|
26
|
+
importer.import_balances.each do |balance|
|
27
|
+
balances[balance.currency] ||= {}
|
28
|
+
balances[balance.currency][key] = balance
|
29
|
+
balances[balance.currency][nil] ||= Balance.new(balance.currency)
|
30
|
+
balances[balance.currency][nil] += balance
|
31
|
+
end
|
32
|
+
|
33
|
+
puts "√"
|
34
|
+
else
|
35
|
+
puts "[#{key}] Skipping import"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
columns.sort!
|
41
|
+
|
42
|
+
balances.keys.sort.each do |coin|
|
43
|
+
row = [coin.code, '|']
|
44
|
+
row += columns.map { |e|
|
45
|
+
available = balances[coin][e]&.available
|
46
|
+
locked = balances[coin][e]&.locked
|
47
|
+
available ? @formatter.format_crypto(available) + (locked > 0 ? ' (+)' : '') : ''
|
48
|
+
}
|
49
|
+
row << '|'
|
50
|
+
row << @formatter.format_crypto(balances[coin][nil].available)
|
51
|
+
rows << row
|
52
|
+
end
|
53
|
+
|
54
|
+
puts
|
55
|
+
|
56
|
+
printer = TablePrinter.new
|
57
|
+
printer.print_table(
|
58
|
+
['Coin', '|'] + columns + ['|', 'TOTAL'],
|
59
|
+
rows,
|
60
|
+
alignment: [:ljust, :center] + columns.map { |e| :rjust } + [:center, :rjust],
|
61
|
+
separator: ' '
|
62
|
+
)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
require_relative 'builder'
|
4
|
+
require_relative 'currency_converter'
|
5
|
+
require_relative 'outputs/all'
|
6
|
+
|
7
|
+
module CoinSync
|
8
|
+
class BuildTask
|
9
|
+
def initialize(config)
|
10
|
+
@config = config
|
11
|
+
end
|
12
|
+
|
13
|
+
def run(output_name, args = [])
|
14
|
+
if output_name.nil?
|
15
|
+
puts "Error: Build task name not given"
|
16
|
+
exit 1
|
17
|
+
end
|
18
|
+
|
19
|
+
output_class = Outputs.registered[output_name.to_sym]
|
20
|
+
|
21
|
+
if output_class.nil?
|
22
|
+
puts "Unknown build task: #{output_name}"
|
23
|
+
exit 1
|
24
|
+
end
|
25
|
+
|
26
|
+
FileUtils.mkdir_p 'build'
|
27
|
+
|
28
|
+
builder = Builder.new(@config)
|
29
|
+
transactions = builder.build_transaction_list
|
30
|
+
|
31
|
+
output = output_class.new(@config, "build/#{output_name}.csv")
|
32
|
+
|
33
|
+
if output.requires_currency_conversion?
|
34
|
+
if @config.convert_to_currency
|
35
|
+
converter = CurrencyConverter.new(@config)
|
36
|
+
converter.process_transactions(transactions)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
output.process_transactions(transactions, *args)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require_relative 'importers/all'
|
2
|
+
|
3
|
+
module CoinSync
|
4
|
+
class Builder
|
5
|
+
attr_reader :transactions
|
6
|
+
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_transaction_list
|
12
|
+
transactions = []
|
13
|
+
|
14
|
+
@config.sources.each do |key, source|
|
15
|
+
if source.importer.can_build?
|
16
|
+
if source.filename.nil?
|
17
|
+
raise "No filename specified for '#{key}', please add a 'file' parameter."
|
18
|
+
end
|
19
|
+
|
20
|
+
File.open(source.filename, 'r') do |file|
|
21
|
+
transactions.concat(source.importer.read_transaction_list(file))
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
transactions.each_with_index { |tx, i| tx.number = i + 1 }
|
27
|
+
|
28
|
+
@transactions = transactions.sort_by { |tx| [tx.time, tx.number] }
|
29
|
+
@transactions.each_with_index { |tx, i| tx.number = i + 1 }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
require_relative 'source'
|
4
|
+
|
5
|
+
module CoinSync
|
6
|
+
class Config
|
7
|
+
attr_reader :source_definitions, :settings
|
8
|
+
|
9
|
+
DEFAULT_CONFIG = 'config.yml'
|
10
|
+
|
11
|
+
def self.load_from_file(filename = nil)
|
12
|
+
yaml = YAML.load(File.read(filename || DEFAULT_CONFIG))
|
13
|
+
self.new(yaml, filename)
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(yaml, config_path = nil)
|
17
|
+
@source_definitions = yaml['sources'] or raise 'Config: No sources listed'
|
18
|
+
@settings = yaml['settings'] || {}
|
19
|
+
@labels = @settings['labels'] || {}
|
20
|
+
|
21
|
+
if includes = yaml['include']
|
22
|
+
includes.each do |file|
|
23
|
+
directory = config_path ? [config_path, '..'] : ['.']
|
24
|
+
require(File.expand_path(File.join(*directory, file)))
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
set_timezone(timezone) if timezone
|
29
|
+
end
|
30
|
+
|
31
|
+
def sources
|
32
|
+
@sources ||= Hash[@source_definitions.keys.map { |key| [key, Source.new(self, key)] }]
|
33
|
+
end
|
34
|
+
|
35
|
+
def filtered_sources(selected, except = nil)
|
36
|
+
included = if selected.nil? || selected.empty?
|
37
|
+
sources.values
|
38
|
+
else
|
39
|
+
selected = [selected] unless selected.is_a?(Array)
|
40
|
+
|
41
|
+
selected.map do |key|
|
42
|
+
sources[key] or raise "Source not found in the config file: '#{key}'"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
if except
|
47
|
+
except = [except] unless except.is_a?(Array)
|
48
|
+
included -= except.map { |key| sources[key] }
|
49
|
+
end
|
50
|
+
|
51
|
+
Hash[included.map { |source| [source.key, source] }]
|
52
|
+
end
|
53
|
+
|
54
|
+
def set_timezone(timezone)
|
55
|
+
ENV['TZ'] = timezone
|
56
|
+
end
|
57
|
+
|
58
|
+
def base_cryptocurrencies
|
59
|
+
settings['base_cryptocurrencies'] || ['USDT', 'BTC', 'ETH', 'BNB', 'KCS', 'LTC', 'BCH', 'NEO']
|
60
|
+
end
|
61
|
+
|
62
|
+
def column_separator
|
63
|
+
settings['column_separator'] || ','
|
64
|
+
end
|
65
|
+
|
66
|
+
def decimal_separator
|
67
|
+
custom_decimal_separator || '.'
|
68
|
+
end
|
69
|
+
|
70
|
+
def custom_decimal_separator
|
71
|
+
settings['decimal_separator']
|
72
|
+
end
|
73
|
+
|
74
|
+
def convert_to_currency
|
75
|
+
settings['convert_to'] ? FiatCurrency.new(settings['convert_to']) : nil
|
76
|
+
end
|
77
|
+
|
78
|
+
def currency_converter
|
79
|
+
settings['convert_with']&.to_sym || :fixer
|
80
|
+
end
|
81
|
+
|
82
|
+
def time_format
|
83
|
+
settings['time_format']
|
84
|
+
end
|
85
|
+
|
86
|
+
def timezone
|
87
|
+
settings['timezone']
|
88
|
+
end
|
89
|
+
|
90
|
+
def translate(label)
|
91
|
+
@labels[label] || label
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require_relative 'currencies'
|
2
|
+
|
3
|
+
module CoinSync
|
4
|
+
class CryptoClassifier
|
5
|
+
MAX_INDEX = 1_000_000
|
6
|
+
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
@base_currencies = config.base_cryptocurrencies.map { |c| CryptoCurrency.new(c) }
|
10
|
+
end
|
11
|
+
|
12
|
+
def is_purchase?(transaction)
|
13
|
+
bought_index = @base_currencies.index(transaction.bought_currency) || MAX_INDEX
|
14
|
+
sold_index = @base_currencies.index(transaction.sold_currency) || MAX_INDEX
|
15
|
+
|
16
|
+
if bought_index < sold_index
|
17
|
+
false
|
18
|
+
elsif bought_index > sold_index
|
19
|
+
true
|
20
|
+
else
|
21
|
+
raise "Couldn't determine which cryptocurrency is the base one: #{transaction.bought_currency.code} vs " +
|
22
|
+
"#{transaction.sold_currency.code}. Use the `base_cryptocurrencies` setting to explicitly choose " +
|
23
|
+
"base cryptocurrencies."
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module CoinSync
|
2
|
+
class Currency < Struct.new(:code)
|
3
|
+
def <=>(other)
|
4
|
+
self.code <=> other.code
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class FiatCurrency < Currency
|
9
|
+
def fiat?
|
10
|
+
true
|
11
|
+
end
|
12
|
+
|
13
|
+
def crypto?
|
14
|
+
false
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class CryptoCurrency < Currency
|
19
|
+
MAPPING = {
|
20
|
+
'XRB' => 'NANO'
|
21
|
+
}
|
22
|
+
|
23
|
+
def initialize(code)
|
24
|
+
super(MAPPING[code] || code)
|
25
|
+
end
|
26
|
+
|
27
|
+
def fiat?
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
def crypto?
|
32
|
+
true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
require 'time'
|
3
|
+
|
4
|
+
require_relative 'currencies'
|
5
|
+
require_relative 'currency_converters/all'
|
6
|
+
require_relative 'transaction'
|
7
|
+
|
8
|
+
module CoinSync
|
9
|
+
class CurrencyConverter
|
10
|
+
def initialize(config)
|
11
|
+
@config = config
|
12
|
+
@target_currency = config.convert_to_currency
|
13
|
+
|
14
|
+
converter_class = CurrencyConverters.registered[config.currency_converter]
|
15
|
+
|
16
|
+
if converter_class
|
17
|
+
@converter = converter_class.new
|
18
|
+
else
|
19
|
+
raise "Unknown currency converter #{config.currency_converter}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def process_transactions(transactions)
|
24
|
+
transactions.each do |tx|
|
25
|
+
print '.'
|
26
|
+
|
27
|
+
if tx.bought_currency.fiat? && tx.bought_currency != @target_currency
|
28
|
+
tx.converted = Transaction::ConvertedAmounts.new
|
29
|
+
tx.converted.bought_currency = @target_currency
|
30
|
+
tx.converted.exchange_rate = @converter.convert(
|
31
|
+
BigDecimal.new(1),
|
32
|
+
from: tx.bought_currency,
|
33
|
+
to: @target_currency,
|
34
|
+
date: tx.time.to_date
|
35
|
+
)
|
36
|
+
tx.converted.bought_amount = tx.bought_amount * tx.converted.exchange_rate
|
37
|
+
tx.converted.sold_currency = tx.sold_currency
|
38
|
+
tx.converted.sold_amount = tx.sold_amount
|
39
|
+
elsif tx.sold_currency.fiat? && tx.sold_currency != @target_currency
|
40
|
+
tx.converted = Transaction::ConvertedAmounts.new
|
41
|
+
tx.converted.bought_currency = tx.bought_currency
|
42
|
+
tx.converted.bought_amount = tx.bought_amount
|
43
|
+
tx.converted.sold_currency = @target_currency
|
44
|
+
|
45
|
+
if tx.sold_currency.code
|
46
|
+
tx.converted.exchange_rate = @converter.convert(
|
47
|
+
BigDecimal.new(1),
|
48
|
+
from: tx.sold_currency,
|
49
|
+
to: @target_currency,
|
50
|
+
date: tx.time.to_date
|
51
|
+
)
|
52
|
+
tx.converted.sold_amount = tx.sold_amount * tx.converted.exchange_rate
|
53
|
+
else
|
54
|
+
tx.converted.exchange_rate = nil
|
55
|
+
tx.converted.sold_amount = BigDecimal.new(0)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
@converter.finalize
|
61
|
+
|
62
|
+
puts
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
Dir[File.join(File.dirname(__FILE__), '*.rb')].each { |f| require(f) }
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'bigdecimal'
|
2
|
+
|
3
|
+
require_relative 'cache'
|
4
|
+
require_relative '../currencies'
|
5
|
+
|
6
|
+
module CoinSync
|
7
|
+
module CurrencyConverters
|
8
|
+
def self.registered
|
9
|
+
@converters ||= {}
|
10
|
+
end
|
11
|
+
|
12
|
+
class Base
|
13
|
+
def self.register_converter(key)
|
14
|
+
if CurrencyConverters.registered[key]
|
15
|
+
raise "Currency converter has already been registered at '#{key}'"
|
16
|
+
else
|
17
|
+
CurrencyConverters.registered[key] = self
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize
|
22
|
+
@cache = Cache.new(self.class.name.downcase.split('::').last)
|
23
|
+
end
|
24
|
+
|
25
|
+
def convert(amount, from:, to:, date:)
|
26
|
+
(amount > 0) or raise "#{self.class}: amount should be positive"
|
27
|
+
(amount.is_a?(BigDecimal)) or raise "#{self.class}: 'amount' should be a BigDecimal"
|
28
|
+
(from.is_a?(FiatCurrency)) or raise "#{self.class}: 'from' should be a FiatCurrency"
|
29
|
+
(to.is_a?(FiatCurrency)) or raise "#{self.class}: 'to' should be a FiatCurrency"
|
30
|
+
(date.is_a?(Date)) or raise "#{self.class}: 'date' should be a Date"
|
31
|
+
|
32
|
+
if rate = @cache[from, to, date]
|
33
|
+
return rate * amount
|
34
|
+
else
|
35
|
+
rate = fetch_conversion_rate(from: from, to: to, date: date)
|
36
|
+
@cache[from, to, date] = rate
|
37
|
+
return rate * amount
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def finalize
|
42
|
+
@cache.save
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|