historical-bank 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,75 @@
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/bank/historical'
20
+
21
+ redis_url = 'redis://localhost:6379'
22
+ namespace = 'currency_example'
23
+
24
+ Money::Bank::Historical.configure do |config|
25
+ # (required) your OpenExchangeRates App ID
26
+ config.oer_app_id = 'XXXXXXXXXXXXXXXXXXXXX'
27
+
28
+ # (optional) currency relative to which all the rates are stored (default: EUR)
29
+ config.base_currency = Money::Currency.new('USD')
30
+
31
+ # (optional) the URL of the Redis server (default: 'redis://localhost:6379')
32
+ config.redis_url = redis_url
33
+
34
+ # (optional) Redis namespace to prefix all keys (default: 'currency')
35
+ config.redis_namespace = namespace
36
+
37
+ # (optional) set a timeout for the OER calls (default: 15 seconds)
38
+ config.timeout = 20
39
+ end
40
+
41
+ bank = Money::Bank::Historical.instance
42
+
43
+ # In Rails, you can set this in the MoneyRails.configure block
44
+ Money.default_bank = bank
45
+
46
+ from_money = Money.new(100_00, 'EUR')
47
+ to_currency = 'GBP'
48
+
49
+ ########## Exchange with the Bank object ##############
50
+
51
+ # exchange money normally as you do with normal banks (uses yesterday's closing rates)
52
+ bank.exchange_with(from_money, to_currency)
53
+
54
+ # exchange money with rates from December 10th 2016
55
+ bank.exchange_with_historical(from_money, to_currency, Date.new(2016, 12, 10))
56
+ # => #<Money fractional:8399 currency:GBP>
57
+
58
+ # can also pass a Time/DateTime object
59
+ bank.exchange_with_historical(from_money, to_currency, Time.utc(2016, 10, 2, 11, 0, 0))
60
+ # => #<Money fractional:8691 currency:GBP>
61
+
62
+ ########## Exchange with the Money object ##############
63
+
64
+ # since it is set as the default bank, we can call Money#exchange_to (uses yesterday's closing rates)
65
+ from_money.exchange_to(to_currency)
66
+
67
+ # same result with a direct call on the Money object
68
+ from_money.exchange_to_historical(to_currency, Date.new(2016, 12, 10))
69
+ # => #<Money fractional:8399 currency:GBP>
70
+
71
+ ########## Clean up Redis keys used here ##############
72
+
73
+ redis = Redis.new(url: redis_url)
74
+ keys = redis.keys("#{namespace}*")
75
+ redis.del(keys)
@@ -0,0 +1,56 @@
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
+ Gem::Specification.new do |s|
20
+ s.name = 'historical-bank'
21
+ s.version = '0.1.0'
22
+ s.summary = 'Historical Bank'
23
+ s.description = 'A `Money::Bank::Base` with historical exchange rates'
24
+ s.authors = ['Kostis Dadamis', 'Emili Parreno']
25
+ s.email = ['kostis.dadamis@skyscanner.net']
26
+ s.homepage = 'https://github.com/Skyscanner/historical-bank-ruby'
27
+ s.license = 'Apache-2.0'
28
+
29
+ require 'rake'
30
+ s.files = FileList['lib/**/*.rb', 'Gemfile', 'examples/*.rb',
31
+ 'historical-bank.gemspec', 'spec/**/*.rb'].to_a
32
+ s.files += ['README.md', 'LICENSE', 'CONTRIBUTING.md', 'AUTHORS',
33
+ 'CHANGELOG.md', 'spec/fixtures/time-series-2015-09.json']
34
+
35
+ s.test_files = s.files.grep(%r{^spec/})
36
+
37
+ s.extra_rdoc_files = ['README.md']
38
+
39
+ s.requirements = 'redis'
40
+
41
+ s.require_path = 'lib'
42
+
43
+ s.required_ruby_version = '>= 2.0.0'
44
+
45
+ s.add_runtime_dependency 'money', '~> 6.7'
46
+ s.add_runtime_dependency 'httparty', '~> 0.14'
47
+ s.add_runtime_dependency 'redis', '~> 3.3'
48
+
49
+ s.add_development_dependency 'rspec', '~> 3.5'
50
+ s.add_development_dependency 'pry-byebug', '~> 3.4'
51
+ s.add_development_dependency 'rubocop', '~> 0.46'
52
+ s.add_development_dependency 'rack-test', '~> 0.6'
53
+ s.add_development_dependency 'webmock', '~> 2.3'
54
+ s.add_development_dependency 'faker', '~> 1.6'
55
+ s.add_development_dependency 'timecop', '~> 0.8'
56
+ end
@@ -0,0 +1,339 @@
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 'money/rates_provider/open_exchange_rates'
21
+ require 'money/rates_store/historical_redis'
22
+
23
+ class Money
24
+ module Bank
25
+ # Bank that serves historical exchange rates. Inherits from
26
+ # +Money::Bank::Base+
27
+ class Historical < Bank::Base
28
+ # Configuration class for +Money::Bank::Historical+
29
+ class Configuration
30
+ # +Money::Currency+ relative to which all exchange rates will be cached
31
+ attr_accessor :base_currency
32
+ # URL of the Redis server
33
+ attr_accessor :redis_url
34
+ # Redis namespace in which the exchange rates will be cached
35
+ attr_accessor :redis_namespace
36
+ # OpenExchangeRates app ID
37
+ attr_accessor :oer_app_id
38
+ # timeout to set in the OpenExchangeRates requests
39
+ attr_accessor :timeout
40
+
41
+ def initialize
42
+ @base_currency = Currency.new('EUR')
43
+ @redis_url = 'redis://localhost:6379'
44
+ @redis_namespace = 'currency'
45
+ @oer_app_id = nil
46
+ @timeout = 15
47
+ end
48
+ end
49
+
50
+ # Returns the configuration (+Money::Bank::Historical::Configuration+)
51
+ def self.configuration
52
+ @configuration ||= Configuration.new
53
+ end
54
+
55
+ # Configures the bank. Parameters that can be configured:
56
+ # - +oer_app_id+ - (required) your OpenExchangeRates App ID
57
+ # - +base_currency+ - (optional) +Money::Currency+ relative to which all the rates are stored (default: EUR)
58
+ # - +redis_url+ - (optional) the URL of the Redis server (default: +redis://localhost:6379+)
59
+ # - +redis_namespace+ - (optional) Redis namespace to prefix all keys (default: +currency+)
60
+ # - +timeout+ - (optional) set a timeout for the OER calls (default: 15 seconds)
61
+ #
62
+ # ==== Examples
63
+ #
64
+ # Money::Bank::Historical.configure do |config|
65
+ # config.oer_app_id = 'XXXXXXXXXXXXXXXXXXXXX'
66
+ # config.base_currency = Money::Currency.new('USD')
67
+ # config.redis_url = 'redis://localhost:6379'
68
+ # config.redis_namespace = 'currency'
69
+ # config.timeout = 20
70
+ # end
71
+ def self.configure
72
+ yield(configuration)
73
+ instance.setup
74
+ end
75
+
76
+ # Called at the end of the superclass' +initialize+ and also when
77
+ # configuration changes. It initializes/resets all the instance variables.
78
+ def setup
79
+ @base_currency = Historical.configuration.base_currency
80
+ # Hash[iso_currency][iso_date]
81
+ @rates = {}
82
+ @store = RatesStore::HistoricalRedis.new(@base_currency,
83
+ Historical.configuration.redis_url,
84
+ Historical.configuration.redis_namespace)
85
+ @provider = RatesProvider::OpenExchangeRates.new(Historical.configuration.oer_app_id,
86
+ @base_currency,
87
+ Historical.configuration.timeout)
88
+ # for controlling access to @rates
89
+ @mutex = Mutex.new
90
+ end
91
+
92
+ # Adds historical rates in bulk to the Redis cache.
93
+ #
94
+ # ==== Parameters
95
+ #
96
+ # +currency_date_rate_hash+ - A +Hash+ of exchange rates, broken down by currency and date. See the example for details.
97
+ #
98
+ # ==== Examples
99
+ #
100
+ # Assuming USD is the base currency
101
+ #
102
+ # rates = {
103
+ # 'EUR' => {
104
+ # '2015-09-10' => 0.11, # 1 USD = 0.11 EUR
105
+ # '2015-09-11' => 0.22
106
+ # },
107
+ # 'GBP' => {
108
+ # '2015-09-10' => 0.44, # 1 USD = 0.44 GBP
109
+ # '2015-09-11' => 0.55
110
+ # }
111
+ # }
112
+ # bank.add_rates(rates)
113
+
114
+ def add_rates(currency_date_rate_hash)
115
+ @store.add_rates(currency_date_rate_hash)
116
+ end
117
+
118
+ # Adds a single rate for a specific date to the Redis cache.
119
+ # If no datetime is passed, it defaults to yesterday (UTC).
120
+ # One of the passed currencies should match the base currency.
121
+ #
122
+ # ==== Parameters
123
+ #
124
+ # - +from_currency+ - Fixed currency of the +rate+ (https://en.wikipedia.org/wiki/Exchange_rate#Quotations). Accepts ISO String and +Money::Currency+ objects.
125
+ # - +to_currency+ - Variable currency of the +rate+ (https://en.wikipedia.org/wiki/Exchange_rate#Quotations). Accepts ISO String and +Money::Currency+ objects.
126
+ # - +rate+ - The price of 1 unit of +from_currency+ in +to_currency+.
127
+ # - +datetime+ - The +Date+ this +rate+ was observed. If +Time+ is passed instead, it's converted to the UTC +Date+. If no +datetime+ is passed, it defaults to yesterday (UTC).
128
+ #
129
+ # ==== Errors
130
+ #
131
+ # - Raises +ArgumentError+ when neither +from_currency+, nor +to_currency+ match the +base_currency+ given in the configuration.
132
+ #
133
+ # ==== Examples
134
+ #
135
+ # Assuming USD is the base currency
136
+ #
137
+ # from_money = Money.new(100_00, 'EUR')
138
+ # to_currency = 'GBP'
139
+ #
140
+ # date = Date.new(2016, 5, 18)
141
+ #
142
+ # # 1 EUR = 1.2 USD on May 18th 2016
143
+ # bank.add_rate('EUR', 'USD', 1.2, date)
144
+ # # 1 USD = 0.8 GBP on May 18th 2016
145
+ # bank.add_rate(Money::Currency.new('USD'), Money::Currency.new('GBP'), 0.8, date)
146
+ #
147
+ # # 100 EUR = 100 * 1.2 USD = 100 * 1.2 * 0.8 GBP = 96 GBP
148
+ # bank.exchange_with_historical(from_money, to_currency, date)
149
+ # # => #<Money fractional:9600 currency:GBP>
150
+
151
+ def add_rate(from_currency, to_currency, rate, datetime = yesterday_utc)
152
+ from_currency = Currency.wrap(from_currency)
153
+ to_currency = Currency.wrap(to_currency)
154
+
155
+ if from_currency != @base_currency && to_currency != @base_currency
156
+ raise ArgumentError, "`from_currency` (#{from_currency.iso_code}) or "\
157
+ "`to_currency` (#{to_currency.iso_code}) should "\
158
+ "match the base currency #{@base_currency.iso_code}"
159
+ end
160
+
161
+ date = datetime_to_date(datetime)
162
+
163
+ currency_date_rate_hash = if from_currency == @base_currency
164
+ {
165
+ to_currency.iso_code => {
166
+ date.iso8601 => rate
167
+ }
168
+ }
169
+ else
170
+ {
171
+ from_currency.iso_code => {
172
+ date.iso8601 => 1 / rate
173
+ }
174
+ }
175
+ end
176
+
177
+ add_rates(currency_date_rate_hash)
178
+ end
179
+
180
+ # Returns the +BigDecimal+ rate for converting +from_currency+
181
+ # to +to_currency+ on a specific date. This is the price of 1 unit of
182
+ # +from_currency+ in +to_currency+ on that date.
183
+ # If rate is not found in the Redis cache, it is fetched from
184
+ # OpenExchangeRates.
185
+ # If no +datetime+ is passed, it defaults to yesterday (UTC).
186
+ #
187
+ # ==== Parameters
188
+ #
189
+ # - +from_currency+ - Fixed currency of the returned rate (https://en.wikipedia.org/wiki/Exchange_rate#Quotations). Accepts ISO String and +Money::Currency+ objects.
190
+ # - +to_currency+ - Variable currency of the returned rate (https://en.wikipedia.org/wiki/Exchange_rate#Quotations). Accepts ISO String and +Money::Currency+ objects.
191
+ # - +datetime+ - The +Date+ the returned rate was observed. If +Time+ is passed instead, it's converted to the UTC +Date+. If no +datetime+ is passed, it defaults to yesterday (UTC).
192
+ #
193
+ # ==== Examples
194
+ #
195
+ # bank.get_rate(Money::Currency.new('GBP'), 'CAD', Date.new(2016, 10, 1))
196
+ # # => #<BigDecimal:7fd39fd2cb78,'0.1703941289 451827243E1',27(45)>
197
+
198
+ def get_rate(from_currency, to_currency, datetime = yesterday_utc)
199
+ from_currency = Currency.wrap(from_currency)
200
+ to_currency = Currency.wrap(to_currency)
201
+
202
+ date = datetime_to_date(datetime)
203
+
204
+ rate_on_date(from_currency, to_currency, date)
205
+ end
206
+
207
+ # Exchanges +from_money+ to +to_currency+ using yesterday's
208
+ # closing rates and returns a new +Money+ object.
209
+ #
210
+ # ==== Parameters
211
+ #
212
+ # - +from_money+ - The +Money+ object to exchange
213
+ # - +to_currency+ - The currency to exchange +from_money+ to. Accepts ISO String and +Money::Currency+ objects.
214
+ def exchange_with(from_money, to_currency)
215
+ exchange_with_historical(from_money, to_currency, yesterday_utc)
216
+ end
217
+
218
+ # Exchanges +from_money+ to +to_currency+ using +datetime+'s
219
+ # closing rates and returns a new +Money+ object.
220
+ #
221
+ # ==== Parameters
222
+ #
223
+ # - +from_money+ - The +Money+ object to exchange
224
+ # - +to_currency+ - The currency to exchange +from_money+ to. Accepts ISO String and +Money::Currency+ objects.
225
+ # - +datetime+ - The +Date+ to get the exchange rate from. If +Time+ is passed instead, it's converted to the UTC +Date+.
226
+ def exchange_with_historical(from_money, to_currency, datetime)
227
+ date = datetime_to_date(datetime)
228
+
229
+ from_currency = from_money.currency
230
+ to_currency = Currency.wrap(to_currency)
231
+
232
+ rate = rate_on_date(from_currency, to_currency, date)
233
+ to_amount = from_money.amount * rate
234
+
235
+ Money.from_amount(to_amount, to_currency)
236
+ end
237
+
238
+ private
239
+
240
+ def datetime_to_date(datetime)
241
+ datetime.is_a?(Date) ? datetime : datetime.utc.to_date
242
+ end
243
+
244
+ # rate for converting 1 unit of from_currency (e.g. USD) to to_currency (e.g. GBP).
245
+ # Comments below assume EUR is the base currency,
246
+ # 1 EUR = 1.21 USD, and 1 EUR = 0.83 GBP on given date
247
+ def rate_on_date(from_currency, to_currency, date)
248
+ return 1 if from_currency == to_currency
249
+
250
+ # 1 EUR = 1.21 USD => 1 USD = 1/1.21 EUR
251
+ from_base_to_from_rate = base_rate_on_date(from_currency, date)
252
+ # 1 EUR = 0.83 GBP
253
+ from_base_to_to_rate = base_rate_on_date(to_currency, date)
254
+
255
+ # 1 USD = 1/1.21 EUR = (1/1.21) * 0.83 GBP = 0.83/1.21 GBP
256
+ from_base_to_to_rate / from_base_to_from_rate
257
+ end
258
+
259
+ # rate for converting 1 unit of base currency to currency
260
+ def base_rate_on_date(currency, date)
261
+ return 1 if @base_currency == currency
262
+
263
+ rate = get_base_rate(currency, date) ||
264
+ fetch_stored_base_rate(currency, date) ||
265
+ fetch_provider_base_rate(currency, date)
266
+
267
+ if rate.nil?
268
+ raise UnknownRate, "Rate from #{currency} to #{@base_currency} "\
269
+ "on #{date} not found"
270
+ end
271
+
272
+ rate
273
+ end
274
+
275
+ def fetch_stored_base_rate(currency, date)
276
+ date_rate_hash = @store.get_rates(currency)
277
+
278
+ if date_rate_hash && !date_rate_hash.empty?
279
+ rate = date_rate_hash[date.iso8601]
280
+ set_base_rates(currency, date_rate_hash)
281
+
282
+ rate
283
+ end
284
+ end
285
+
286
+ def fetch_provider_base_rate(currency, date)
287
+ currency_date_rate_hash = @provider.fetch_month_rates(date)
288
+
289
+ date_rate_hash = currency_date_rate_hash[currency.iso_code]
290
+ rate = date_rate_hash && date_rate_hash[date.iso8601]
291
+
292
+ if currency_date_rate_hash && !currency_date_rate_hash.empty?
293
+ @store.add_rates(currency_date_rate_hash)
294
+ end
295
+
296
+ if date_rate_hash && !date_rate_hash.empty?
297
+ set_base_rates(currency, date_rate_hash)
298
+ end
299
+
300
+ rate
301
+ end
302
+
303
+ def set_base_rates(currency, date_rate_hash)
304
+ iso_currency = currency.iso_code
305
+ @mutex.synchronize do
306
+ @rates[iso_currency] = {} if @rates[iso_currency].nil?
307
+ @rates[iso_currency].merge!(date_rate_hash)
308
+ end
309
+ end
310
+
311
+ def get_base_rate(currency, date)
312
+ @mutex.synchronize do
313
+ rates = @rates[currency.iso_code]
314
+ rates[date] if rates
315
+ end
316
+ end
317
+
318
+ # yesterday in UTC timezone
319
+ def yesterday_utc
320
+ Time.now.utc.to_date - 1
321
+ end
322
+ end
323
+ end
324
+
325
+ # Exchanges to +other_currency+ using +datetime+'s
326
+ # closing rates and returns a new +Money+ object.
327
+ # +rounding_method+ is ignored in this version of the gem.
328
+ #
329
+ # ==== Parameters
330
+ #
331
+ # - +other_currency+ - The currency to exchange to. Accepts ISO String and +Money::Currency+ objects.
332
+ # - +datetime+ - The +Date+ to get the exchange rate from. If +Time+ is passed instead, it's converted to the UTC +Date+.
333
+ # - +rounding_method+ - This parameter is ignored in this version of the gem.
334
+
335
+ def exchange_to_historical(other_currency, datetime, &rounding_method)
336
+ Bank::Historical.instance.exchange_with_historical(self, other_currency,
337
+ datetime, &rounding_method)
338
+ end
339
+ end