bitex_bot 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +18 -0
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +50 -0
- data/Rakefile +1 -0
- data/bin/bitex_bot +5 -0
- data/bitex_bot.gemspec +41 -0
- data/lib/bitex_bot/database.rb +93 -0
- data/lib/bitex_bot/models/buy_closing_flow.rb +34 -0
- data/lib/bitex_bot/models/buy_opening_flow.rb +86 -0
- data/lib/bitex_bot/models/close_buy.rb +7 -0
- data/lib/bitex_bot/models/close_sell.rb +4 -0
- data/lib/bitex_bot/models/closing_flow.rb +80 -0
- data/lib/bitex_bot/models/open_buy.rb +11 -0
- data/lib/bitex_bot/models/open_sell.rb +11 -0
- data/lib/bitex_bot/models/opening_flow.rb +114 -0
- data/lib/bitex_bot/models/order_book_simulator.rb +75 -0
- data/lib/bitex_bot/models/sell_closing_flow.rb +36 -0
- data/lib/bitex_bot/models/sell_opening_flow.rb +82 -0
- data/lib/bitex_bot/robot.rb +173 -0
- data/lib/bitex_bot/settings.rb +13 -0
- data/lib/bitex_bot/version.rb +3 -0
- data/lib/bitex_bot.rb +18 -0
- data/settings.yml.sample +84 -0
- data/spec/factories/bitex_buy.rb +12 -0
- data/spec/factories/bitex_sell.rb +12 -0
- data/spec/factories/buy_opening_flow.rb +17 -0
- data/spec/factories/open_buy.rb +17 -0
- data/spec/factories/open_sell.rb +17 -0
- data/spec/factories/sell_opening_flow.rb +17 -0
- data/spec/models/buy_closing_flow_spec.rb +150 -0
- data/spec/models/buy_opening_flow_spec.rb +154 -0
- data/spec/models/order_book_simulator_spec.rb +57 -0
- data/spec/models/robot_spec.rb +103 -0
- data/spec/models/sell_closing_flow_spec.rb +160 -0
- data/spec/models/sell_opening_flow_spec.rb +156 -0
- data/spec/spec_helper.rb +43 -0
- data/spec/support/bitex_stubs.rb +66 -0
- data/spec/support/bitstamp_stubs.rb +110 -0
- metadata +363 -0
@@ -0,0 +1,36 @@
|
|
1
|
+
module BitexBot
|
2
|
+
class SellClosingFlow < ClosingFlow
|
3
|
+
has_many :open_positions, class_name: 'OpenSell', foreign_key: :closing_flow_id
|
4
|
+
has_many :close_positions, class_name: 'CloseSell', foreign_key: :closing_flow_id
|
5
|
+
scope :active, lambda { where(done: false) }
|
6
|
+
|
7
|
+
def self.open_position_class
|
8
|
+
OpenSell
|
9
|
+
end
|
10
|
+
|
11
|
+
def order_method
|
12
|
+
:buy
|
13
|
+
end
|
14
|
+
|
15
|
+
# The amount received when selling initially, minus
|
16
|
+
# the amount spent re-buying the sold coins.
|
17
|
+
def get_usd_profit
|
18
|
+
open_positions.sum(:amount) - close_positions.sum(:amount)
|
19
|
+
end
|
20
|
+
|
21
|
+
# The coins we actually bought minus the coins we were supposed
|
22
|
+
# to re-buy
|
23
|
+
def get_btc_profit
|
24
|
+
close_positions.sum(:quantity) - quantity
|
25
|
+
end
|
26
|
+
|
27
|
+
def get_next_price_and_quantity
|
28
|
+
closes = close_positions
|
29
|
+
next_price =
|
30
|
+
desired_price + ((closes.count * (closes.count * 2)) / 100.0)
|
31
|
+
next_quantity =
|
32
|
+
((quantity * desired_price) - closes.sum(:amount)) / next_price
|
33
|
+
[next_price, next_quantity]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module BitexBot
|
2
|
+
# A workflow for selling bitcoin in Bitex and buying on another exchange. The
|
3
|
+
# SellOpeningFlow factory function estimates how much you could buy on the other
|
4
|
+
# exchange and calculates a reasonable price taking into account the remote
|
5
|
+
# orderbook and the recent operated volume.
|
6
|
+
#
|
7
|
+
# When created, a SellOpeningFlow places an Ask on Bitex for the calculated
|
8
|
+
# quantity and price, when the Ask is matched on Bitex an OpenSell is
|
9
|
+
# created to buy the same quantity for a lower price on the other exchange.
|
10
|
+
#
|
11
|
+
# A SellOpeningFlow can be cancelled at any point, which will cancel the Bitex
|
12
|
+
# order and any orders on the remote exchange created from its OpenSell's
|
13
|
+
#
|
14
|
+
# @attr order_id The first thing a SellOpeningFlow does is placing an Ask on Bitex,
|
15
|
+
# this is its unique id.
|
16
|
+
class SellOpeningFlow < OpeningFlow
|
17
|
+
|
18
|
+
# Start a workflow for selling bitcoin on bitex and buying on the other
|
19
|
+
# exchange. The quantity to be sold on bitex is retrieved from Settings, if
|
20
|
+
# there is not enough BTC on bitex or USD on the other exchange then no
|
21
|
+
# order will be placed and an exception will be raised instead.
|
22
|
+
# The amount a SellOpeningFlow will try to sell and the price it will try to
|
23
|
+
# charge are derived from these parameters:
|
24
|
+
#
|
25
|
+
# @param usd_balance [BigDecimal] amount of usd available in the other
|
26
|
+
# exchange that can be spent to balance this sale.
|
27
|
+
# @param order_book [[price, quantity]] a list of lists representing an ask
|
28
|
+
# order book in the other exchange.
|
29
|
+
# @param transactions [Hash] a list of hashes representing
|
30
|
+
# all transactions in the other exchange. Each hash contains 'date', 'tid',
|
31
|
+
# 'price' and 'amount', where 'amount' is the BTC transacted.
|
32
|
+
# @param bitex_fee [BigDecimal] the transaction fee to pay on bitex.
|
33
|
+
# @param other_fee [BigDecimal] the transaction fee to pay on the other
|
34
|
+
# exchange.
|
35
|
+
#
|
36
|
+
# @return [SellOpeningFlow] The newly created flow.
|
37
|
+
# @raise [CannotCreateFlow] If there's any problem creating this flow, for
|
38
|
+
# example when you run out of BTC on bitex or out of USD on the other
|
39
|
+
# exchange.
|
40
|
+
def self.create_for_market(usd_balance, order_book, transactions,
|
41
|
+
bitex_fee, other_fee)
|
42
|
+
super
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.open_position_class
|
46
|
+
OpenSell
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.transaction_class
|
50
|
+
Bitex::Sell
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.transaction_order_id(transaction)
|
54
|
+
transaction.ask_id
|
55
|
+
end
|
56
|
+
|
57
|
+
def self.order_class
|
58
|
+
Bitex::Ask
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.value_to_use
|
62
|
+
Settings.selling.quantity_to_sell_per_order
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.get_safest_price(transactions, order_book, bitcoins_to_use)
|
66
|
+
OrderBookSimulator.run(Settings.time_to_live, transactions,
|
67
|
+
order_book, nil, bitcoins_to_use)
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.get_remote_value_to_use(value_to_use_needed, safest_price)
|
71
|
+
value_to_use_needed * safest_price
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.order_is_executing?(order)
|
75
|
+
order.status != :executing && order.remaining_quantity == order.quantity
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.get_bitex_price(btc_to_sell, usd_to_spend_re_buying)
|
79
|
+
(usd_to_spend_re_buying / btc_to_sell) * (1 + Settings.selling.profit / 100.0)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
trap "INT" do
|
2
|
+
if BitexBot::Robot.graceful_shutdown
|
3
|
+
print "\b"
|
4
|
+
BitexBot::Robot.logger.info("Ok, ok, I'm out.")
|
5
|
+
exit 1
|
6
|
+
end
|
7
|
+
BitexBot::Robot.graceful_shutdown = true
|
8
|
+
BitexBot::Robot.logger.info("Shutting down as soon as I've cleaned up.")
|
9
|
+
end
|
10
|
+
|
11
|
+
module BitexBot
|
12
|
+
class Robot
|
13
|
+
cattr_accessor :graceful_shutdown
|
14
|
+
cattr_accessor :cooldown_until
|
15
|
+
cattr_accessor :test_mode
|
16
|
+
cattr_accessor :logger do
|
17
|
+
Logger.new(Settings.log.file || STDOUT, 10, 10240000).tap do |l|
|
18
|
+
l.level = Logger.const_get(Settings.log.level.upcase)
|
19
|
+
l.formatter = proc do |severity, datetime, progname, msg|
|
20
|
+
date = datetime.strftime("%m/%d %H:%M:%S.%L")
|
21
|
+
"#{ '%-6s' % severity } #{date}: #{msg}\n"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
cattr_accessor :current_cooldowns do 0 end
|
26
|
+
|
27
|
+
# Trade constantly respecting cooldown times so that we don't get
|
28
|
+
# banned by api clients.
|
29
|
+
def self.run!
|
30
|
+
setup
|
31
|
+
logger.info("Loading trading robot, ctrl+c *once* to exit gracefully.")
|
32
|
+
self.cooldown_until = Time.now
|
33
|
+
bot = new
|
34
|
+
|
35
|
+
while true
|
36
|
+
start_time = Time.now
|
37
|
+
return if start_time < cooldown_until
|
38
|
+
self.current_cooldowns = 0
|
39
|
+
bot.trade!
|
40
|
+
self.cooldown_until = start_time + current_cooldowns.seconds
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.setup
|
45
|
+
Bitex.api_key = Settings.bitex
|
46
|
+
Bitstamp.setup do |config|
|
47
|
+
config.key = Settings.bitstamp.key
|
48
|
+
config.secret = Settings.bitstamp.secret
|
49
|
+
config.client_id = Settings.bitstamp.client_id.to_s
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.with_cooldown(&block)
|
54
|
+
result = block.call
|
55
|
+
return result if test_mode
|
56
|
+
self.current_cooldowns += 1
|
57
|
+
sleep 0.1
|
58
|
+
return result
|
59
|
+
end
|
60
|
+
|
61
|
+
def with_cooldown(&block)
|
62
|
+
self.class.with_cooldown(&block)
|
63
|
+
end
|
64
|
+
|
65
|
+
def trade!
|
66
|
+
finalise_some_opening_flows
|
67
|
+
if(!active_opening_flows? && !open_positions? &&
|
68
|
+
!active_closing_flows? && self.class.graceful_shutdown)
|
69
|
+
self.class.logger.info("Shutdown completed")
|
70
|
+
exit
|
71
|
+
end
|
72
|
+
sync_opening_flows if active_opening_flows?
|
73
|
+
start_closing_flows if open_positions?
|
74
|
+
sync_closing_flows if active_closing_flows?
|
75
|
+
start_opening_flows_if_needed
|
76
|
+
rescue CannotCreateFlow => e
|
77
|
+
self.notify("#{e.message}:\n\n#{e.backtrace.join("\n")}")
|
78
|
+
BitexBot::Robot.graceful_shutdown = true
|
79
|
+
rescue StandardError => e
|
80
|
+
self.notify("#{e.message}:\n\n#{e.backtrace.join("\n")}")
|
81
|
+
sleep 30 unless self.class.test_mode
|
82
|
+
end
|
83
|
+
|
84
|
+
def finalise_some_opening_flows
|
85
|
+
[BuyOpeningFlow, SellOpeningFlow].each do |kind|
|
86
|
+
flows = self.class.graceful_shutdown ? kind.active : kind.old_active
|
87
|
+
flows.each{|flow| flow.finalise! }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def start_closing_flows
|
92
|
+
[BuyClosingFlow, SellClosingFlow].each{|kind| kind.close_open_positions}
|
93
|
+
end
|
94
|
+
|
95
|
+
def open_positions?
|
96
|
+
OpenBuy.open.exists? || OpenSell.open.exists?
|
97
|
+
end
|
98
|
+
|
99
|
+
def sync_closing_flows
|
100
|
+
orders = with_cooldown{ Bitstamp.orders.all }
|
101
|
+
transactions = with_cooldown{ Bitstamp.user_transactions.all }
|
102
|
+
|
103
|
+
[BuyClosingFlow, SellClosingFlow].each do |kind|
|
104
|
+
kind.active.each do |flow|
|
105
|
+
flow.sync_closed_positions(orders, transactions)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def active_closing_flows?
|
111
|
+
BuyClosingFlow.active.exists? || SellClosingFlow.active.exists?
|
112
|
+
end
|
113
|
+
|
114
|
+
def start_opening_flows_if_needed
|
115
|
+
return if open_positions?
|
116
|
+
return if active_closing_flows?
|
117
|
+
return if self.class.graceful_shutdown
|
118
|
+
|
119
|
+
|
120
|
+
recent_buying, recent_selling =
|
121
|
+
[BuyOpeningFlow, SellOpeningFlow].collect do |kind|
|
122
|
+
threshold = (Settings.time_to_live / 2).seconds.ago
|
123
|
+
kind.active.where('created_at > ?', threshold).first
|
124
|
+
end
|
125
|
+
|
126
|
+
return if recent_buying && recent_selling
|
127
|
+
|
128
|
+
balances = with_cooldown{ Bitstamp.balance }
|
129
|
+
order_book = with_cooldown{ Bitstamp.order_book }
|
130
|
+
transactions = with_cooldown{ Bitstamp.transactions }
|
131
|
+
|
132
|
+
unless recent_buying
|
133
|
+
BuyOpeningFlow.create_for_market(
|
134
|
+
balances['btc_available'].to_d,
|
135
|
+
order_book['bids'],
|
136
|
+
transactions,
|
137
|
+
Bitex::Profile.get[:fee],
|
138
|
+
balances['fee'].to_d )
|
139
|
+
end
|
140
|
+
unless recent_selling
|
141
|
+
SellOpeningFlow.create_for_market(
|
142
|
+
balances['usd_available'].to_d,
|
143
|
+
order_book['asks'],
|
144
|
+
transactions,
|
145
|
+
Bitex::Profile.get[:fee],
|
146
|
+
balances['fee'].to_d )
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
def sync_opening_flows
|
151
|
+
[SellOpeningFlow, BuyOpeningFlow].each{|o| o.sync_open_positions }
|
152
|
+
end
|
153
|
+
|
154
|
+
def active_opening_flows?
|
155
|
+
BuyOpeningFlow.active.exists? || SellOpeningFlow.active.exists?
|
156
|
+
end
|
157
|
+
|
158
|
+
def notify(message)
|
159
|
+
self.class.logger.error(message)
|
160
|
+
if Settings.mailer
|
161
|
+
mail = Mail.new do
|
162
|
+
from Settings.mailer.from
|
163
|
+
to Settings.mailer.to
|
164
|
+
subject 'Notice from your robot trader'
|
165
|
+
body message
|
166
|
+
end
|
167
|
+
mail.delivery_method(Settings.mailer.method.to_sym,
|
168
|
+
Settings.mailer.options.symbolize_keys)
|
169
|
+
mail.deliver!
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'settingslogic'
|
2
|
+
require 'debugger'
|
3
|
+
class BitexBot::Settings < Settingslogic
|
4
|
+
path = File.expand_path('bitex_bot_settings.yml', Dir.pwd)
|
5
|
+
unless FileTest.exists?(path)
|
6
|
+
sample_path = File.expand_path('../../../settings.yml.sample', __FILE__)
|
7
|
+
FileUtils.cp(sample_path, path)
|
8
|
+
puts "No settings found, I've created a new one with sample "\
|
9
|
+
"values at #{path}. Please go ahead and edit it before running this again."
|
10
|
+
exit 1
|
11
|
+
end
|
12
|
+
source path
|
13
|
+
end
|
data/lib/bitex_bot.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require "bitex_bot/version"
|
2
|
+
require "active_support"
|
3
|
+
require "active_record"
|
4
|
+
require "active_model"
|
5
|
+
require "mail"
|
6
|
+
require "logger"
|
7
|
+
require "bitex"
|
8
|
+
require "bitstamp"
|
9
|
+
require "bitex_bot/settings"
|
10
|
+
require "bitex_bot/database"
|
11
|
+
require "debugger"
|
12
|
+
require "bitex_bot/models/opening_flow.rb"
|
13
|
+
require "bitex_bot/models/closing_flow.rb"
|
14
|
+
Dir[File.dirname(__FILE__) + '/bitex_bot/models/*.rb'].each {|file| require file }
|
15
|
+
require "bitex_bot/robot"
|
16
|
+
|
17
|
+
module BitexBot
|
18
|
+
end
|
data/settings.yml.sample
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
log:
|
2
|
+
# File to write log to. The log is rotated every 10240000 bytes, 10 oldest
|
3
|
+
# logs are kept around.
|
4
|
+
# If you want to log to stdout you can comment this line.
|
5
|
+
file: bitex_bot.log
|
6
|
+
# How much information to show in the log, valid values are debug, info, error
|
7
|
+
level: info
|
8
|
+
|
9
|
+
# Seconds to keep an order alive before recalculating the price and replacing it.
|
10
|
+
# Given the way in which the robot tries to find the safest price to place an
|
11
|
+
# order, if the time to live is too long the price is not going to be
|
12
|
+
# competitive. About 20 seconds is a good number here.
|
13
|
+
time_to_live: 20
|
14
|
+
|
15
|
+
# Settings for buying on bitex and selling on bitstamp.
|
16
|
+
buying:
|
17
|
+
# Dollars to spend on each initial bitex bid.
|
18
|
+
# Should at least be 10.0 which is the current minimum order size on Bitex.
|
19
|
+
# if it's too large you're taking a higher risk if whatever happens and you
|
20
|
+
# cannot re sell it afterwards. Also, higher amounts result in your bitex
|
21
|
+
# bid price not being competitive.
|
22
|
+
# A number between 10.0 and 1000.0 is reccommended.
|
23
|
+
amount_to_spend_per_order: 10.0
|
24
|
+
|
25
|
+
# Your profit when buying on bitex, 0.5 means 0.5%.
|
26
|
+
# After calculating the price at which bitcoins can be sold on bitstamp, the
|
27
|
+
# robot deduces your profit from that price, and places a bid on bitex paying
|
28
|
+
# a lower price.
|
29
|
+
profit: 0.5
|
30
|
+
|
31
|
+
# Settings for selling on bitex and buying on bitstamp.
|
32
|
+
selling:
|
33
|
+
# Quantity to sell on each initial bitex Ask.
|
34
|
+
# It should be at least 10 USD worth of BTC at current market prices, a bit
|
35
|
+
# more just to be safe. Otherwise Bitex will reject your orders for being too
|
36
|
+
# small.
|
37
|
+
# If it's too large then you're taking a risk if whatever happens and you
|
38
|
+
# If it's too small then the robot is pointless, and if it's too large you're
|
39
|
+
# taking a higher risk if whatever happens ad you cannot re buy afterwards.
|
40
|
+
# Also, higher amounts result in your bitex bid price not being competitive.
|
41
|
+
# A number between 0.05 and 2.0 is recommended.
|
42
|
+
quantity_to_sell_per_order: 0.1
|
43
|
+
|
44
|
+
# Your profit when selling on bitex, 0.5 means 0.5%.
|
45
|
+
# After calculating the price at which bitcoins can be bought on bitstamp,
|
46
|
+
# the robot deduces your profit from that price and places an ask on bitex
|
47
|
+
# charging a higher price.
|
48
|
+
profit: 0.5
|
49
|
+
|
50
|
+
# This is your bitex api key, it's passed in to the
|
51
|
+
# bitex gem: https://github.com/bitex-la/bitex-ruby
|
52
|
+
bitex: your_bitex_api_key_which_should_be_kept_safe
|
53
|
+
|
54
|
+
# These are passed in to the bitstamp gem:
|
55
|
+
# see https://github.com/kojnapp/bitstamp for more info.
|
56
|
+
bitstamp:
|
57
|
+
key: YOUR_API_KEY
|
58
|
+
secret: YOUR_API_SECRET
|
59
|
+
client_id: YOUR_BITSTAMP_USERNAME
|
60
|
+
|
61
|
+
# Settings for the ActiveRecord Database to use.
|
62
|
+
# sqlite is just fine. Check this link for more options:
|
63
|
+
# http://apidock.com/rails/ActiveRecord/Base/establish_connection/class
|
64
|
+
database:
|
65
|
+
adapter: sqlite3
|
66
|
+
database: bitex_bot.db
|
67
|
+
|
68
|
+
# The robot sends you emails whenever a problem occurs.
|
69
|
+
# If you do not want to receive emails just remove this 'mailer'
|
70
|
+
# key and everything under it.
|
71
|
+
# It uses https://github.com/mikel/mail under the hood, so method
|
72
|
+
# is any valid delivery_method for teh mail gem.
|
73
|
+
# Options is the options hash passed in to delivery_method.
|
74
|
+
mailer:
|
75
|
+
from: 'robot@example.com'
|
76
|
+
to: 'you@example.com'
|
77
|
+
method: smtp
|
78
|
+
options:
|
79
|
+
address: "your_smtp_server_address.com"
|
80
|
+
port: 587
|
81
|
+
authentication: "plain"
|
82
|
+
enable_starttls_auto: true
|
83
|
+
user_name: 'your_user_name'
|
84
|
+
password: 'your_smtp_password'
|
@@ -0,0 +1,17 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
factory :buy_opening_flow, class: BitexBot::BuyOpeningFlow do
|
3
|
+
price 300.0
|
4
|
+
value_to_use 600.0
|
5
|
+
suggested_closing_price 310.0
|
6
|
+
status 'executing'
|
7
|
+
order_id 12345
|
8
|
+
end
|
9
|
+
|
10
|
+
factory :other_buy_opening_flow, class: BitexBot::BuyOpeningFlow do
|
11
|
+
price 400.0
|
12
|
+
value_to_use 400.0
|
13
|
+
suggested_closing_price 410.0
|
14
|
+
status 'executing'
|
15
|
+
order_id 2
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
factory :open_buy, class: BitexBot::OpenBuy do
|
3
|
+
price 300.0
|
4
|
+
amount 600.0
|
5
|
+
quantity 2.0
|
6
|
+
transaction_id 12345678
|
7
|
+
association :opening_flow, factory: :buy_opening_flow
|
8
|
+
end
|
9
|
+
|
10
|
+
factory :tiny_open_buy, class: BitexBot::OpenBuy do
|
11
|
+
price 400.0
|
12
|
+
amount 4.0
|
13
|
+
quantity 0.01
|
14
|
+
transaction_id 23456789
|
15
|
+
association :opening_flow, factory: :other_buy_opening_flow
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
factory :open_sell, class: BitexBot::OpenSell do
|
3
|
+
price 300.0
|
4
|
+
amount 600.0
|
5
|
+
quantity 2.0
|
6
|
+
transaction_id 12345678
|
7
|
+
association :opening_flow, factory: :sell_opening_flow
|
8
|
+
end
|
9
|
+
|
10
|
+
factory :tiny_open_sell, class: BitexBot::OpenSell do
|
11
|
+
price 400.0
|
12
|
+
amount 4.0
|
13
|
+
quantity 0.01
|
14
|
+
transaction_id 23456789
|
15
|
+
association :opening_flow, factory: :other_sell_opening_flow
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
FactoryGirl.define do
|
2
|
+
factory :sell_opening_flow, class: BitexBot::SellOpeningFlow do
|
3
|
+
price 300.0
|
4
|
+
value_to_use 2.0
|
5
|
+
suggested_closing_price 290.0
|
6
|
+
status 'executing'
|
7
|
+
order_id 12345
|
8
|
+
end
|
9
|
+
|
10
|
+
factory :other_sell_opening_flow, class: BitexBot::SellOpeningFlow do
|
11
|
+
price 400.0
|
12
|
+
value_to_use 1.0
|
13
|
+
suggested_closing_price 390.0
|
14
|
+
status 'executing'
|
15
|
+
order_id 2
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe BitexBot::BuyClosingFlow do
|
4
|
+
it "closes a single open position completely" do
|
5
|
+
stub_bitstamp_sell
|
6
|
+
open = create :open_buy
|
7
|
+
flow = BitexBot::BuyClosingFlow.close_open_positions
|
8
|
+
open.reload.closing_flow.should == flow
|
9
|
+
flow.open_positions.should == [open]
|
10
|
+
flow.desired_price.should == 310
|
11
|
+
flow.quantity.should == 2
|
12
|
+
flow.btc_profit.should be_nil
|
13
|
+
flow.usd_profit.should be_nil
|
14
|
+
close = flow.close_positions.first
|
15
|
+
close.order_id.should == 1
|
16
|
+
close.amount.should be_nil
|
17
|
+
close.quantity.should be_nil
|
18
|
+
end
|
19
|
+
|
20
|
+
it "closes an aggregate of several open positions" do
|
21
|
+
stub_bitstamp_sell
|
22
|
+
open_one = create :tiny_open_buy
|
23
|
+
open_two = create :open_buy
|
24
|
+
flow = BitexBot::BuyClosingFlow.close_open_positions
|
25
|
+
close = flow.close_positions.first
|
26
|
+
open_one.reload.closing_flow.should == flow
|
27
|
+
open_two.reload.closing_flow.should == flow
|
28
|
+
flow.open_positions.should == [open_one, open_two]
|
29
|
+
flow.desired_price.should == '310.497512437810945273631840797'.to_d
|
30
|
+
flow.quantity.should == 2.01
|
31
|
+
flow.btc_profit.should be_nil
|
32
|
+
flow.usd_profit.should be_nil
|
33
|
+
close.order_id.should == 1
|
34
|
+
close.amount.should be_nil
|
35
|
+
close.quantity.should be_nil
|
36
|
+
end
|
37
|
+
|
38
|
+
it "does not try to close if the amount is too low" do
|
39
|
+
open = create :tiny_open_buy
|
40
|
+
expect do
|
41
|
+
BitexBot::BuyClosingFlow.close_open_positions.should be_nil
|
42
|
+
end.not_to change{ BitexBot::BuyClosingFlow.count }
|
43
|
+
end
|
44
|
+
|
45
|
+
it "does not try to close if there are no open positions" do
|
46
|
+
expect do
|
47
|
+
BitexBot::BuyClosingFlow.close_open_positions.should be_nil
|
48
|
+
end.not_to change{ BitexBot::BuyClosingFlow.count }
|
49
|
+
end
|
50
|
+
|
51
|
+
describe "when syncinc executed orders" do
|
52
|
+
before(:each) do
|
53
|
+
stub_bitstamp_sell
|
54
|
+
stub_bitstamp_user_transactions
|
55
|
+
create :tiny_open_buy
|
56
|
+
create :open_buy
|
57
|
+
end
|
58
|
+
|
59
|
+
it "syncs the executed orders, calculates profit" do
|
60
|
+
flow = BitexBot::BuyClosingFlow.close_open_positions
|
61
|
+
stub_bitstamp_orders_into_transactions
|
62
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
63
|
+
close = flow.close_positions.last
|
64
|
+
close.amount.should == 624.1
|
65
|
+
close.quantity.should == 2.01
|
66
|
+
flow.should be_done
|
67
|
+
flow.btc_profit.should == 0
|
68
|
+
flow.usd_profit.should == 20.1
|
69
|
+
end
|
70
|
+
|
71
|
+
it "retries closing at a lower price every minute" do
|
72
|
+
flow = BitexBot::BuyClosingFlow.close_open_positions
|
73
|
+
expect do
|
74
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
75
|
+
end.not_to change{ BitexBot::CloseBuy.count }
|
76
|
+
flow.should_not be_done
|
77
|
+
|
78
|
+
# Immediately calling sync again does not try to cancel the ask.
|
79
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
80
|
+
Bitstamp.orders.all.size.should == 1
|
81
|
+
|
82
|
+
# Partially executes order, and 61 seconds after that
|
83
|
+
# sync_closed_positions tries to cancel it.
|
84
|
+
stub_bitstamp_orders_into_transactions(ratio: 0.5)
|
85
|
+
Timecop.travel 61.seconds.from_now
|
86
|
+
Bitstamp.orders.all.size.should == 1
|
87
|
+
expect do
|
88
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
89
|
+
end.not_to change{ BitexBot::CloseBuy.count }
|
90
|
+
Bitstamp.orders.all.size.should == 0
|
91
|
+
flow.should_not be_done
|
92
|
+
|
93
|
+
# Next time we try to sync_closed_positions the flow
|
94
|
+
# detects the previous close_buy was cancelled correctly so
|
95
|
+
# it syncs it's total amounts and tries to place a new one.
|
96
|
+
expect do
|
97
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
98
|
+
end.to change{ BitexBot::CloseBuy.count }.by(1)
|
99
|
+
flow.close_positions.first.tap do |close|
|
100
|
+
close.amount.should == 312.05
|
101
|
+
close.quantity.should == 1.005
|
102
|
+
end
|
103
|
+
|
104
|
+
# The second ask is executed completely so we can wrap it up and consider
|
105
|
+
# this closing flow done.
|
106
|
+
stub_bitstamp_orders_into_transactions
|
107
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
108
|
+
flow.close_positions.last.tap do |close|
|
109
|
+
close.amount.should == 312.0299
|
110
|
+
close.quantity.should == 1.005
|
111
|
+
end
|
112
|
+
flow.should be_done
|
113
|
+
flow.btc_profit.should == 0
|
114
|
+
flow.usd_profit.should == 20.0799
|
115
|
+
end
|
116
|
+
|
117
|
+
it "does not retry for an amount less than minimum_for_closing" do
|
118
|
+
flow = BitexBot::BuyClosingFlow.close_open_positions
|
119
|
+
|
120
|
+
stub_bitstamp_orders_into_transactions(ratio: 0.999)
|
121
|
+
Bitstamp.orders.all.first.cancel!
|
122
|
+
|
123
|
+
expect do
|
124
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
125
|
+
end.not_to change{ BitexBot::CloseBuy.count }
|
126
|
+
|
127
|
+
flow.should be_done
|
128
|
+
flow.btc_profit.should == 0.00201
|
129
|
+
flow.usd_profit.should == 19.4759
|
130
|
+
end
|
131
|
+
|
132
|
+
it "can lose USD if price had to be dropped dramatically" do
|
133
|
+
# This flow is forced to sell the original BTC quantity for less, thus regaining
|
134
|
+
# less USD than what was spent on bitex.
|
135
|
+
flow = BitexBot::BuyClosingFlow.close_open_positions
|
136
|
+
60.times do
|
137
|
+
Timecop.travel 60.seconds.from_now
|
138
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
139
|
+
end
|
140
|
+
|
141
|
+
stub_bitstamp_orders_into_transactions
|
142
|
+
|
143
|
+
flow.sync_closed_positions(Bitstamp.orders.all, Bitstamp.user_transactions.all)
|
144
|
+
|
145
|
+
flow.reload.should be_done
|
146
|
+
flow.btc_profit.should == 0
|
147
|
+
flow.usd_profit.should == -16.08
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|