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.
@@ -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