devilicious 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 86c8535c1f1dc7924d4344803a1490359657c349
4
+ data.tar.gz: 0de03730367cbca3c56fb8c66f1c7a05d32663c0
5
+ SHA512:
6
+ metadata.gz: 559c597ff4ff70c7e6057016a8d47b73f7eb227f4a60e9cffff8792ecf83fc669ce8df6a45edbba9f68d3a0aa9762a8919397dca93ed7c40b8f12244ca5aeb43
7
+ data.tar.gz: 976cbe3219761aef3e78bef22e994eb50a29fb3a9367881dcab241ffcada2870f6ed4b249b667f522371924bf663758a249a8f641bb27f8315bb8285c252cb83
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in devilicious.gemspec
4
+ gemspec
@@ -0,0 +1,15 @@
1
+ Devilicious
2
+ Copyright (C) 2014 Cédric Félizard
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
@@ -0,0 +1,52 @@
1
+ # Devilicious
2
+
3
+ **Disclaimer: Do not expect to make money with this program.**
4
+
5
+ This RubyGem is just an experiment to find out whether or not there are (still) arbitrage opportunities across Bitcoin markets (as of August 2014).
6
+
7
+ Spoiler: Meh, not really.
8
+
9
+ Supported exchanges are:
10
+
11
+ - Kraken (EUR)
12
+ - BitcoinDe (EUR)
13
+ - BTCE (EUR)
14
+ - HitBTC (EUR)
15
+ - Bitcurex (EUR)
16
+ - Bitstamp (USD)
17
+ - BitNZ (NZD)
18
+ - ANXBTC (CHF)
19
+
20
+ I've been running it for a couple of days and my findings are:
21
+
22
+ - the best volume to trade is usually in the 1 to 5 bitcoins range
23
+ - you won't make more than €30/$40 even trading when the price is on a rollercoaster (specifically mid August 2014)
24
+ - you'll need to trade 3+ bitcoins to make those 40 bucks
25
+ - in order to trade across a bunch of markets, you'll need to have a lot of funds in every exchanges (since you want to buy/sell at the same time)
26
+ - high frequency trading might work out better but fuck that, it just adds noise to the blockchain
27
+
28
+ **Disclaimer #2: This code is quite crappy.**
29
+
30
+ This is a just-for-fun quick-and-dirty not-optimized-at-all pretty-dumb program.
31
+ Worst of all, it doesn't even have tests!
32
+
33
+ **Disclaimer #3: Obviously, use this program at your own risks!**
34
+
35
+ ## Installation
36
+
37
+ `gem install devilicious`
38
+
39
+ ## Usage
40
+
41
+ `devilicious --help` is your friend.
42
+
43
+ Example: `devilicious -f Table -m 2 -b 30`
44
+
45
+ ## License
46
+
47
+ AGPLv3
48
+
49
+ ## Bonus link
50
+
51
+ http://www.reddit.com/r/Bitcoin/comments/2dqhiy/8month_high/cjs416y
52
+
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require "devilicious"
5
+ rescue LoadError
6
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
7
+ Dir[File.expand_path("../lib/**/*.rb", __dir__)].each { |file| require file }
8
+ end
9
+
10
+
11
+ arbitrer = Devilicious::Arbitrer.new
12
+ arbitrer.run!
13
+
@@ -0,0 +1,24 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "devilicious"
7
+ spec.version = "1.0.0"
8
+ spec.authors = ["Cédric Félizard"]
9
+ spec.email = ["cedric@felizard.fr"]
10
+ spec.summary = %q{Multi-currency Bitcoin arbitrage bot}
11
+ spec.description = spec.summary
12
+ spec.homepage = ""
13
+ spec.license = "AGPLv3"
14
+
15
+ spec.files = `git ls-files`.split($/)
16
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency "retryable"
21
+
22
+ spec.add_development_dependency "bundler", "~> 1.5"
23
+ spec.add_development_dependency "rake"
24
+ end
@@ -0,0 +1,6 @@
1
+ module Devilicious
2
+ def self.config
3
+ @config ||= Config.parse(ARGV)
4
+ end
5
+ end
6
+
@@ -0,0 +1,160 @@
1
+ module Devilicious
2
+ class Arbitrer
3
+ def markets
4
+ @markets ||= [
5
+
6
+ Market::Kraken,
7
+ Market::BitcoinDe,
8
+ Market::BtcE,
9
+ Market::HitBtc,
10
+ Market::Bitcurex,
11
+ Market::Bitstamp,
12
+ Market::BitNz,
13
+ Market::AnxBtc,
14
+
15
+ ].map { |market| market.new }
16
+ end
17
+
18
+ def run!
19
+ @market_queue = []
20
+ spawn_observers!
21
+
22
+ loop do
23
+ sleep 1 while @market_queue.empty?
24
+
25
+ market_1 = @market_queue.shift
26
+
27
+ markets.each do |market_2|
28
+ next if market_1 == market_2
29
+
30
+ if market_2.order_book.nil?
31
+ Log.debug "Order book for #{market_2} not available yet, skipping"
32
+ next
33
+ end
34
+
35
+ # dup everything to avoid race conditions while calculating opportunities
36
+ order_book_1 = market_1.order_book.dup
37
+ order_book_2 = market_2.order_book.dup
38
+ order_book_1.market = market_1.dup
39
+ order_book_2.market = market_2.dup
40
+
41
+ check_for_opportunity(order_book_1, order_book_2)
42
+ check_for_opportunity(order_book_2, order_book_1)
43
+ end
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def spawn_observers!
50
+ Thread.abort_on_exception = true
51
+ trap("EXIT", -> { Thread.list.each(&:kill) } )
52
+
53
+ Thread.new do
54
+ timeout = Devilicious.config.market_refresh_rate
55
+
56
+ loop do
57
+ threads = markets.map do |market|
58
+ Log.debug "Refreshing order book for #{market}..."
59
+ Thread.new { market.refresh_order_book!; @market_queue << market }
60
+ end
61
+
62
+ sleep timeout
63
+
64
+ alive_threads = threads.select(&:alive?)
65
+ unless alive_threads.empty?
66
+ Log.warn "Timeout after #{timeout} seconds for #{alive_threads.size} observer threads"
67
+ end
68
+
69
+ threads.each do |thread|
70
+ thread.abort_on_exception = false
71
+ thread.kill
72
+ end
73
+
74
+ threads.each(&:join)
75
+ end
76
+ end
77
+ end
78
+
79
+ def check_for_opportunity(order_book_1, order_book_2)
80
+ Log.debug "Checking opportunity buying from #{order_book_1.market} and selling at #{order_book_2.market}... "
81
+ if opportunity = find_best_opportunity(order_book_1, order_book_2)
82
+ threshold = Money.new(Devilicious.config.beep_profit_threshold, Devilicious.config.default_fiat_currency)
83
+ if threshold > 0 && opportunity.profit >= threshold
84
+ Thread.new do
85
+ 3.times { system "/usr/bin/aplay", "-q", "#{__dir__}/sounds/boom.wav" }
86
+ end
87
+ end
88
+
89
+ formatter = Formatter.list[Devilicious.config.formatter]
90
+ formatter.output(opportunity)
91
+ end
92
+ end
93
+
94
+ def find_best_opportunity(order_book_1, order_book_2)
95
+ return unless order_book_1.lowest_ask.price < order_book_2.highest_bid.price
96
+
97
+ initial_ask_offer = order_book_1.weighted_asks_up_to(order_book_2.highest_bid.price)
98
+ initial_bid_offer = order_book_2.weighted_bids_down_to(order_book_1.lowest_ask.price)
99
+
100
+ _dummy = initial_ask_offer.price + initial_bid_offer.price # NOTE: will raise if currency mismatch
101
+
102
+ max_volume = [initial_ask_offer.volume, initial_bid_offer.volume].min
103
+
104
+ best_offer_limited_volume = find_best_volume(
105
+ order_book_1, order_book_2,
106
+ [max_volume, Devilicious.config.max_volume].min
107
+ )
108
+
109
+ best_offer_unlimited_volume = find_best_volume(
110
+ order_book_1, order_book_2,
111
+ [max_volume, BigDecimal.new("22E6")].min
112
+ )
113
+
114
+ best_volume = if best_offer_limited_volume.profit > best_offer_unlimited_volume.profit
115
+ best_offer_limited_volume
116
+ else
117
+ best_offer_unlimited_volume
118
+ end.volume
119
+
120
+ return if best_offer_limited_volume.profit <= 0
121
+
122
+ best_offer_limited_volume.best_volume = best_volume
123
+
124
+ best_offer_limited_volume
125
+ end
126
+
127
+ def find_best_volume(order_book_1, order_book_2, max_volume)
128
+ volume = best_volume = BigDecimal.new(Devilicious.config.min_volume)
129
+ best_profit = 0
130
+
131
+ while volume <= max_volume
132
+ ask_offer = order_book_1.min_ask_price_for_volume(order_book_2.highest_bid.price, volume)
133
+ bid_offer = order_book_2.max_bid_price_for_volume(order_book_1.lowest_ask.price, volume)
134
+
135
+ fee = (
136
+ ask_offer.price * order_book_1.market.trade_fee +
137
+ bid_offer.price * order_book_2.market.trade_fee
138
+ ) * volume
139
+
140
+ profit = (bid_offer.price - ask_offer.price) * volume - fee
141
+
142
+ if profit > best_profit
143
+ best_profit, best_volume, best_profit_fee = profit, volume, fee
144
+ end
145
+
146
+ volume += BigDecimal.new("0.1")
147
+ end
148
+
149
+ OpenStruct.new(
150
+ order_book_1: order_book_1,
151
+ order_book_2: order_book_2,
152
+ ask_offer: order_book_1.min_ask_price_for_volume(order_book_2.highest_bid.price, best_volume),
153
+ bid_offer: order_book_2.max_bid_price_for_volume(order_book_1.lowest_ask.price, best_volume),
154
+ volume: best_volume,
155
+ profit: best_profit,
156
+ fee: best_profit_fee,
157
+ )
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,54 @@
1
+ require "optparse"
2
+ require "ostruct"
3
+
4
+ module Devilicious
5
+ class Config
6
+ def self.parse(args)
7
+ config = OpenStruct.new
8
+ config.debug = false
9
+ config.verbose = false
10
+ config.formatter = "Verbose"
11
+ config.max_volume = BigDecimal.new("10")
12
+ config.min_volume = BigDecimal.new("0.1")
13
+ config.beep_profit_threshold = BigDecimal.new("-1") # negative means disabled
14
+ config.default_fiat_currency = "EUR" # ideally the most used currency so we do as little conversions as possible
15
+ config.market_refresh_rate = 30 # order books expire delay in seconds
16
+
17
+ opt_parser = OptionParser.new do |opts|
18
+ opts.banner = "Usage: devilicious [config]"
19
+
20
+ opts.separator ""
21
+
22
+ opts.on("-f", "--formatter TYPE", Formatter.list.keys,
23
+ "Select formatter (#{Formatter.list.keys.sort.join(", ")})") do |f|
24
+ config.formatter = f
25
+ end
26
+
27
+ opts.on("-m", "--max-volume N", "Maximum volume to trade") do |m|
28
+ config.max_volume = BigDecimal.new(m)
29
+ end
30
+
31
+ opts.on("-b", "--beep-profit-threshold N", "Beep the fuck out of the speakers when profit threshold is reached") do |t|
32
+ config.beep_profit_threshold = BigDecimal.new(t)
33
+ end
34
+
35
+ opts.on("-v", "--verbose", "Run verbosely") do |v|
36
+ config.verbose = v
37
+ end
38
+
39
+ opts.on("-d", "--debug", "Debug mode") do |d|
40
+ config.debug = d
41
+ end
42
+
43
+ opts.on_tail("-h", "--help", "Show this message") do
44
+ puts opts
45
+ exit
46
+ end
47
+ end
48
+
49
+ opt_parser.parse!(args)
50
+ config.freeze
51
+ end
52
+ end
53
+ end
54
+
@@ -0,0 +1,81 @@
1
+ require "retryable"
2
+
3
+ module Devilicious
4
+ class CurrencyConverter
5
+ @rates = {}
6
+
7
+ def self.convert(amount, from, to)
8
+ amount * rate(from, to)
9
+ end
10
+
11
+ def self.rate from, to
12
+ pair = [from, to].join
13
+
14
+ if @rates[pair].nil? || @rates[pair].updated_at < Time.now - 10*60
15
+
16
+ Log.debug "Refreshing #{pair} rate..."
17
+
18
+ rate = begin
19
+ RateExchange.get_rate(from, to)
20
+ rescue => exception
21
+ Log.warn "Could not retrieve exchange rate from RateExchange: #{exception.inspect}"
22
+
23
+ YahooExchange.get_rate(from, to)
24
+ end
25
+
26
+ @rates[pair] = OpenStruct.new(
27
+ rate: rate,
28
+ updated_at: Time.now
29
+ ).freeze
30
+
31
+ end
32
+
33
+ @rates[pair].rate
34
+ end
35
+
36
+ class RateExchange
37
+ URL = "http://rate-exchange.appspot.com/currency?from=%s&to=%s".freeze
38
+
39
+ def self.get_rate(from, to)
40
+ url = URL % [from, to]
41
+ json = get_json(url)
42
+ rate = json["rate"].to_s
43
+ BigDecimal.new(rate)
44
+ end
45
+
46
+ def self.get_json(url)
47
+ retryable(tries: 3, sleep: 1) do
48
+ json = open(url).read
49
+ JSON.parse(json)
50
+ end
51
+ end
52
+ end
53
+
54
+ class YahooExchange
55
+ URL = "http://download.finance.yahoo.com/d/quotes.csv?s=%s%s=X&f=sl1d1&e=.csv".freeze
56
+
57
+ def self.get_rate(from, to)
58
+ url = URL % [from, to]
59
+ csv = get_csv(url)
60
+ rate = BigDecimal.new(csv[1].to_s)
61
+
62
+ if rate <= 1E-3
63
+ Log.warn "Cannot retrieve exchange rate for #{from}#{to}, not enough precision, using the opposite pair"
64
+
65
+ url = URL % [to, from]
66
+ csv = get_csv(url)
67
+ rate = BigDecimal.new(1) / BigDecimal.new(csv[1].to_s)
68
+ end
69
+
70
+ rate
71
+ end
72
+
73
+ def self.get_csv(url)
74
+ retryable(tries: 3, sleep: 1) do
75
+ csv = open(url).read
76
+ csv.split(",")
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,22 @@
1
+ module Devilicious
2
+ module Formatter
3
+ def self.list
4
+ @list ||= {}
5
+ end
6
+
7
+ class Base
8
+ def self.inherited(child)
9
+ formatter = child.new
10
+ Formatter.list[formatter.to_s] = formatter
11
+ end
12
+
13
+ def to_s
14
+ self.class.to_s.gsub(/.*::/, "")
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ # Dir["#{__dir__}/**/*.rb"].each do |formatter|
21
+ # require formatter
22
+ # end
@@ -0,0 +1,17 @@
1
+ module Devilicious
2
+ module Formatter
3
+ class Summary < Base
4
+ def output(opportunity)
5
+ pair = [opportunity.order_book_1.market, " to ", opportunity.order_book_2.market, " " * 3].map(&:to_s).join
6
+ @best_trades ||= {}
7
+ @best_trades[pair] = opportunity
8
+
9
+ Log.info "", timestamp: false
10
+ @best_trades.sort_by { |_, opportunity| opportunity.profit }.each do |pair, opportunity|
11
+ Log.info "#{pair} \t#{opportunity.profit} with #{opportunity.volume.to_f} XBT", timestamp: false
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+
@@ -0,0 +1,20 @@
1
+ module Devilicious
2
+ module Formatter
3
+ class Table < Base
4
+ def output(opportunity)
5
+ pair = [opportunity.order_book_1.market, " to ", opportunity.order_book_2.market, " " * 4].map(&:to_s).join
6
+ @best_trades ||= {}
7
+ @best_trades[pair] = opportunity
8
+
9
+ Log.info "", timestamp: false
10
+ Log.info "PAIR \tPROFIT \tVOLUME \tBUY \tSELL", timestamp: false
11
+ @best_trades.sort_by { |_, opportunity| opportunity.profit }.each do |pair, opportunity|
12
+ pair = pair.dup << " " * [30 - pair.size, 0].max # padding
13
+ Log.info [pair, opportunity.profit, opportunity.volume.to_f, opportunity.ask_offer.price, opportunity.bid_offer.price].join(" \t"), timestamp: false
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+
@@ -0,0 +1,26 @@
1
+ module Devilicious
2
+ module Formatter
3
+ class Verbose < Base
4
+ def output(opportunity)
5
+ fiat_out = opportunity.ask_offer.price * opportunity.volume
6
+ fiat_in = opportunity.bid_offer.price * opportunity.volume
7
+
8
+ opportunity.ask_offer.price = opportunity.ask_offer.price.exchange_to(opportunity.order_book_1.market.fiat_currency)
9
+ opportunity.bid_offer.price = opportunity.bid_offer.price.exchange_to(opportunity.order_book_2.market.fiat_currency)
10
+
11
+ volume = "#{opportunity.volume.to_f} XBT "
12
+ volume << if opportunity.volume == opportunity.best_volume
13
+ "(BEST VOLUME!)"
14
+ else
15
+ "(best #{opportunity.best_volume.to_f})"
16
+ end
17
+
18
+ Log.info \
19
+ "BUY \e[1m#{volume}\e[m from \e[1m#{opportunity.order_book_1.market}\e[m for #{fiat_out} at \e[1m#{opportunity.ask_offer.price}\e[m (#{opportunity.ask_offer.weighted_price} weighted average)" <<
20
+ " and SELL at \e[1m#{opportunity.order_book_2.market}\e[m for #{fiat_in} at \e[1m#{opportunity.bid_offer.price}\e[m (#{opportunity.bid_offer.weighted_price})" <<
21
+ " - PROFIT = \e[1m#{opportunity.profit}\e[m (including #{opportunity.fee} fee)"
22
+ end
23
+ end
24
+ end
25
+ end
26
+
@@ -0,0 +1,29 @@
1
+ module Devilicious
2
+ module Log
3
+ @semaphore ||= Mutex.new
4
+
5
+ module_function
6
+
7
+ def info message, options = {}
8
+ options = {
9
+ output: $stdout,
10
+ timestamp: true
11
+ }.merge(options)
12
+
13
+ message = "#{Time.now} #{message}" if options.fetch(:timestamp)
14
+
15
+ @semaphore.synchronize { options.fetch(:output).puts message }
16
+ end
17
+
18
+ def debug message, options = {}
19
+ info message, options if Devilicious.config.debug
20
+ end
21
+
22
+ def warn message, options = {}
23
+ options = {output: $stderr}.merge(options)
24
+
25
+ info "[WARN] #{message}", options
26
+ end
27
+ end
28
+ end
29
+
@@ -0,0 +1,42 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class AnxBtc < Base
6
+ def fiat_currency
7
+ "CHF"
8
+ end
9
+
10
+ def trade_fee
11
+ BigDecimal.new("0.005").freeze # 0.5%??? - see https://anxbtc.com/faq#tab1
12
+ end
13
+
14
+ def refresh_order_book!
15
+ json = get_json("https://anxpro.com/api/2/BTC#{fiat_currency}/money/depth/full")
16
+
17
+ asks = format_asks_bids(json["data"]["asks"])
18
+ bids = format_asks_bids(json["data"]["bids"])
19
+
20
+ mark_as_refreshed
21
+ @order_book = OrderBook.new(asks: asks, bids: bids)
22
+ end
23
+
24
+ private
25
+
26
+ def format_asks_bids(json)
27
+ json.map do |tuple|
28
+ price, volume = tuple["price"], tuple["amount"]
29
+ price_chf = Money.new(price, fiat_currency)
30
+ price_normalized = price_chf.exchange_to(Devilicious.config.default_fiat_currency)
31
+
32
+ Offer.new(
33
+ price: price_normalized,
34
+ volume: volume
35
+ ).freeze
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+
@@ -0,0 +1,37 @@
1
+ require "open-uri"
2
+ require "json"
3
+ require "retryable"
4
+
5
+ module Devilicious
6
+ module Market
7
+ class Base
8
+ attr_reader :order_book
9
+
10
+ def to_s
11
+ self.class.to_s.gsub(/.*::/, "")
12
+ end
13
+
14
+ def trade_fee
15
+ raise NotImplementedError
16
+ end
17
+
18
+ private
19
+
20
+ def get_html(url)
21
+ retryable(tries: 5, sleep: 1) do
22
+ open(url).read
23
+ end
24
+ end
25
+
26
+ def get_json(url)
27
+ html = get_html(url)
28
+ JSON.parse(html)
29
+ end
30
+
31
+ def mark_as_refreshed
32
+ Log.debug "Order book for #{self} has been refreshed"
33
+ end
34
+ end
35
+ end
36
+ end
37
+
@@ -0,0 +1,43 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class BitNz < Base
6
+ # NOTE: https://bitnz.com/
7
+
8
+ def fiat_currency
9
+ "NZD"
10
+ end
11
+
12
+ def trade_fee
13
+ BigDecimal.new("0.005").freeze # 0.5% - see https://bitnz.com/fees
14
+ end
15
+
16
+ def refresh_order_book!
17
+ json = get_json("https://bitnz.com/api/0/orderbook")
18
+
19
+ asks = format_asks_bids(json["asks"])
20
+ bids = format_asks_bids(json["bids"])
21
+
22
+ mark_as_refreshed
23
+ @order_book = OrderBook.new(asks: asks, bids: bids)
24
+ end
25
+
26
+ private
27
+
28
+ def format_asks_bids(json)
29
+ json.map do |price, volume|
30
+ price_nzd = Money.new(price.to_s, fiat_currency)
31
+ price_normalized = price_nzd.exchange_to(Devilicious.config.default_fiat_currency)
32
+
33
+ Offer.new(
34
+ price: price_normalized,
35
+ volume: volume.to_s
36
+ ).freeze
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+
@@ -0,0 +1,43 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class BitcoinDe < Base
6
+ ScrappingError = Class.new(Exception)
7
+
8
+ def fiat_currency
9
+ "EUR"
10
+ end
11
+
12
+ def trade_fee
13
+ BigDecimal.new("0.005").freeze # 0.5% - see https://www.bitcoin.de/en/infos#gebuehren
14
+ end
15
+
16
+ def refresh_order_book!
17
+ retryable(tries: 2, sleep: 0, on: ScrappingError) do
18
+ html = get_html("https://www.bitcoin.de/en/market")
19
+
20
+ asks = format_asks_bids(html, "offer")
21
+ bids = format_asks_bids(html, "order")
22
+
23
+ raise ScrappingError if asks.empty? || asks.size != bids.size
24
+
25
+ mark_as_refreshed
26
+ @order_book = OrderBook.new(asks: asks, bids: bids)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def format_asks_bids(html, type)
33
+ raise ScrappingError unless html.match(/<tbody id="trade_#{type}_results_table_body"(.+?)<\/tbody>/m)
34
+
35
+ $1.each_line.select { |line| line.include?("data-critical-price") }.map do |line|
36
+ raise ScrappingError unless line.match(/<tr[^>]+data-critical-price="([\d\.]+)" data-amount="([\d\.]+)">/)
37
+
38
+ Offer.new(price: Money.new($1, fiat_currency), volume: $2).freeze
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,39 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class Bitcurex < Base
6
+ # NOTE: https://eur.bitcurex.com/
7
+
8
+ def fiat_currency
9
+ "EUR"
10
+ end
11
+
12
+ def trade_fee
13
+ BigDecimal.new("0.004").freeze # 0.4% - see https://eur.bitcurex.com/op%C5%82aty-i-limity
14
+ end
15
+
16
+ def refresh_order_book!
17
+ json = get_json("https://#{fiat_currency.downcase}.bitcurex.com/data/orderbook.json")
18
+
19
+ asks = format_asks_bids(json["asks"])
20
+ bids = format_asks_bids(json["bids"])
21
+
22
+ mark_as_refreshed
23
+ @order_book = OrderBook.new(asks: asks, bids: bids)
24
+ end
25
+
26
+ private
27
+
28
+ def format_asks_bids(json)
29
+ json.map do |price, volume|
30
+ Offer.new(
31
+ price: Money.new(price, fiat_currency),
32
+ volume: volume
33
+ ).freeze
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
@@ -0,0 +1,42 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class Bitstamp < Base
6
+ # NOTE: https://www.bitstamp.net/
7
+
8
+ def fiat_currency
9
+ "USD"
10
+ end
11
+
12
+ def trade_fee
13
+ BigDecimal.new("0.005").freeze # 0.5% - see https://www.bitstamp.net/fee_schedule/
14
+ end
15
+
16
+ def refresh_order_book!
17
+ json = get_json("https://www.bitstamp.net/api/order_book/")
18
+
19
+ asks = format_asks_bids(json["asks"])
20
+ bids = format_asks_bids(json["bids"])
21
+
22
+ mark_as_refreshed
23
+ @order_book = OrderBook.new(asks: asks, bids: bids)
24
+ end
25
+
26
+ private
27
+
28
+ def format_asks_bids(json)
29
+ json.map do |price, volume|
30
+ price_usd = Money.new(price, fiat_currency)
31
+ price_normalized = price_usd.exchange_to(Devilicious.config.default_fiat_currency)
32
+
33
+ Offer.new(
34
+ price: price_normalized,
35
+ volume: volume
36
+ ).freeze
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+
@@ -0,0 +1,37 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class BtcE < Base
6
+ def fiat_currency
7
+ "EUR"
8
+ end
9
+
10
+ def trade_fee
11
+ BigDecimal.new("0.002").freeze # 0.2%
12
+ end
13
+
14
+ def refresh_order_book!
15
+ json = get_json("https://btc-e.com/api/2/btc_#{fiat_currency.downcase}/depth")
16
+
17
+ asks = format_asks_bids(json["asks"])
18
+ bids = format_asks_bids(json["bids"])
19
+
20
+ mark_as_refreshed
21
+ @order_book = OrderBook.new(asks: asks, bids: bids)
22
+ end
23
+
24
+ private
25
+
26
+ def format_asks_bids(json)
27
+ json.map do |price, volume|
28
+ Offer.new(
29
+ price: Money.new(price.to_s, fiat_currency),
30
+ volume: volume.to_s
31
+ ).freeze
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
@@ -0,0 +1,40 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class HitBtc < Base
6
+ # NOTE: https://hitbtc-com.github.io/hitbtc-api/
7
+
8
+ def fiat_currency
9
+ "EUR"
10
+ end
11
+
12
+ def trade_fee
13
+ BigDecimal.new("0.001").freeze # 0.1% - see https://hitbtc.com/fees-and-limits
14
+ end
15
+
16
+ def refresh_order_book!
17
+ json = get_json("https://api.hitbtc.com/api/1/public/BTC#{fiat_currency}/orderbook")
18
+
19
+ asks = format_asks_bids(json["asks"])
20
+ bids = format_asks_bids(json["bids"])
21
+
22
+ mark_as_refreshed
23
+ @order_book = OrderBook.new(asks: asks, bids: bids)
24
+ end
25
+
26
+ private
27
+
28
+ def format_asks_bids(json)
29
+ json.map do |price, volume|
30
+ Offer.new(
31
+ price: Money.new(price, fiat_currency),
32
+ volume: volume
33
+ ).freeze
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+
@@ -0,0 +1,35 @@
1
+ require "devilicious/markets/base"
2
+
3
+ module Devilicious
4
+ module Market
5
+ class Kraken < Base
6
+ def fiat_currency
7
+ "EUR"
8
+ end
9
+
10
+ def trade_fee
11
+ BigDecimal.new("0.002").freeze # 0.2%
12
+ end
13
+
14
+ def refresh_order_book!
15
+ json = get_json("https://api.kraken.com/0/public/Depth?pair=XBT#{fiat_currency}")
16
+
17
+ asks = format_asks_bids(json["result"]["XXBTZ#{fiat_currency}"]["asks"])
18
+ bids = format_asks_bids(json["result"]["XXBTZ#{fiat_currency}"]["bids"])
19
+
20
+ mark_as_refreshed
21
+ @order_book = OrderBook.new(asks: asks, bids: bids)
22
+ end
23
+ private
24
+
25
+ def format_asks_bids(json)
26
+ json.map do |price, volume|
27
+ Offer.new(
28
+ price: Money.new(price, fiat_currency),
29
+ volume: volume
30
+ ).freeze
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,64 @@
1
+ require "bigdecimal"
2
+
3
+ module Devilicious
4
+ class Money < BigDecimal
5
+ attr_reader :currency
6
+
7
+ def initialize(amount, currency)
8
+ @currency = currency
9
+
10
+ super(amount)
11
+ end
12
+
13
+ def exchange_to new_currency
14
+ new_amount = CurrencyConverter.convert(self, currency, new_currency)
15
+
16
+ self.class.new new_amount, new_currency
17
+ end
18
+
19
+ def to_s
20
+ sprintf("%.#{decimal_places}f", self) << " " << currency
21
+ end
22
+
23
+ def inspect
24
+ "#<Devilicious::Money amount=#{to_s}>"
25
+ end
26
+
27
+ def +(other)
28
+ assert_currency! other
29
+ self.class.new super, currency
30
+ end
31
+
32
+ def -(other)
33
+ assert_currency! other
34
+ self.class.new super, currency
35
+ end
36
+
37
+ def *(other)
38
+ assert_currency! other
39
+ self.class.new super, currency
40
+ end
41
+
42
+ def /(other)
43
+ assert_currency! other
44
+ self.class.new super, currency
45
+ end
46
+
47
+ private
48
+
49
+ def decimal_places
50
+ max = 6 # NOTE: don't care about extra decimals
51
+
52
+ 2.upto(max) do |i|
53
+ return i if self == self.round(i)
54
+ end
55
+
56
+ max
57
+ end
58
+
59
+ def assert_currency!(other)
60
+ raise "Currency mismatch: #{self.inspect} #{other.inspect}" if other.is_a?(self.class) && other.currency != currency
61
+ end
62
+ end
63
+ end
64
+
@@ -0,0 +1,35 @@
1
+ require "bigdecimal"
2
+
3
+ module Devilicious
4
+ class Offer
5
+ attr_reader :price, :weighted_price, :volume
6
+
7
+ def initialize(hash)
8
+ self.price = hash.delete(:price)
9
+ self.weighted_price = hash.delete(:weighted_price)
10
+ self.volume = BigDecimal.new(hash.delete(:volume))
11
+
12
+ raise ArgumentError unless hash.empty?
13
+ raise ArgumentError, "#{price.class} is not Money" unless price.is_a? Money
14
+ end
15
+
16
+ def inspect
17
+ "#<Devilicious::Offer price=#{price} volume=#{volume.to_f}>"
18
+ end
19
+
20
+ def price=(price)
21
+ raise ArgumentError if price < 0
22
+ @price = price
23
+ end
24
+
25
+ def weighted_price=(weighted_price)
26
+ raise ArgumentError if weighted_price && weighted_price < 0
27
+ @weighted_price = weighted_price
28
+ end
29
+
30
+ def volume=(volume)
31
+ raise ArgumentError if volume < 0
32
+ @volume = volume
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,89 @@
1
+ module Devilicious
2
+ class OrderBook
3
+ attr_reader :asks, :bids
4
+ attr_accessor :market
5
+
6
+ def initialize(hash)
7
+ @asks = hash.delete(:asks).sort_by(&:price).freeze # buy
8
+ @bids = hash.delete(:bids).sort_by(&:price).freeze # sell
9
+
10
+ raise ArgumentError unless hash.empty?
11
+ end
12
+
13
+ def highest_bid
14
+ bids.last
15
+ end
16
+
17
+ def lowest_ask
18
+ asks.first
19
+ end
20
+
21
+ def weighted_asks_up_to(max_price)
22
+ weighted_offers(asks, ->(price) { price <= max_price })
23
+ end
24
+
25
+ def weighted_bids_down_to(min_price)
26
+ weighted_offers(bids, ->(price) { price >= min_price })
27
+ end
28
+
29
+ def min_ask_price_for_volume(max_price, max_volume)
30
+ interesting_asks = interesting_offers(asks, ->(price) { price <= max_price })
31
+ best_offer_price_for_volume(interesting_asks, max_volume)
32
+ end
33
+
34
+ def max_bid_price_for_volume(min_price, max_volume)
35
+ interesting_bids = interesting_offers(bids, ->(price) { price >= min_price }).reverse # reverse to start from most expensive
36
+ best_offer_price_for_volume(interesting_bids, max_volume)
37
+ end
38
+
39
+ private
40
+
41
+ def interesting_offers(offers, condition)
42
+ offers.select { |offer| condition.call(offer.price) }
43
+ end
44
+
45
+ def weighted_offers(offers, condition)
46
+ interesting_offers = interesting_offers(offers, condition)
47
+
48
+ total_volume = interesting_offers.map(&:volume).inject(:+) || 0
49
+ total_weight_price = interesting_offers.map { |offer| offer.price * offer.volume }.inject(:+) || 0
50
+ weighted_price = total_weight_price / total_volume
51
+
52
+ Offer.new(
53
+ price: Money.new(weighted_price, currency),
54
+ volume: total_volume
55
+ )
56
+ end
57
+
58
+ def best_offer_price_for_volume(offers, max_volume)
59
+ total_volume = 0
60
+ good_offers = []
61
+
62
+ offers.each do |offer|
63
+ if total_volume <= max_volume
64
+ good_offers << offer.dup # NOTE: dup because we're gonna mess with its volume below
65
+ total_volume += offer.volume
66
+ end
67
+ end
68
+
69
+ if total_volume > max_volume
70
+ substract_volume = total_volume - max_volume
71
+ good_offers.last.volume -= substract_volume
72
+ total_volume -= substract_volume
73
+ end
74
+
75
+ total_weight_price = good_offers.map { |offer| offer.price * offer.volume }.inject(:+) || 0
76
+ weighted_price = total_weight_price / total_volume
77
+
78
+ Offer.new(
79
+ price: good_offers.last.price,
80
+ volume: total_volume,
81
+ weighted_price: weighted_price,
82
+ )
83
+ end
84
+
85
+ def currency
86
+ lowest_ask.price.currency
87
+ end
88
+ end
89
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: devilicious
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Cédric Félizard
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-08-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: retryable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.5'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.5'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Multi-currency Bitcoin arbitrage bot
56
+ email:
57
+ - cedric@felizard.fr
58
+ executables:
59
+ - devilicious
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".gitignore"
64
+ - Gemfile
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - bin/devilicious
69
+ - devilicious.gemspec
70
+ - lib/devilicious.rb
71
+ - lib/devilicious/arbitrer.rb
72
+ - lib/devilicious/config.rb
73
+ - lib/devilicious/currency_converter.rb
74
+ - lib/devilicious/formatters/base.rb
75
+ - lib/devilicious/formatters/summary.rb
76
+ - lib/devilicious/formatters/table.rb
77
+ - lib/devilicious/formatters/verbose.rb
78
+ - lib/devilicious/log.rb
79
+ - lib/devilicious/markets/anx_btc.rb
80
+ - lib/devilicious/markets/base.rb
81
+ - lib/devilicious/markets/bit_nz.rb
82
+ - lib/devilicious/markets/bitcoin_de.rb
83
+ - lib/devilicious/markets/bitcurex.rb
84
+ - lib/devilicious/markets/bitstamp.rb
85
+ - lib/devilicious/markets/btc_e.rb
86
+ - lib/devilicious/markets/hit_btc.rb
87
+ - lib/devilicious/markets/kraken.rb
88
+ - lib/devilicious/money.rb
89
+ - lib/devilicious/offer.rb
90
+ - lib/devilicious/order_book.rb
91
+ - lib/devilicious/sounds/boom.wav
92
+ homepage: ''
93
+ licenses:
94
+ - AGPLv3
95
+ metadata: {}
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubyforge_project:
112
+ rubygems_version: 2.2.2
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: Multi-currency Bitcoin arbitrage bot
116
+ test_files: []