bitbot-trader 0.0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/Gemfile +10 -0
- data/Gemfile.devtools +55 -0
- data/LICENSE.txt +22 -0
- data/README.md +28 -0
- data/Rakefile +4 -0
- data/bitbot-trader.gemspec +22 -0
- data/config/devtools.yml +4 -0
- data/config/flay.yml +3 -0
- data/config/flog.yml +2 -0
- data/config/mutant.yml +3 -0
- data/config/reek.yml +103 -0
- data/config/yardstick.yml +2 -0
- data/examples/account_info.rb +16 -0
- data/examples/open_orders.rb +16 -0
- data/lib/bitbot/trader.rb +32 -0
- data/lib/bitbot/trader/account.rb +29 -0
- data/lib/bitbot/trader/amount.rb +15 -0
- data/lib/bitbot/trader/api_methods.rb +33 -0
- data/lib/bitbot/trader/open_order.rb +29 -0
- data/lib/bitbot/trader/price.rb +15 -0
- data/lib/bitbot/trader/provider.rb +19 -0
- data/lib/bitbot/trader/providers/bitstamp.rb +29 -0
- data/lib/bitbot/trader/providers/bitstamp/http_client.rb +101 -0
- data/lib/bitbot/trader/providers/bitstamp/open_orders_parser.rb +33 -0
- data/lib/bitbot/trader/providers/bitstamp/open_orders_request.rb +25 -0
- data/lib/bitbot/trader/providers/mt_gox.rb +29 -0
- data/lib/bitbot/trader/providers/mt_gox/account_info_parser.rb +45 -0
- data/lib/bitbot/trader/providers/mt_gox/account_info_request.rb +24 -0
- data/lib/bitbot/trader/providers/mt_gox/http_client.rb +98 -0
- data/lib/bitbot/trader/providers/mt_gox/http_client/hmac_middleware.rb +118 -0
- data/lib/bitbot/trader/providers/mt_gox/open_orders_parser.rb +35 -0
- data/lib/bitbot/trader/providers/mt_gox/open_orders_request.rb +25 -0
- data/lib/bitbot/trader/providers/mt_gox/value_with_currency_coercer.rb +28 -0
- data/lib/bitbot/trader/request.rb +41 -0
- data/lib/bitbot/trader/utils/nonce_generator.rb +21 -0
- data/lib/bitbot/trader/version.rb +5 -0
- data/lib/bitbot/trader/wallet.rb +25 -0
- data/spec/integration/bitstamp/open_orders_spec.rb +28 -0
- data/spec/integration/mt_gox/account_spec.rb +28 -0
- data/spec/integration/mt_gox/open_orders_spec.rb +29 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/support/big_decimal_matcher.rb +5 -0
- data/spec/support/http_connection_helpers.rb +15 -0
- data/spec/support/http_request_mock.rb +7 -0
- data/spec/support/provider_mock.rb +5 -0
- data/spec/unit/bitbot/trader/account_spec.rb +28 -0
- data/spec/unit/bitbot/trader/api_methods_spec.rb +43 -0
- data/spec/unit/bitbot/trader/open_order_spec.rb +19 -0
- data/spec/unit/bitbot/trader/provider_spec.rb +18 -0
- data/spec/unit/bitbot/trader/providers/bitstamp/http_client_spec.rb +75 -0
- data/spec/unit/bitbot/trader/providers/bitstamp/open_order_parser_spec.rb +69 -0
- data/spec/unit/bitbot/trader/providers/bitstamp/open_orders_request_spec.rb +24 -0
- data/spec/unit/bitbot/trader/providers/bitstamp_spec.rb +45 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/account_info_parser_spec.rb +58 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/account_info_request_spec.rb +24 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/http_client/hmac_middleware_spec.rb +55 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/http_client_spec.rb +100 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/open_order_parser_spec.rb +95 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/open_orders_request_spec.rb +24 -0
- data/spec/unit/bitbot/trader/providers/mt_gox/value_with_currency_coercer_spec.rb +21 -0
- data/spec/unit/bitbot/trader/providers/mt_gox_spec.rb +26 -0
- data/spec/unit/bitbot/trader/request_spec.rb +39 -0
- data/spec/unit/bitbot/trader/utils/nonce_generator_spec.rb +19 -0
- metadata +166 -0
@@ -0,0 +1,118 @@
|
|
1
|
+
require "faraday"
|
2
|
+
require "base64"
|
3
|
+
require "digest/sha2"
|
4
|
+
require "digest/hmac"
|
5
|
+
|
6
|
+
module Bitbot
|
7
|
+
module Trader
|
8
|
+
module Providers
|
9
|
+
class MtGox
|
10
|
+
class HttpClient
|
11
|
+
# Signs request with hmac signature
|
12
|
+
#
|
13
|
+
class HmacMiddleware < Faraday::Middleware
|
14
|
+
# Extracts headers, path and body
|
15
|
+
#
|
16
|
+
# @param [#[]] env
|
17
|
+
# must respond to request_headers, url and body
|
18
|
+
#
|
19
|
+
# @return [Array]
|
20
|
+
#
|
21
|
+
# @api private
|
22
|
+
#
|
23
|
+
def self.extract_params(env)
|
24
|
+
headers = env[:request_headers]
|
25
|
+
path = env[:url].to_s.split("/2/").last
|
26
|
+
body = env[:body]
|
27
|
+
|
28
|
+
[headers, path, body]
|
29
|
+
end
|
30
|
+
|
31
|
+
# API key
|
32
|
+
#
|
33
|
+
# @return [String]
|
34
|
+
#
|
35
|
+
# @api private
|
36
|
+
#
|
37
|
+
attr_reader :key
|
38
|
+
|
39
|
+
# API secret
|
40
|
+
#
|
41
|
+
# @return [String]
|
42
|
+
#
|
43
|
+
# @api private
|
44
|
+
#
|
45
|
+
attr_reader :secret
|
46
|
+
|
47
|
+
# Initializes HmacMiddleware
|
48
|
+
#
|
49
|
+
# @param [Object] app
|
50
|
+
# @param [Hash] options
|
51
|
+
#
|
52
|
+
# @return [undefined]
|
53
|
+
#
|
54
|
+
# @api private
|
55
|
+
#
|
56
|
+
def initialize(app, options)
|
57
|
+
super(app)
|
58
|
+
@key = options.fetch(:key)
|
59
|
+
@secret = options.fetch(:secret)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Adds signature to current request
|
63
|
+
#
|
64
|
+
# @param [Object] env
|
65
|
+
#
|
66
|
+
# @return [Object]
|
67
|
+
#
|
68
|
+
# @api private
|
69
|
+
#
|
70
|
+
def call(env)
|
71
|
+
headers, path, body = self.class.extract_params(env)
|
72
|
+
prepare_headers(headers, path, body)
|
73
|
+
@app.call(env)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# Adds signature to headers
|
79
|
+
#
|
80
|
+
# @param [Hash] headers
|
81
|
+
# @param [String] path
|
82
|
+
# @param [String] body
|
83
|
+
#
|
84
|
+
# @return [undefined]
|
85
|
+
#
|
86
|
+
# @api private
|
87
|
+
#
|
88
|
+
def prepare_headers(headers, path, body)
|
89
|
+
headers.merge!(
|
90
|
+
"Rest-Key" => @key,
|
91
|
+
"Rest-Sign" => generate_signature(path, body),
|
92
|
+
"User-Agent" => "bitbot-trader"
|
93
|
+
)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Generates signature
|
97
|
+
#
|
98
|
+
# @param [String] path
|
99
|
+
# @param [String] body
|
100
|
+
#
|
101
|
+
# @return [String] singature
|
102
|
+
#
|
103
|
+
# @api private
|
104
|
+
#
|
105
|
+
def generate_signature(path, body)
|
106
|
+
secret = Base64.strict_decode64(@secret)
|
107
|
+
data = path + 0.chr + body
|
108
|
+
hmac = Digest::HMAC.digest(data, secret, Digest::SHA512)
|
109
|
+
Base64.strict_encode64(hmac)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
Faraday::Request.register_middleware hmac: HmacMiddleware
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module Bitbot
|
2
|
+
module Trader
|
3
|
+
module Providers
|
4
|
+
class MtGox
|
5
|
+
# Parses raw open orders
|
6
|
+
#
|
7
|
+
class OpenOrderParser
|
8
|
+
include Virtus.model
|
9
|
+
|
10
|
+
attribute :oid, String
|
11
|
+
attribute :currency, String
|
12
|
+
attribute :type, String
|
13
|
+
attribute :effective_amount, Amount, coercer: ValueWithCurrencyCoercer
|
14
|
+
attribute :price, Price, coercer: ValueWithCurrencyCoercer
|
15
|
+
attribute :type, String
|
16
|
+
|
17
|
+
# Makes raw open order hash into OpenOrder object
|
18
|
+
#
|
19
|
+
# @return [OpenOrder]
|
20
|
+
#
|
21
|
+
# @api private
|
22
|
+
#
|
23
|
+
def parse
|
24
|
+
OpenOrder.new(
|
25
|
+
id: oid,
|
26
|
+
price: price,
|
27
|
+
amount: effective_amount,
|
28
|
+
bid: type == "bid"
|
29
|
+
)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Bitbot
|
2
|
+
module Trader
|
3
|
+
module Providers
|
4
|
+
class MtGox
|
5
|
+
# POST request to /money/orders
|
6
|
+
#
|
7
|
+
# @see https://en.bitcoin.it/wiki/MtGox/API/HTTP/v1
|
8
|
+
#
|
9
|
+
class OpenOrdersRequest < Request
|
10
|
+
# Fetches user's open orders
|
11
|
+
#
|
12
|
+
# @return [Array<OpenOrder>]
|
13
|
+
#
|
14
|
+
# @api private
|
15
|
+
#
|
16
|
+
def call
|
17
|
+
client.post("money/orders")["data"].map { |raw_order|
|
18
|
+
OpenOrderParser.new(raw_order).parse
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Bitbot
|
2
|
+
module Trader
|
3
|
+
module Providers
|
4
|
+
class MtGox
|
5
|
+
# Helps managing values with currencies
|
6
|
+
#
|
7
|
+
class ValueWithCurrencyCoercer < Virtus::Attribute
|
8
|
+
# Parses value with currency hash
|
9
|
+
#
|
10
|
+
# @param [Hash] data
|
11
|
+
#
|
12
|
+
# @param [Integer] decimal_point
|
13
|
+
# MtGox sends integer values. We use this to divide that value.
|
14
|
+
#
|
15
|
+
# @return [Hash]
|
16
|
+
#
|
17
|
+
# @api private
|
18
|
+
#
|
19
|
+
def self.call(data)
|
20
|
+
value, currency = data.values_at("value_int", "currency")
|
21
|
+
decimal_point = currency == "BTC" ? 8 : 5
|
22
|
+
{value: BigDecimal(value) / (10 ** decimal_point), currency: currency}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Bitbot
|
2
|
+
module Trader
|
3
|
+
# Request class for different api methods
|
4
|
+
#
|
5
|
+
# @abstract
|
6
|
+
#
|
7
|
+
class Request
|
8
|
+
# Object that communicates with external API
|
9
|
+
#
|
10
|
+
# @return [Object]
|
11
|
+
#
|
12
|
+
# @api private
|
13
|
+
#
|
14
|
+
attr_reader :client
|
15
|
+
|
16
|
+
# Initializes request object
|
17
|
+
#
|
18
|
+
# @param [Object] client
|
19
|
+
#
|
20
|
+
# @return [undefined]
|
21
|
+
#
|
22
|
+
# @api private
|
23
|
+
#
|
24
|
+
def initialize(client)
|
25
|
+
@client = client
|
26
|
+
end
|
27
|
+
|
28
|
+
# Every request object implements call
|
29
|
+
#
|
30
|
+
# @abstract
|
31
|
+
#
|
32
|
+
# @return [undefined]
|
33
|
+
#
|
34
|
+
# @api private
|
35
|
+
#
|
36
|
+
def call(*)
|
37
|
+
raise NotImplementedError
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Bitbot
|
2
|
+
module Trader
|
3
|
+
module Utils
|
4
|
+
# Generates a nonce
|
5
|
+
#
|
6
|
+
# Some http requests use it to prevent double http requests
|
7
|
+
#
|
8
|
+
class NonceGenerator
|
9
|
+
# Generates a nonce from current time
|
10
|
+
#
|
11
|
+
# @return [Fixnum] nonce
|
12
|
+
#
|
13
|
+
# @api private
|
14
|
+
#
|
15
|
+
def self.generate(time_class = Time)
|
16
|
+
time_class.now.to_i
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "virtus"
|
2
|
+
|
3
|
+
module Bitbot
|
4
|
+
module Trader
|
5
|
+
# User USD/BTC/etc wallet
|
6
|
+
#
|
7
|
+
class Wallet
|
8
|
+
include Virtus.model
|
9
|
+
|
10
|
+
attribute :value, BigDecimal
|
11
|
+
attribute :currency, String
|
12
|
+
|
13
|
+
# Shows wallet balance
|
14
|
+
#
|
15
|
+
# @example
|
16
|
+
# wallet.balance #=> <BigDecimal>
|
17
|
+
#
|
18
|
+
# @return [BigDecimal]
|
19
|
+
#
|
20
|
+
# @api public
|
21
|
+
#
|
22
|
+
alias :balance :value
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Providers::Bitstamp, "#open_orders" do
|
4
|
+
let(:result) {
|
5
|
+
%![{"price": "20.10", "amount": "10.00000000", "type": 0, "id": 2826860, "datetime": "2013-04-20 12:12:14"}]!
|
6
|
+
}
|
7
|
+
|
8
|
+
let(:provider) { described_class.new({}, client) }
|
9
|
+
|
10
|
+
let(:client) { described_class::HttpClient.new(connection, "double", "double") }
|
11
|
+
let(:connection) { mock_connection("/open_orders/", result) }
|
12
|
+
|
13
|
+
it "parses open orders" do
|
14
|
+
open_orders = provider.open_orders
|
15
|
+
expect(open_orders.size).to be(1)
|
16
|
+
|
17
|
+
order = open_orders.first
|
18
|
+
expect(order.id).to eq("2826860")
|
19
|
+
|
20
|
+
amount = order.amount
|
21
|
+
expect(amount.value).to be_big_decimal(10)
|
22
|
+
expect(amount.currency).to eq("BTC")
|
23
|
+
|
24
|
+
price = order.price
|
25
|
+
expect(price.value).to be_big_decimal(20.10)
|
26
|
+
expect(price.currency).to eq("USD")
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Providers::MtGox, "#account" do
|
4
|
+
let(:result) {
|
5
|
+
%!{"result":"success","data":{"Login":"johndoe","Index":"193629","Id":"9b46b62e-4fd3-4cfa-9445-96f49dee846c","Rights":["get_info"],"Language":"en_US","Created":"2012-08-30 21:35:49","Last_Login":"2013-04-22 14:16:11","Wallets":{"BTC":{"Balance":{"value":"0.00000000","value_int":"0","display":"0.00000000\u00a0BTC","display_short":"0.00\u00a0BTC","currency":"BTC"},"Operations":164,"Daily_Withdraw_Limit":{"value":"100.00000000","value_int":"10000000000","display":"100.00000000\u00a0BTC","display_short":"100.00\u00a0BTC","currency":"BTC"},"Monthly_Withdraw_Limit":null,"Max_Withdraw":{"value":"100.00000000","value_int":"10000000000","display":"100.00000000\u00a0BTC","display_short":"100.00\u00a0BTC","currency":"BTC"},"Open_Orders":{"value":"0.00000000","value_int":"0","display":"0.00000000\u00a0BTC","display_short":"0.00\u00a0BTC","currency":"BTC"}},"EUR":{"Balance":{"value":"0.12314","value_int":"12314","display":"0.12314\u00a0\u20ac","display_short":"0.12\u00a0\u20ac","currency":"EUR"},"Operations":3,"Daily_Withdraw_Limit":{"value":"1000.00000","value_int":"100000000","display":"1,000.00000\u00a0\u20ac","display_short":"1,000.00\u00a0\u20ac","currency":"EUR"},"Monthly_Withdraw_Limit":{"value":"10000.00000","value_int":"1000000000","display":"10,000.00000\u00a0\u20ac","display_short":"10,000.00\u00a0\u20ac","currency":"EUR"},"Max_Withdraw":{"value":"1000.00000","value_int":"100000000","display":"1,000.00000\u00a0\u20ac","display_short":"1,000.00\u00a0\u20ac","currency":"EUR"},"Open_Orders":{"value":"0.00000","value_int":"0","display":"0.00000\u00a0\u20ac","display_short":"0.00\u00a0\u20ac","currency":"EUR"}},"USD":{"Balance":{"value":"3533.02728","value_int":"353302728","display":"$3,533.02728","display_short":"$3,533.03","currency":"USD"},"Operations":155,"Daily_Withdraw_Limit":{"value":"1000.00000","value_int":"100000000","display":"$1,000.00000","display_short":"$1,000.00","currency":"USD"},"Monthly_Withdraw_Limit":{"value":"10000.00000","value_int":"1000000000","display":"$10,000.00000","display_short":"$10,000.00","currency":"USD"},"Max_Withdraw":{"value":"1000.00000","value_int":"100000000","display":"$1,000.00000","display_short":"$1,000.00","currency":"USD"},"Open_Orders":{"value":"1053.50000","value_int":"105350000","display":"$1,053.50000","display_short":"$1,053.50","currency":"USD"}}},"Monthly_Volume":{"value":"413.48602792","value_int":"41348602792","display":"413.48602792\u00a0BTC","display_short":"413.49\u00a0BTC","currency":"BTC"},"Trade_Fee":0.53}}!
|
6
|
+
}
|
7
|
+
|
8
|
+
let(:provider) { described_class.new(options, client) }
|
9
|
+
let(:options) { {key: "double", secret: "double"} }
|
10
|
+
|
11
|
+
let(:client) { described_class::HttpClient.new(connection) }
|
12
|
+
let(:connection) { mock_connection("/money/info", result) }
|
13
|
+
|
14
|
+
it "parses account info" do
|
15
|
+
account = provider.account
|
16
|
+
|
17
|
+
expect(account.fee).to eq(0.53)
|
18
|
+
|
19
|
+
usd_wallet = account.wallet("USD")
|
20
|
+
expect(usd_wallet.balance).to eq(3533.02728)
|
21
|
+
|
22
|
+
eur_wallet = account.wallet("EUR")
|
23
|
+
expect(eur_wallet.balance).to eq(0.12314)
|
24
|
+
|
25
|
+
btc_wallet = account.wallet("BTC")
|
26
|
+
expect(btc_wallet.balance).to eq(0)
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Providers::MtGox, "#open_orders" do
|
4
|
+
let(:result) {
|
5
|
+
%!{"result":"success","data":[{"oid":"7c6d2237-52fb-4af4-b6ec-75e42f50c331","currency":"USD","item":"BTC","type":"bid","amount":{"value":"35.00000000","value_int":"3500000000","display":"35.00000000\u00a0BTC","display_short":"35.00\u00a0BTC","currency":"BTC"},"effective_amount":{"value":"35.00000000","value_int":"3500000000","display":"35.00000000\u00a0BTC","display_short":"35.00\u00a0BTC","currency":"BTC"},"price":{"value":"30.10000","value_int":"3010000","display":"$30.10000","display_short":"$30.10","currency":"USD"},"status":"open","date":1365886868,"priority":"1365886868485232","actions":[]}]}!
|
6
|
+
}
|
7
|
+
|
8
|
+
let(:provider) { described_class.new(options, client) }
|
9
|
+
let(:options) { {key: "double", secret: "double"} }
|
10
|
+
|
11
|
+
let(:client) { described_class::HttpClient.new(connection) }
|
12
|
+
let(:connection) { mock_connection("/money/orders", result) }
|
13
|
+
|
14
|
+
it "parses open orders" do
|
15
|
+
open_orders = provider.open_orders
|
16
|
+
expect(open_orders.size).to be(1)
|
17
|
+
|
18
|
+
order = open_orders.first
|
19
|
+
expect(order.id).to eq("7c6d2237-52fb-4af4-b6ec-75e42f50c331")
|
20
|
+
|
21
|
+
amount = order.amount
|
22
|
+
expect(amount.value).to eq(35)
|
23
|
+
expect(amount.currency).to eq("BTC")
|
24
|
+
|
25
|
+
price = order.price
|
26
|
+
expect(price.value).to eq(30.1)
|
27
|
+
expect(price.currency).to eq("USD")
|
28
|
+
end
|
29
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
if ENV["COVERAGE"] == "true"
|
2
|
+
require "simplecov"
|
3
|
+
require "coveralls"
|
4
|
+
|
5
|
+
SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
|
6
|
+
SimpleCov::Formatter::HTMLFormatter,
|
7
|
+
Coveralls::SimpleCov::Formatter
|
8
|
+
]
|
9
|
+
|
10
|
+
SimpleCov.start do
|
11
|
+
add_filter "spec"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
require "bitbot/trader"
|
16
|
+
|
17
|
+
include Bitbot::Trader
|
18
|
+
require "devtools/spec_helper"
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module HttpConnectionHelpers
|
2
|
+
def mock_connection(path, result)
|
3
|
+
Faraday.new { |builder|
|
4
|
+
builder.adapter :test do |stub|
|
5
|
+
stub.post(path) { [200, {}, result] }
|
6
|
+
end
|
7
|
+
}
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
RSpec.configure do |config|
|
12
|
+
config.include HttpConnectionHelpers, type: :integration, example_group: {
|
13
|
+
file_path: /spec\/integration/
|
14
|
+
}
|
15
|
+
end
|