historical-bank 0.1.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.
- checksums.yaml +7 -0
- data/AUTHORS +2 -0
- data/CHANGELOG.md +4 -0
- data/CONTRIBUTING.md +84 -0
- data/Gemfile +21 -0
- data/LICENSE +7 -0
- data/README.md +296 -0
- data/examples/add_get_rates.rb +110 -0
- data/examples/exchange.rb +75 -0
- data/historical-bank.gemspec +56 -0
- data/lib/money/bank/historical.rb +339 -0
- data/lib/money/rates_provider/open_exchange_rates.rb +123 -0
- data/lib/money/rates_store/historical_redis.rb +133 -0
- data/spec/bank/historical_spec.rb +503 -0
- data/spec/fixtures/time-series-2015-09.json +5199 -0
- data/spec/rates_provider/open_exchange_rates_spec.rb +201 -0
- data/spec/rates_store/historical_redis_spec.rb +176 -0
- data/spec/spec_helper.rb +29 -0
- metadata +209 -0
@@ -0,0 +1,123 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017 Skyscanner Limited.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
# frozen_string_literal: true
|
18
|
+
|
19
|
+
require 'money'
|
20
|
+
require 'httparty'
|
21
|
+
|
22
|
+
class Money
|
23
|
+
module RatesProvider
|
24
|
+
# Raised when a +RatesProvider+ request fails
|
25
|
+
class RequestFailed < StandardError; end
|
26
|
+
|
27
|
+
# Retrieves exchange rates from OpenExchangeRates.org API, relative
|
28
|
+
# to the given +base_currency+.
|
29
|
+
# It is fetching rates for all currencies in one request, as we are charged on a
|
30
|
+
# "per date" basis. I.e. one month's data for all currencies counts as 30 calls.
|
31
|
+
class OpenExchangeRates
|
32
|
+
include HTTParty
|
33
|
+
base_uri 'https://openexchangerates.org/api'
|
34
|
+
|
35
|
+
# minimum date that OER has data
|
36
|
+
# (https://docs.openexchangerates.org/docs/historical-json)
|
37
|
+
MIN_DATE = Date.new(1999, 1, 1).freeze
|
38
|
+
|
39
|
+
# ==== Parameters
|
40
|
+
# - +oer_app_id+ - App ID for the OpenExchangeRates API access (Enterprise or Unlimited plan)
|
41
|
+
# - +base_currency+ - The base currency that will be used for the OER requests. It should be a +Money::Currency+ object.
|
42
|
+
# - +timeout+ - The timeout in seconds to set on the requests
|
43
|
+
def initialize(oer_app_id, base_currency, timeout)
|
44
|
+
@oer_app_id = oer_app_id
|
45
|
+
@base_currency_code = base_currency.iso_code
|
46
|
+
@timeout = timeout
|
47
|
+
end
|
48
|
+
|
49
|
+
# Fetches the rates for all available quote currencies for a whole month.
|
50
|
+
# Fetching for all currencies or just one has the same API charge.
|
51
|
+
# In addition, the API doesn't allow fetching more than a month's data.
|
52
|
+
#
|
53
|
+
# It returns a +Hash+ with the rates for each quote currency and date
|
54
|
+
# as shown in the example. Rates are +BigDecimal+.
|
55
|
+
#
|
56
|
+
# ==== Parameters
|
57
|
+
#
|
58
|
+
# - +date+ - +date+'s month is the month for which we request rates. Minimum +date+ is January 1st 1999, as defined by the OER API (https://docs.openexchangerates.org/docs/api-introduction). Maximum +date+ is yesterday (UTC), as today's rates are not final (https://openexchangerates.org/faq/#timezone).
|
59
|
+
#
|
60
|
+
# ==== Errors
|
61
|
+
#
|
62
|
+
# - Raises +ArgumentError+ when +date+ is less than January 1st 1999, or greater than yesterday (UTC)
|
63
|
+
# - Raises +Money::RatesProvider::RequestFailed+ when the OER request fails
|
64
|
+
#
|
65
|
+
# ==== Examples
|
66
|
+
#
|
67
|
+
# oer.fetch_month_rates(Date.new(2016, 10, 5))
|
68
|
+
# # => {"AED"=>{"2016-10-01"=>#<BigDecimal:7fa19a188e98,'0.3672682E1',18(36)>, "2016-10-02"=>#<BigDecimal:7fa19b11a5c8,'0.367296E1',18(36)>, ...
|
69
|
+
def fetch_month_rates(date)
|
70
|
+
if date < MIN_DATE || date > max_date
|
71
|
+
raise ArgumentError, "Provided date #{date} for OER query should be "\
|
72
|
+
"between #{MIN_DATE} and #{max_date}"
|
73
|
+
end
|
74
|
+
|
75
|
+
end_of_month = Date.civil(date.year, date.month, -1)
|
76
|
+
|
77
|
+
start_date = Date.civil(date.year, date.month, 1)
|
78
|
+
end_date = [end_of_month, max_date].min
|
79
|
+
|
80
|
+
options = request_options(start_date, end_date)
|
81
|
+
response = self.class.get('/time-series.json', options)
|
82
|
+
|
83
|
+
unless response.success?
|
84
|
+
raise RequestFailed, "Month rates request failed for #{date} - "\
|
85
|
+
"Code: #{response.code} - Body: #{response.body}"
|
86
|
+
end
|
87
|
+
|
88
|
+
result = Hash.new { |hash, key| hash[key] = {} }
|
89
|
+
|
90
|
+
# sample response can be found in spec/fixtures.
|
91
|
+
# we're transforming the response from Hash[iso_date][iso_currency] to
|
92
|
+
# Hash[iso_currency][iso_date], as it will allow more efficient caching/retrieving
|
93
|
+
response['rates'].each do |iso_date, day_rates|
|
94
|
+
day_rates.each do |iso_currency, rate|
|
95
|
+
result[iso_currency][iso_date] = rate.to_d
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
result
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
|
104
|
+
def request_options(start_date, end_date)
|
105
|
+
{
|
106
|
+
query: {
|
107
|
+
app_id: @oer_app_id,
|
108
|
+
start: start_date,
|
109
|
+
end: end_date,
|
110
|
+
base: @base_currency_code
|
111
|
+
},
|
112
|
+
timeout: @timeout
|
113
|
+
}
|
114
|
+
end
|
115
|
+
|
116
|
+
# A historical day's rates can be obtained when the date changes at 00:00 UTC
|
117
|
+
# https://openexchangerates.org/faq/#timezone
|
118
|
+
def max_date
|
119
|
+
Time.now.utc.to_date - 1
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017 Skyscanner Limited.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
# frozen_string_literal: true
|
18
|
+
|
19
|
+
require 'money'
|
20
|
+
require 'redis'
|
21
|
+
|
22
|
+
class Money
|
23
|
+
module RatesStore
|
24
|
+
# Raised when a +RatesStore+ request fails
|
25
|
+
class RequestFailed < StandardError; end
|
26
|
+
|
27
|
+
# Exchange rates cache implemented with Redis.
|
28
|
+
#
|
29
|
+
# All cached rates are relative to the given +base_currency+.
|
30
|
+
# It stores +Hash+ es with keys formatted as +namespace:base_currency:quote_currency+ .
|
31
|
+
# These hashes contain ISO dates as keys with base currency rates as values.
|
32
|
+
#
|
33
|
+
# ==== Examples
|
34
|
+
#
|
35
|
+
# If +currency+ is the Redis namespace we used in the constructor,
|
36
|
+
# USD is the base currency, and we're looking for HUF rates, they can be found
|
37
|
+
# in the +currency:USD:HUF+ key, and their format will be
|
38
|
+
# {
|
39
|
+
# "2016-05-01": "0.272511002E3", # Rates are stored as BigDecimal Strings
|
40
|
+
# "2016-05-02": "0.270337998E3",
|
41
|
+
# "2016-05-03": "0.271477498E3"
|
42
|
+
# }
|
43
|
+
class HistoricalRedis
|
44
|
+
# ==== Parameters
|
45
|
+
#
|
46
|
+
# - +base_currency+ - The base currency relative to which all rates are stored
|
47
|
+
# - +redis_url+ - The URL of the Redis server
|
48
|
+
# - +namespace+ - Namespace with which to prefix all Redis keys
|
49
|
+
|
50
|
+
def initialize(base_currency, redis_url, namespace)
|
51
|
+
@base_currency = base_currency
|
52
|
+
@redis = Redis.new(url: redis_url)
|
53
|
+
@namespace = namespace
|
54
|
+
end
|
55
|
+
|
56
|
+
# Adds historical rates in bulk
|
57
|
+
#
|
58
|
+
# ==== Parameters
|
59
|
+
#
|
60
|
+
# +currency_date_rate_hash+ - A +Hash+ of exchange rates, broken down by currency and date. See the example for details.
|
61
|
+
#
|
62
|
+
# ==== Errors
|
63
|
+
#
|
64
|
+
# - Raises +ArgumentError+ when the base currency is included in +currency_date_rate_hash+ with rate other than 1.
|
65
|
+
# - Raises +Money::RatesStore::RequestFailed+ when the Redis request fails
|
66
|
+
#
|
67
|
+
# ==== Examples
|
68
|
+
#
|
69
|
+
# Assuming USD is the base currency
|
70
|
+
#
|
71
|
+
# rates = {
|
72
|
+
# 'EUR' => {
|
73
|
+
# '2015-09-10' => 0.11, # 1 USD = 0.11 EUR
|
74
|
+
# '2015-09-11' => 0.22
|
75
|
+
# },
|
76
|
+
# 'GBP' => {
|
77
|
+
# '2015-09-10' => 0.44, # 1 USD = 0.44 GBP
|
78
|
+
# '2015-09-11' => 0.55
|
79
|
+
# }
|
80
|
+
# }
|
81
|
+
# store.add_rates(rates)
|
82
|
+
|
83
|
+
def add_rates(currency_date_rate_hash)
|
84
|
+
if !currency_date_rate_hash[@base_currency.iso_code].nil? &&
|
85
|
+
!currency_date_rate_hash[@base_currency.iso_code].values.all? { |r| r == 1 }
|
86
|
+
|
87
|
+
raise ArgumentError, "When base currency #{@base_currency.iso_code} is included "\
|
88
|
+
"in given Hash #{currency_date_rate_hash}, its rate should "\
|
89
|
+
'be equal to 1'
|
90
|
+
end
|
91
|
+
|
92
|
+
@redis.pipelined do
|
93
|
+
currency_date_rate_hash.each do |iso_currency, iso_date_rate_hash|
|
94
|
+
k = key(iso_currency)
|
95
|
+
@redis.mapped_hmset(k, iso_date_rate_hash)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
rescue Redis::BaseError => e
|
99
|
+
raise RequestFailed, "Error while storing rates - #{e.message} - "\
|
100
|
+
"rates: #{currency_date_rate_hash}"
|
101
|
+
end
|
102
|
+
|
103
|
+
# Returns a +Hash+ of rates for all cached dates for the given currency.
|
104
|
+
#
|
105
|
+
# ==== Parameters
|
106
|
+
#
|
107
|
+
# - +currency+ - The quote currency for which we request all the cached rates. This is a +Money::Currency+ object.
|
108
|
+
#
|
109
|
+
# ==== Examples
|
110
|
+
#
|
111
|
+
# store.get_rates(Money::Currency.new('GBP'))
|
112
|
+
# # => {"2017-01-01"=>#<BigDecimal:7fa19ba27260,'0.809782E0',9(18)>, "2017-01-02"=>#<BigDecimal:7fa19ba27210,'0.814263E0',9(18)>, "2017-01-03"=>#<BigDecimal:7fa19ba271c0,'0.816721E0',9(18)>, ...
|
113
|
+
def get_rates(currency)
|
114
|
+
k = key(currency.iso_code)
|
115
|
+
iso_date_rate_hash = @redis.hgetall(k)
|
116
|
+
|
117
|
+
iso_date_rate_hash.each do |iso_date, rate_string|
|
118
|
+
iso_date_rate_hash[iso_date] = rate_string.to_d
|
119
|
+
end
|
120
|
+
rescue Redis::BaseError => e
|
121
|
+
raise RequestFailed, 'Error while retrieving rates for '\
|
122
|
+
"#{currency} - #{e.message}"\
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
# e.g. currency:EUR:USD
|
128
|
+
def key(currency_iso)
|
129
|
+
[@namespace, @base_currency.iso_code, currency_iso].join(':')
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,503 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2017 Skyscanner Limited.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
# frozen_string_literal: true
|
18
|
+
|
19
|
+
require 'spec_helper'
|
20
|
+
|
21
|
+
class Money
|
22
|
+
module Bank
|
23
|
+
describe Historical do
|
24
|
+
let(:base_currency) { Currency.new('EUR') }
|
25
|
+
let(:redis_url) { "redis://localhost:#{ENV['REDIS_PORT']}" }
|
26
|
+
let(:redis) { Redis.new(port: ENV['REDIS_PORT']) }
|
27
|
+
let(:redis_namespace) { 'currency_test' }
|
28
|
+
let(:bank) { Historical.instance }
|
29
|
+
|
30
|
+
before do
|
31
|
+
Historical.configure do |config|
|
32
|
+
config.base_currency = base_currency
|
33
|
+
config.redis_url = redis_url
|
34
|
+
config.redis_namespace = redis_namespace
|
35
|
+
config.oer_app_id = SecureRandom.hex
|
36
|
+
config.timeout = 20
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
after do
|
41
|
+
keys = redis.keys("#{redis_namespace}*")
|
42
|
+
redis.del(keys) unless keys.empty?
|
43
|
+
end
|
44
|
+
|
45
|
+
describe '#add_rates' do
|
46
|
+
let(:usd_date_rate_hash) do
|
47
|
+
{
|
48
|
+
'2015-09-10' => 0.11111.to_d,
|
49
|
+
'2015-09-11' => 0.22222.to_d,
|
50
|
+
'2015-09-12' => 0.33333.to_d
|
51
|
+
}
|
52
|
+
end
|
53
|
+
let(:currency_date_rate_hash) do
|
54
|
+
{
|
55
|
+
'USD' => usd_date_rate_hash,
|
56
|
+
'GBP' => {
|
57
|
+
'2015-09-10' => 0.44444.to_d,
|
58
|
+
'2015-09-11' => 0.55555.to_d,
|
59
|
+
'2015-09-12' => 0.66666.to_d
|
60
|
+
},
|
61
|
+
'VND' => {
|
62
|
+
'2015-09-10' => 0.77777.to_d,
|
63
|
+
'2015-09-11' => 0.88888.to_d,
|
64
|
+
'2015-09-12' => 0.99999.to_d
|
65
|
+
}
|
66
|
+
}
|
67
|
+
end
|
68
|
+
# it's pointing to the same cache as the one in bank
|
69
|
+
let(:new_store) do
|
70
|
+
RatesStore::HistoricalRedis.new(base_currency, redis_url, redis_namespace)
|
71
|
+
end
|
72
|
+
|
73
|
+
subject { bank.add_rates(currency_date_rate_hash) }
|
74
|
+
|
75
|
+
it 'sets the rates' do
|
76
|
+
subject
|
77
|
+
cached_rates = new_store.get_rates(Currency.new('USD'))
|
78
|
+
|
79
|
+
expect(cached_rates).to eq usd_date_rate_hash
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
describe '#add_rate' do
|
84
|
+
let(:rate) { rand }
|
85
|
+
|
86
|
+
# it's pointing to the same cache as the one in bank
|
87
|
+
let(:new_store) do
|
88
|
+
RatesStore::HistoricalRedis.new(base_currency, redis_url, redis_namespace)
|
89
|
+
end
|
90
|
+
let(:datetime) { Time.utc(2017, 1, 4, 13, 0, 0) }
|
91
|
+
|
92
|
+
subject { bank.add_rate(from_currency, to_currency, rate, datetime) }
|
93
|
+
|
94
|
+
context 'when base currency == from_currency' do
|
95
|
+
let(:from_currency) { 'EUR' }
|
96
|
+
let(:to_currency) { Money::Currency.new('USD') }
|
97
|
+
|
98
|
+
it "sets date's rate" do
|
99
|
+
subject
|
100
|
+
cached_rates = new_store.get_rates(to_currency)
|
101
|
+
|
102
|
+
cached_rate = cached_rates['2017-01-04']
|
103
|
+
expect(cached_rate).to be_within(0.00000001).of(rate)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
context 'when base currency == to_currency' do
|
108
|
+
let(:from_currency) { Money::Currency.new('USD') }
|
109
|
+
let(:to_currency) { Money::Currency.new('EUR') }
|
110
|
+
|
111
|
+
it "sets date's rate as the inverse" do
|
112
|
+
subject
|
113
|
+
cached_rates = new_store.get_rates(from_currency)
|
114
|
+
|
115
|
+
cached_rate = cached_rates['2017-01-04']
|
116
|
+
expect(cached_rate).to be_within(0.00000001).of(1 / rate)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
context 'when base currency does not match either of the passed currencies' do
|
121
|
+
let(:from_currency) { Money::Currency.new('USD') }
|
122
|
+
let(:to_currency) { 'GBP' }
|
123
|
+
|
124
|
+
it 'fails with ArgumentError' do
|
125
|
+
expect { subject }.to raise_error ArgumentError
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
context 'when datetime is a Date' do
|
130
|
+
let(:datetime) { Faker::Date.between(Date.today - 100, Date.today + 100) }
|
131
|
+
let(:from_currency) { 'EUR' }
|
132
|
+
let(:to_currency) { Money::Currency.new('USD') }
|
133
|
+
|
134
|
+
it "sets yesterday's rate" do
|
135
|
+
subject
|
136
|
+
cached_rates = new_store.get_rates(to_currency)
|
137
|
+
|
138
|
+
cached_rate = cached_rates[datetime.iso8601]
|
139
|
+
expect(cached_rate).to be_within(0.00000001).of(rate)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
context 'when datetime is not passed' do
|
144
|
+
let(:from_currency) { 'EUR' }
|
145
|
+
let(:to_currency) { Money::Currency.new('USD') }
|
146
|
+
let(:now) { Time.utc(2017, 12, 5, 13, 0, 0) }
|
147
|
+
|
148
|
+
subject { bank.add_rate(from_currency, to_currency, rate) }
|
149
|
+
|
150
|
+
before { Timecop.travel(now) }
|
151
|
+
after { Timecop.return }
|
152
|
+
|
153
|
+
it "sets yesterday's rate" do
|
154
|
+
subject
|
155
|
+
cached_rates = new_store.get_rates(to_currency)
|
156
|
+
|
157
|
+
cached_rate = cached_rates['2017-12-04']
|
158
|
+
expect(cached_rate).to be_within(0.00000001).of(rate)
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
describe '#get_rate' do
|
164
|
+
let(:from_currency) { 'EUR' }
|
165
|
+
let(:to_currency) { Money::Currency.new('USD') }
|
166
|
+
let(:rate) { rand }
|
167
|
+
|
168
|
+
context 'when datetime is passed' do
|
169
|
+
subject { bank.get_rate(from_currency, to_currency, datetime) }
|
170
|
+
|
171
|
+
before { bank.add_rate(from_currency, to_currency, rate, datetime) }
|
172
|
+
|
173
|
+
context 'and it is a Time' do
|
174
|
+
let(:datetime) { Time.utc(2016, 3, 12, 14, 0, 0) }
|
175
|
+
|
176
|
+
it 'returns the correct rate' do
|
177
|
+
expect(subject).to be_within(0.00000001).of(rate)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
context 'and it is a Date' do
|
182
|
+
let(:datetime) { Faker::Date.between(Date.today - 100, Date.today + 100) }
|
183
|
+
|
184
|
+
it 'returns the correct rate' do
|
185
|
+
expect(subject).to be_within(0.00000001).of(rate)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
context 'when datetime is not passed' do
|
191
|
+
subject { bank.get_rate(from_currency, to_currency) }
|
192
|
+
|
193
|
+
before { bank.add_rate(from_currency, to_currency, rate) }
|
194
|
+
|
195
|
+
it "returns yesterday's rate" do
|
196
|
+
expect(subject).to be_within(0.00000001).of(rate)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
describe '#exchange_with_historical' do
|
202
|
+
let(:from_currency) { Currency.wrap('USD') }
|
203
|
+
let(:from_money) { Money.new(10_000, from_currency) }
|
204
|
+
let(:to_currency) { Currency.wrap('GBP') }
|
205
|
+
let(:datetime) { Faker::Time.between(Date.today - 300, Date.today - 2) }
|
206
|
+
let(:date) { datetime.utc.to_date }
|
207
|
+
let(:from_currency_base_rate) { 1.1250721881474421.to_d }
|
208
|
+
let(:to_currency_base_rate) { 0.7346888078084116.to_d }
|
209
|
+
let(:from_currency_base_rates) do
|
210
|
+
{
|
211
|
+
(date - 1).iso8601 => rand,
|
212
|
+
date.iso8601 => from_currency_base_rate,
|
213
|
+
(date + 1).iso8601 => rand
|
214
|
+
}
|
215
|
+
end
|
216
|
+
let(:to_currency_base_rates) do
|
217
|
+
{
|
218
|
+
(date - 1).iso8601 => rand,
|
219
|
+
date.iso8601 => to_currency_base_rate,
|
220
|
+
(date + 1).iso8601 => rand
|
221
|
+
}
|
222
|
+
end
|
223
|
+
let(:expected_result) { Money.new(6530, to_currency) }
|
224
|
+
|
225
|
+
before do
|
226
|
+
allow_any_instance_of(RatesStore::HistoricalRedis).to receive(:get_rates)
|
227
|
+
.with(from_currency).and_return(from_currency_base_rates_store)
|
228
|
+
allow_any_instance_of(RatesStore::HistoricalRedis).to receive(:get_rates)
|
229
|
+
.with(to_currency).and_return(to_currency_base_rates_store)
|
230
|
+
allow_any_instance_of(RatesProvider::OpenExchangeRates)
|
231
|
+
.to receive(:fetch_month_rates).with(date).and_return(rates_provider)
|
232
|
+
end
|
233
|
+
|
234
|
+
subject { bank.exchange_with_historical(from_money, to_currency, datetime) }
|
235
|
+
|
236
|
+
describe 'to_currency parameter' do
|
237
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
238
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
239
|
+
let(:rates_provider) { nil }
|
240
|
+
|
241
|
+
context 'when iso code string' do
|
242
|
+
let(:to_currency) { 'GBP' }
|
243
|
+
|
244
|
+
it { is_expected.to eq expected_result }
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
describe 'datetime type' do
|
249
|
+
let(:datetime) { Faker::Time.between(Date.today - 300, Date.today - 2) }
|
250
|
+
let(:date) { datetime.utc.to_date }
|
251
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
252
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
253
|
+
let(:rates_provider) { nil }
|
254
|
+
|
255
|
+
it 'returns same result when passing a date or time on that date' do
|
256
|
+
time_result = bank.exchange_with_historical(from_money, to_currency, datetime)
|
257
|
+
date_result = bank.exchange_with_historical(from_money, to_currency, date)
|
258
|
+
|
259
|
+
expect(time_result).to eq date_result
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
describe 'selecting data source' do
|
264
|
+
context 'when both rates exist in Redis' do
|
265
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
266
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
267
|
+
let(:rates_provider) { nil }
|
268
|
+
|
269
|
+
it { is_expected.to eq expected_result }
|
270
|
+
end
|
271
|
+
|
272
|
+
context "when from_currency rate doesn't exist in Redis" do
|
273
|
+
let(:from_currency_base_rates_store) { nil }
|
274
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
275
|
+
let(:rates_provider) { { from_currency.iso_code => from_currency_base_rates } }
|
276
|
+
|
277
|
+
it { is_expected.to eq expected_result }
|
278
|
+
end
|
279
|
+
|
280
|
+
context 'when from_currency rate exists in Redis for other dates' do
|
281
|
+
let(:from_currency_base_rates_other_dates) do
|
282
|
+
{
|
283
|
+
(date - 3).iso8601 => rand,
|
284
|
+
(date - 2).iso8601 => rand,
|
285
|
+
(date - 1).iso8601 => rand
|
286
|
+
}
|
287
|
+
end
|
288
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates_other_dates }
|
289
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
290
|
+
let(:rates_provider) { { from_currency.iso_code => from_currency_base_rates } }
|
291
|
+
|
292
|
+
it { is_expected.to eq expected_result }
|
293
|
+
end
|
294
|
+
|
295
|
+
context "when to_currency rate doesn't exist in Redis" do
|
296
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
297
|
+
let(:to_currency_base_rates_store) { nil }
|
298
|
+
let(:rates_provider) { { to_currency.iso_code => to_currency_base_rates } }
|
299
|
+
|
300
|
+
it { is_expected.to eq expected_result }
|
301
|
+
end
|
302
|
+
|
303
|
+
context 'when to_currency rate exists in Redis for other dates' do
|
304
|
+
let(:to_currency_base_rates_other_dates) do
|
305
|
+
{
|
306
|
+
(date + 1).iso8601 => rand,
|
307
|
+
(date + 2).iso8601 => rand,
|
308
|
+
(date + 3).iso8601 => rand
|
309
|
+
}
|
310
|
+
end
|
311
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
312
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates_other_dates }
|
313
|
+
let(:rates_provider) { { to_currency.iso_code => to_currency_base_rates } }
|
314
|
+
|
315
|
+
it { is_expected.to eq expected_result }
|
316
|
+
end
|
317
|
+
|
318
|
+
context 'when neither of the rates exists in Redis' do
|
319
|
+
let(:from_currency_base_rates_store) { nil }
|
320
|
+
let(:to_currency_base_rates_store) { nil }
|
321
|
+
let(:rates_provider) do
|
322
|
+
{
|
323
|
+
from_currency.iso_code => from_currency_base_rates,
|
324
|
+
to_currency.iso_code => to_currency_base_rates
|
325
|
+
}
|
326
|
+
end
|
327
|
+
|
328
|
+
it { is_expected.to eq expected_result }
|
329
|
+
end
|
330
|
+
|
331
|
+
context 'when from_currency == to_currency' do
|
332
|
+
let(:to_currency) { from_money.currency }
|
333
|
+
let(:from_currency_base_rates_store) { nil }
|
334
|
+
let(:to_currency_base_rates_store) { nil }
|
335
|
+
let(:rates_provider) { nil }
|
336
|
+
let(:expected_result) { from_money }
|
337
|
+
|
338
|
+
it { is_expected.to eq expected_result }
|
339
|
+
end
|
340
|
+
|
341
|
+
context 'when from_currency == base_currency' do
|
342
|
+
let(:from_money) { Money.new(10_000, base_currency) }
|
343
|
+
let(:from_currency_base_rates_store) { nil }
|
344
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
345
|
+
let(:rates_provider) { nil }
|
346
|
+
let(:expected_result) { Money.new(7347, to_currency) }
|
347
|
+
|
348
|
+
it { is_expected.to eq expected_result }
|
349
|
+
end
|
350
|
+
|
351
|
+
context 'when to_currency == base_currency' do
|
352
|
+
let(:to_currency) { base_currency }
|
353
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
354
|
+
let(:to_currency_base_rates_store) { nil }
|
355
|
+
let(:rates_provider) { nil }
|
356
|
+
let(:expected_result) { Money.new(8888, to_currency) }
|
357
|
+
|
358
|
+
it { is_expected.to eq expected_result }
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
# taken from real rates from XE.com
|
363
|
+
describe 'money conversion' do
|
364
|
+
let(:from_currency_base_rates_store) { from_currency_base_rates }
|
365
|
+
let(:to_currency_base_rates_store) { to_currency_base_rates }
|
366
|
+
let(:rates_provider) { nil }
|
367
|
+
|
368
|
+
context 'for rates example 1' do
|
369
|
+
let(:from_currency) { Currency.wrap('USD') }
|
370
|
+
let(:from_money) { Money.new(500_00, from_currency) }
|
371
|
+
let(:to_currency) { Currency.wrap('GBP') }
|
372
|
+
let(:from_currency_base_rate) { 1.13597.to_d }
|
373
|
+
let(:to_currency_base_rate) { 0.735500.to_d }
|
374
|
+
let(:expected_result) { Money.new(323_73, to_currency) }
|
375
|
+
|
376
|
+
it { is_expected.to eq expected_result }
|
377
|
+
end
|
378
|
+
|
379
|
+
context 'for rates example 2' do
|
380
|
+
let(:from_currency) { Currency.wrap('INR') }
|
381
|
+
let(:from_money) { Money.new(6_516_200, from_currency) }
|
382
|
+
let(:to_currency) { Currency.wrap('CAD') }
|
383
|
+
let(:from_currency_base_rate) { 73.5602.to_d }
|
384
|
+
let(:to_currency_base_rate) { 1.46700.to_d }
|
385
|
+
let(:expected_result) { Money.new(1_299_52, to_currency) }
|
386
|
+
|
387
|
+
it { is_expected.to eq expected_result }
|
388
|
+
end
|
389
|
+
|
390
|
+
# VND has no decimal places
|
391
|
+
context 'for rates example 3' do
|
392
|
+
let(:from_currency) { Currency.wrap('SGD') }
|
393
|
+
let(:from_money) { Money.new(345_67, from_currency) }
|
394
|
+
let(:to_currency) { Currency.wrap('VND') }
|
395
|
+
let(:from_currency_base_rate) { 1.57222.to_d }
|
396
|
+
let(:to_currency_base_rate) { 25_160.75.to_d }
|
397
|
+
let(:expected_result) { Money.new(5_531_870, to_currency) }
|
398
|
+
|
399
|
+
it { is_expected.to eq expected_result }
|
400
|
+
end
|
401
|
+
|
402
|
+
# KWD has 3 decimal places
|
403
|
+
context 'for rates example 4' do
|
404
|
+
let(:from_currency) { Currency.wrap('CNY') }
|
405
|
+
let(:from_money) { Money.new(987_654, from_currency) }
|
406
|
+
let(:to_currency) { Currency.wrap('KWD') }
|
407
|
+
let(:from_currency_base_rate) { 7.21517.to_d }
|
408
|
+
let(:to_currency_base_rate) { 0.342725.to_d }
|
409
|
+
let(:expected_result) { Money.new(469_142, to_currency) }
|
410
|
+
|
411
|
+
it { is_expected.to eq expected_result }
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
context 'when OER client fails with ArgumentError' do
|
416
|
+
let(:datetime) { Faker::Time.between(Date.new(1990, 1, 1), Date.new(1998, 12, 31)) }
|
417
|
+
let(:from_currency_base_rates_store) { nil }
|
418
|
+
let(:to_currency_base_rates_store) { nil }
|
419
|
+
let(:rates_provider) { nil }
|
420
|
+
|
421
|
+
before do
|
422
|
+
# unstub and let it blow up
|
423
|
+
allow_any_instance_of(RatesProvider::OpenExchangeRates)
|
424
|
+
.to receive(:fetch_month_rates).and_call_original
|
425
|
+
end
|
426
|
+
|
427
|
+
it 'fails' do
|
428
|
+
expect { subject }.to raise_error(ArgumentError)
|
429
|
+
end
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
433
|
+
describe '#exchange_with' do
|
434
|
+
let(:from_currency) { Currency.wrap('USD') }
|
435
|
+
let(:from_money) { Money.new(10_000, from_currency) }
|
436
|
+
let(:to_currency) { Currency.wrap('GBP') }
|
437
|
+
let(:utc_date) { Time.now.utc.to_date }
|
438
|
+
let(:from_currency_base_rate) { 1.1250721881474421.to_d }
|
439
|
+
let(:to_currency_base_rate) { 0.7346888078084116.to_d }
|
440
|
+
let(:from_currency_base_rates_store) do
|
441
|
+
{
|
442
|
+
(utc_date - 3).iso8601 => rand,
|
443
|
+
(utc_date - 2).iso8601 => rand,
|
444
|
+
(utc_date - 1).iso8601 => from_currency_base_rate
|
445
|
+
}
|
446
|
+
end
|
447
|
+
let(:to_currency_base_rates_store) do
|
448
|
+
{
|
449
|
+
(utc_date - 3).iso8601 => rand,
|
450
|
+
(utc_date - 2).iso8601 => rand,
|
451
|
+
(utc_date - 1).iso8601 => to_currency_base_rate
|
452
|
+
}
|
453
|
+
end
|
454
|
+
let(:expected_result) { Money.new(6530, to_currency) }
|
455
|
+
|
456
|
+
before do
|
457
|
+
allow_any_instance_of(Money::RatesStore::HistoricalRedis).to receive(:get_rates)
|
458
|
+
.with(from_currency).and_return(from_currency_base_rates_store)
|
459
|
+
allow_any_instance_of(Money::RatesStore::HistoricalRedis).to receive(:get_rates)
|
460
|
+
.with(to_currency).and_return(to_currency_base_rates_store)
|
461
|
+
end
|
462
|
+
|
463
|
+
subject { bank.exchange_with(from_money, to_currency) }
|
464
|
+
|
465
|
+
it "selects yesterday's rates" do
|
466
|
+
expect(subject).to eq expected_result
|
467
|
+
end
|
468
|
+
end
|
469
|
+
end
|
470
|
+
end
|
471
|
+
|
472
|
+
describe Money do
|
473
|
+
describe '#exchange_with_historical' do
|
474
|
+
let(:base_currency) { Currency.new('EUR') }
|
475
|
+
let(:redis_url) { "redis://localhost:#{ENV['REDIS_PORT']}" }
|
476
|
+
let(:redis_namespace) { 'currency_test' }
|
477
|
+
let(:rates) do
|
478
|
+
{
|
479
|
+
'USD' => { '2015-09-10' => 0.11 },
|
480
|
+
'GBP' => { '2015-09-10' => 0.44 }
|
481
|
+
}
|
482
|
+
end
|
483
|
+
let(:money) { Money.new(100_00, 'USD') }
|
484
|
+
let(:to_currency) { Money::Currency.new('GBP') }
|
485
|
+
let(:datetime) { Date.new(2015, 9, 10) }
|
486
|
+
|
487
|
+
before do
|
488
|
+
Bank::Historical.configure do |config|
|
489
|
+
config.base_currency = base_currency
|
490
|
+
config.redis_url = redis_url
|
491
|
+
config.redis_namespace = redis_namespace
|
492
|
+
end
|
493
|
+
|
494
|
+
Bank::Historical.instance.add_rates(rates)
|
495
|
+
end
|
496
|
+
|
497
|
+
subject { money.exchange_to_historical(to_currency, datetime) }
|
498
|
+
|
499
|
+
# on Sept 10th, 100 EUR = 100 / 0.11 USD = 100 / 0.11 * 0.44 GBP = 400 GBP
|
500
|
+
it { is_expected.to eq Money.new(400_00, 'GBP') }
|
501
|
+
end
|
502
|
+
end
|
503
|
+
end
|