devilicious 1.0.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.
@@ -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: []