fxer 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +115 -0
- data/Rakefile +7 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config/ecb.yml +1 -0
- data/config/fxer.yml +5 -0
- data/exe/fxer +16 -0
- data/exe/fxer-fetcher +20 -0
- data/fxer.gemspec +30 -0
- data/lib/exchange_rate.rb +24 -0
- data/lib/fxer.rb +9 -0
- data/lib/fxer/exchange.rb +110 -0
- data/lib/fxer/exchange/data.rb +84 -0
- data/lib/fxer/exchange/data/date.rb +56 -0
- data/lib/fxer/exchange/data/date/currency.rb +20 -0
- data/lib/fxer/exchange/data_source.rb +48 -0
- data/lib/fxer/exchange/data_source/ecb.rb +114 -0
- data/lib/fxer/exchange/exchange_configuration.rb +70 -0
- data/lib/fxer/fetcher.rb +79 -0
- data/lib/fxer/version.rb +3 -0
- metadata +154 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: bee3714d8bd267547541f35c4c39439b3dd712133d7fa2486db961c0ce796a9a
|
4
|
+
data.tar.gz: be700568d0b2677254d7b58695e1de9587e10d6c8c72f14705f7503618be5e14
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 4ce92080d2e7db647f7d3f684daafc69a94ea14fd3c179e679b70eb3fa9de70ac3ba79536645d4f098e1612155f44a1bbba61003e3cae96901caf23ffc87d9f9
|
7
|
+
data.tar.gz: 27f16af88934df6524be839438f6a0fbba1f73456472093079c8537b68307148f90d02e9c63e8fbb8b07a56adb719226ae03086b8342343b346114681cad9f45
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Sam Nissen
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
# Fxer
|
2
|
+
|
3
|
+
Fxer is an exchange rate calculator, using the European Central Bank's
|
4
|
+
rates covering the last 90 days.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
gem 'fxer'
|
12
|
+
```
|
13
|
+
|
14
|
+
And then execute:
|
15
|
+
|
16
|
+
```bash
|
17
|
+
bundle
|
18
|
+
```
|
19
|
+
|
20
|
+
Or install it yourself as, replacing the version numbers:
|
21
|
+
|
22
|
+
```bash
|
23
|
+
gem build fxer.gemspec
|
24
|
+
gem install fxer-1.2.3.gem
|
25
|
+
```
|
26
|
+
|
27
|
+
## Usage
|
28
|
+
|
29
|
+
### Simplest exchange
|
30
|
+
|
31
|
+
Fxer includes a quick and simple way of obtaining an exchange rate via a
|
32
|
+
separate namespace:
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
ExchangeRate.at(Date.today, 'EUR', 'AUD')
|
36
|
+
# => 1.4732
|
37
|
+
```
|
38
|
+
|
39
|
+
### Configurable exchange
|
40
|
+
|
41
|
+
For situations where you need more control, the Fxer namespace provides
|
42
|
+
configuration:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
exchanger = Fxer::Exchange.new.configure do |config|
|
46
|
+
config.permissive = true
|
47
|
+
config.source = :ecb
|
48
|
+
config.store = "/my/path/"
|
49
|
+
end
|
50
|
+
|
51
|
+
exchanger.convert_at_date(Date.today, 'GBP', 'USD')
|
52
|
+
# => 1.309507859949982
|
53
|
+
```
|
54
|
+
|
55
|
+
#### `config.permissive`
|
56
|
+
|
57
|
+
Fxer by default uses the most recently available data at or before
|
58
|
+
the date indicated. Setting permissive to false changes that, in effect a
|
59
|
+
strict-mode, and an error will be raised if a date
|
60
|
+
doesn't have corresponding data.
|
61
|
+
|
62
|
+
#### `config.source`
|
63
|
+
|
64
|
+
Fxer is designed to accommodate code for additional sources
|
65
|
+
of exchange rate data. Source can only be `:ecb` as of now.
|
66
|
+
|
67
|
+
#### `config.store`
|
68
|
+
|
69
|
+
The configuration of store allows you to indicate where you have
|
70
|
+
locally stored your exchange data file, so that Fxer does not need
|
71
|
+
to download that data to determine the rate.
|
72
|
+
|
73
|
+
### Executable exchange
|
74
|
+
|
75
|
+
fxer also provides an executable for getting rates in Bash:
|
76
|
+
|
77
|
+
```bash
|
78
|
+
FXER_RATE_DATA_PATH="/my/path" fxer "2017-07-18" NOK HKD
|
79
|
+
# => 0.9689571804652662
|
80
|
+
```
|
81
|
+
|
82
|
+
where the environment variable for local file hosting is optional.
|
83
|
+
|
84
|
+
### Data retrieval (also executable)
|
85
|
+
|
86
|
+
And fxer will download new ECB data for you, in either Ruby or Bash:
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
ENV['FXER_RATE_DATA_DIRECTORY'] = "/my/path/"
|
90
|
+
Fxer::Fetcher::Ecb.download
|
91
|
+
```
|
92
|
+
|
93
|
+
```bash
|
94
|
+
FXER_RATE_DATA_DIRECTORY="/my/path/" fxer-fetcher ecb
|
95
|
+
```
|
96
|
+
|
97
|
+
## Development
|
98
|
+
|
99
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
100
|
+
Then, run `bundle exec rspec spec` to run the tests.
|
101
|
+
You can also run `bin/console` for an interactive prompt that
|
102
|
+
will allow you to experiment.
|
103
|
+
|
104
|
+
Until this is pushed to RubyGems and GitHub, there is no defined development
|
105
|
+
process.
|
106
|
+
|
107
|
+
## Contributing
|
108
|
+
|
109
|
+
Bug reports and pull requests will be welcome once fxer is live
|
110
|
+
at https://github.com/samnissen/fxer.
|
111
|
+
|
112
|
+
## License
|
113
|
+
|
114
|
+
The gem is available as open source under the terms of the
|
115
|
+
[MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "fxer"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/config/ecb.yml
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
:ecb_fx_rate_url: "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist-90d.xml"
|
data/config/fxer.yml
ADDED
data/exe/fxer
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fxer'
|
4
|
+
|
5
|
+
#
|
6
|
+
# fxer is a simple command line tool to exchange data
|
7
|
+
# using the default exchange rate data source and an
|
8
|
+
# optional local store. See more documentation in Fxer::Exchange,
|
9
|
+
# and in the README.
|
10
|
+
#
|
11
|
+
|
12
|
+
path = ENV['FXER_RATE_DATA_PATH']
|
13
|
+
opts = { :store => path }
|
14
|
+
|
15
|
+
exchanger = Fxer::Exchange.new(opts)
|
16
|
+
puts exchanger.convert_at_date(ARGV[0], ARGV[1], ARGV[2])
|
data/exe/fxer-fetcher
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fxer'
|
4
|
+
|
5
|
+
#
|
6
|
+
# fxer-fetcher is a commandline pre-fetcher for exchange rate data.
|
7
|
+
# with only one data source, this is intentionally kept simple.
|
8
|
+
# fxer-fetcher downloads the most recent available data from the ECB.
|
9
|
+
#
|
10
|
+
|
11
|
+
DEFAULT_FETCHER_DOWNLOAD_CLASS_NAME = "ecb"
|
12
|
+
|
13
|
+
case "#{ARGV[0] || DEFAULT_FETCHER_DOWNLOAD_CLASS_NAME}".to_sym
|
14
|
+
when :ecb
|
15
|
+
puts "\n\n\tFetching ECB data..."
|
16
|
+
Fxer::Fetcher::Ecb.download
|
17
|
+
puts "\tSuccess!\n\n"
|
18
|
+
else
|
19
|
+
raise Fxer::ExchangeRate::INVALID_SOURCE_MESSAGE
|
20
|
+
end
|
data/fxer.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "fxer/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "fxer"
|
8
|
+
spec.version = Fxer::VERSION
|
9
|
+
spec.authors = ["Sam Nissen"]
|
10
|
+
spec.email = ["scnissen@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = "Convert currency based on external sources' rates"
|
13
|
+
spec.homepage = "https://github.com/samnissen/fxer"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
17
|
+
f.match(%r{^(test|spec|features)/})
|
18
|
+
end
|
19
|
+
spec.bindir = "exe"
|
20
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
|
+
spec.require_paths = ["lib"]
|
22
|
+
|
23
|
+
spec.add_dependency "activesupport", "~> 5.2.3"
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 2.1.4"
|
26
|
+
spec.add_development_dependency "rake", "~> 13.0.1"
|
27
|
+
spec.add_development_dependency "rake-release", "~> 1.2"
|
28
|
+
spec.add_development_dependency "rspec", "~> 3.9"
|
29
|
+
spec.add_development_dependency "nokogiri", "~> 1.10"
|
30
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require "fxer"
|
2
|
+
|
3
|
+
#
|
4
|
+
# ExchangeRate is a convenience module with one method to pass through
|
5
|
+
# data to Fxer::Exchange.
|
6
|
+
#
|
7
|
+
# Names like "ExchangeRate", "Exchange", etc. are the names of existing gems.
|
8
|
+
# Also, while Fxer::Exchange has options and state, ExchangeRate is
|
9
|
+
# simple and stateless. See that class for options and other details.
|
10
|
+
#
|
11
|
+
module ExchangeRate
|
12
|
+
|
13
|
+
#
|
14
|
+
# at requires three arguments, a date, and two strings representing
|
15
|
+
# currencies. More details about the requirements are detailed in
|
16
|
+
# Exchange.
|
17
|
+
#
|
18
|
+
# Example:
|
19
|
+
# ExchangeRate.at(Date.today,'GBP','USD')
|
20
|
+
#
|
21
|
+
def self.at(date, base, counter)
|
22
|
+
Fxer::Exchange.new.convert_at_date(date, base, counter)
|
23
|
+
end
|
24
|
+
end
|
data/lib/fxer.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
require "fxer/exchange/data"
|
2
|
+
require "fxer/exchange/data_source"
|
3
|
+
require "fxer/exchange/exchange_configuration"
|
4
|
+
|
5
|
+
require "yaml"
|
6
|
+
require "bigdecimal"
|
7
|
+
|
8
|
+
module Fxer
|
9
|
+
class Exchange
|
10
|
+
attr_writer :configuration
|
11
|
+
|
12
|
+
#
|
13
|
+
# initialize takes a hash of optional values:
|
14
|
+
# 1. :source - a symbol representing the source of the exchange data
|
15
|
+
# 2. :store - a string representing a directory where
|
16
|
+
# exchange data is stored, so that new data is not
|
17
|
+
# downloaded upon each exchange calculation.
|
18
|
+
# 3. :permissive - a boolean that, set to true, allows the exchange
|
19
|
+
# rate to be pulled from the nearest possible date. Set to false,
|
20
|
+
# an error will arise if no data exists for the date.
|
21
|
+
#
|
22
|
+
# Alternatively these same values can be provided through
|
23
|
+
# a configuraiton block, like so:
|
24
|
+
# exchanger = Fxer::Exchange.new.configure do |config|
|
25
|
+
# config.source = :ecb
|
26
|
+
# config.store = "/path/to/data"
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# Alternatively these same values can be provided directly
|
30
|
+
# exchanger = Fxer::Exchange.new
|
31
|
+
# exchanger.configuration.source = :ecb
|
32
|
+
# exchanger.configuration.store = "/path/to/data"
|
33
|
+
#
|
34
|
+
# And these values can be reset to their defaults,
|
35
|
+
# defined in config/fxer.yml.
|
36
|
+
#
|
37
|
+
# Guidance on setting up much of the configuration comes from:
|
38
|
+
# http://brandonhilkert.com/blog/ruby-gem-configuration-patterns/
|
39
|
+
#
|
40
|
+
def initialize(opts = {})
|
41
|
+
configure { |config|
|
42
|
+
config.source = opts[:source] if opts[:source]
|
43
|
+
config.store = opts[:store] if opts[:store]
|
44
|
+
config.permissive = opts[:permissive] if opts[:permissive]
|
45
|
+
}
|
46
|
+
end
|
47
|
+
|
48
|
+
def configuration
|
49
|
+
@configuration ||= ExchangeConfiguration.new
|
50
|
+
end
|
51
|
+
def reset
|
52
|
+
@configuration = ExchangeConfiguration.new
|
53
|
+
end
|
54
|
+
def configure
|
55
|
+
yield(configuration)
|
56
|
+
self
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# convert_at_date takes these arguments:
|
61
|
+
# 1. date - A Date object for the day's rates to access.
|
62
|
+
# The closest available day will be accessed if the
|
63
|
+
# chosen day is not available, or date is otherwise invalid
|
64
|
+
# 2. base - Representing a country's currency,
|
65
|
+
# formatted per source requirements
|
66
|
+
# 3. counter - Same format as `base`
|
67
|
+
#
|
68
|
+
# and returns a float, the result of diving two floats
|
69
|
+
# representing the currency's relative value at the date specified.
|
70
|
+
#
|
71
|
+
# Example:
|
72
|
+
# opts = {
|
73
|
+
# :store => "/my/path/",
|
74
|
+
# :source => :ecb,
|
75
|
+
# :permissive => true
|
76
|
+
# }
|
77
|
+
# Fxer::Exchange.new(opts).convert_at_date(Date.today, "HKD", "NZD")
|
78
|
+
# => 1.12345
|
79
|
+
#
|
80
|
+
def convert_at_date(date, base, counter)
|
81
|
+
data_source = Fxer::Exchange::DataSource.new(options)
|
82
|
+
counter_rate, base_rate = data_source.exchange(date, base, counter, source)
|
83
|
+
|
84
|
+
Float(BigDecimal(counter_rate.rate.to_s) / BigDecimal(base_rate.rate.to_s))
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
#
|
90
|
+
# DataSource and its subclasses don't require the same level of
|
91
|
+
# convenient configuration -- so they get a Hash of options
|
92
|
+
# (instead of a ExchangeConfiguration object, for instance).
|
93
|
+
# options returns such a Hash.
|
94
|
+
#
|
95
|
+
def options
|
96
|
+
{
|
97
|
+
:store => @configuration.store,
|
98
|
+
:permissive => @configuration.permissive
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
#
|
103
|
+
# source is a convenience method for the configuration object's source,
|
104
|
+
# and it returns a Symbol.
|
105
|
+
#
|
106
|
+
def source
|
107
|
+
@configuration.source
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require "fxer/exchange/data/date"
|
2
|
+
|
3
|
+
module Fxer
|
4
|
+
class Exchange
|
5
|
+
|
6
|
+
DATE_ERROR_MESSAGE = "Unable to find your date:"
|
7
|
+
|
8
|
+
class Data
|
9
|
+
attr_accessor :dates, :transaction_date, :to, :from
|
10
|
+
|
11
|
+
#
|
12
|
+
# initalize instantiates dates as an array to contain its Date objects.
|
13
|
+
#
|
14
|
+
def initialize(opts = {})
|
15
|
+
@options = opts
|
16
|
+
@dates = []
|
17
|
+
end
|
18
|
+
|
19
|
+
#
|
20
|
+
# rates sifts the available data for the nearest date, then
|
21
|
+
# sifts through the currencies to find the base and counter rates.
|
22
|
+
#
|
23
|
+
# Note @transaction_date, @to, @from must be set before `rates`
|
24
|
+
# is called. Convenient methods exist for this purpose. For instance:
|
25
|
+
# rate_set = Fxer::Exchange::DataSource::Ecb.new(options).rate_sets
|
26
|
+
# rates = rate_set.at("2017-07-21").from("USD").to("GBP").rates
|
27
|
+
#
|
28
|
+
# rates returns an array of two Currency objects,
|
29
|
+
# [counter, base].
|
30
|
+
#
|
31
|
+
def rates
|
32
|
+
date = nearest_date
|
33
|
+
|
34
|
+
[
|
35
|
+
date.currencies.find{ |d| d.key == @to },
|
36
|
+
date.currencies.find{ |d| d.key == @from }
|
37
|
+
]
|
38
|
+
end
|
39
|
+
|
40
|
+
#
|
41
|
+
# at, from and to are convenience methods to set the transaction date,
|
42
|
+
# base currency, and counter currency, respectively. They return self
|
43
|
+
# so that they can be chained. arguments are defined in Fxer::Exchange
|
44
|
+
#
|
45
|
+
def at(d)
|
46
|
+
@transaction_date = d
|
47
|
+
self
|
48
|
+
end
|
49
|
+
def from(f)
|
50
|
+
@from = f
|
51
|
+
self
|
52
|
+
end
|
53
|
+
def to(t)
|
54
|
+
@to = t
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
# transaction_date takes one argument, defined in Fxer::Exchange
|
60
|
+
# and normalizes it to a Date object
|
61
|
+
#
|
62
|
+
def transaction_date=(raw_date)
|
63
|
+
@transaction_date = Date.normalize_date(d)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
#
|
69
|
+
# nearest_date chooses the date object matching the transaction date.
|
70
|
+
#
|
71
|
+
# If no date is matched, the next date can be chosen if the
|
72
|
+
# permissive flag evaluates to true. Otherwise it raises an error.
|
73
|
+
#
|
74
|
+
def nearest_date
|
75
|
+
d = @dates.find { |d| "#{d.date}" == "#{@transaction_date}" }
|
76
|
+
return d if d
|
77
|
+
|
78
|
+
raise "#{DATE_ERROR_MESSAGE} #{@transaction_date}" unless @options[:permissive]
|
79
|
+
|
80
|
+
dates.sort_by{ |d| d.date }.reverse.first
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require "fxer/exchange/data/date/currency"
|
2
|
+
|
3
|
+
module Fxer
|
4
|
+
class Exchange
|
5
|
+
class Data
|
6
|
+
|
7
|
+
#
|
8
|
+
# Fxer::Exchange::Data::Date must be namespaced or
|
9
|
+
# it will overwrite the existing Date class, resulting in warnings:
|
10
|
+
# "warning: toplevel constant Date referenced by X", and odd behavior.
|
11
|
+
# http://stem.ps/rails/2015/01/25/ruby-gotcha-toplevel-constant-referenced-by.html
|
12
|
+
#
|
13
|
+
class Fxer::Exchange::Data::Date
|
14
|
+
attr_accessor :currencies, :date
|
15
|
+
|
16
|
+
#
|
17
|
+
# In creating a date, create a shell to store this Date's currencies.
|
18
|
+
#
|
19
|
+
def initialize
|
20
|
+
@currencies = []
|
21
|
+
end
|
22
|
+
|
23
|
+
#
|
24
|
+
# Setting the date allows string or Date object. For more details,
|
25
|
+
# see the documentation for normalize_date.
|
26
|
+
#
|
27
|
+
def date=(raw_date)
|
28
|
+
@date = Date.normalize_date(raw_date)
|
29
|
+
end
|
30
|
+
|
31
|
+
#
|
32
|
+
# self.normalize_date takes one argument,
|
33
|
+
# 1. raw_date, either a string represnting a date, or a Date,
|
34
|
+
# where the former will be converted into the latter
|
35
|
+
# and returns a Date object if it is a convertable string.
|
36
|
+
# Note this is used elsewhere, hence the Eigenclass method.
|
37
|
+
#
|
38
|
+
# In normalize_date, casing the class of a ruby object is not done
|
39
|
+
# by `.class`ing the object because of how the === operator works:
|
40
|
+
# https://stackoverflow.com/a/3801609/1651458
|
41
|
+
#
|
42
|
+
# Also, `::Date` is required because of the local Date
|
43
|
+
# class in this Gem: https://stackoverflow.com/a/29351635/1651458
|
44
|
+
#
|
45
|
+
def self.normalize_date(raw_date)
|
46
|
+
case raw_date
|
47
|
+
when String
|
48
|
+
::Date.strptime(raw_date) # A namespace that specifies Ruby's core class.
|
49
|
+
else
|
50
|
+
raw_date
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Fxer
|
2
|
+
class Exchange
|
3
|
+
class Data
|
4
|
+
class Date
|
5
|
+
class Currency
|
6
|
+
attr_accessor :key, :rate
|
7
|
+
|
8
|
+
#
|
9
|
+
# rate= takes one argument,
|
10
|
+
# 1. raw_rate, a string representing a float
|
11
|
+
# and converts it into a float, and assigns it to its rate attribute
|
12
|
+
#
|
13
|
+
def rate=(raw_rate)
|
14
|
+
@rate = "#{raw_rate}".to_f
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require "active_support/core_ext/hash"
|
2
|
+
require "date"
|
3
|
+
require "open-uri"
|
4
|
+
require "pathname"
|
5
|
+
|
6
|
+
require "fxer/exchange/data_source/ecb"
|
7
|
+
|
8
|
+
module Fxer
|
9
|
+
class Exchange
|
10
|
+
class DataSource
|
11
|
+
|
12
|
+
#
|
13
|
+
# initialize accepts one optional argument,
|
14
|
+
# 1. a hash of options which will be passed to whichever data source
|
15
|
+
# the user has indicated (i.e. Fxer::Exchange::DataSource::Ecb).
|
16
|
+
#
|
17
|
+
def initialize(opts = {})
|
18
|
+
@options = opts
|
19
|
+
end
|
20
|
+
|
21
|
+
#
|
22
|
+
# exchange takes 4 required arguments and returns the data required
|
23
|
+
# to determine an exchange value. Arguments are defined in Fxer::Exchange.
|
24
|
+
#
|
25
|
+
def exchange(date, base, counter, source)
|
26
|
+
fetch_rates(source).at(date).from(base).to(counter).rates
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
#
|
32
|
+
# fetch_rates takes one argument,
|
33
|
+
# 1. a symbol represnting a source's corresponding class name.
|
34
|
+
# Source symbols are defined in Fxer::Exchange.
|
35
|
+
#
|
36
|
+
# fetch_rates converts the source of data indicated by the user,
|
37
|
+
# i.e. :ecb, (which has been sanitized during configuration), and
|
38
|
+
# converts that to a subclass of DataSource, providing options
|
39
|
+
# and returning a set of rates, represented by an
|
40
|
+
# Fxer::Exchange::Data object.
|
41
|
+
#
|
42
|
+
def fetch_rates(source)
|
43
|
+
klass_name = "#{source}".downcase.capitalize
|
44
|
+
Object.const_get("Fxer::Exchange::DataSource::#{klass_name}").new(@options).rate_set
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module Fxer
|
2
|
+
class Exchange
|
3
|
+
class DataSource
|
4
|
+
class Ecb
|
5
|
+
attr_reader :rate_set
|
6
|
+
|
7
|
+
#
|
8
|
+
# initialize takes one optional hash.
|
9
|
+
# Arguments are defined in Fxer::Exchange.
|
10
|
+
#
|
11
|
+
# It assigns store to be used in the Ecb class. initialize also
|
12
|
+
# kicks off the request for data and normalization into our
|
13
|
+
# Data objects.
|
14
|
+
#
|
15
|
+
def initialize(opts = {})
|
16
|
+
@store = opts[:store]
|
17
|
+
@options = opts.except(:store)
|
18
|
+
|
19
|
+
fetch_rates
|
20
|
+
normalize_rates
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
#
|
26
|
+
# normalize_rates creates a Data object to store each Date, which
|
27
|
+
# itself stores n Currencies, all assigned to @rate_set
|
28
|
+
#
|
29
|
+
# In ECB data, the Euro is static, and so is omitted from the dataset.
|
30
|
+
# So, the Currency representing the Euro is manually added.
|
31
|
+
#
|
32
|
+
def normalize_rates
|
33
|
+
@rate_set = Fxer::Exchange::Data.new(@options)
|
34
|
+
|
35
|
+
@raw_data.each do |rate_sets|
|
36
|
+
date = Fxer::Exchange::Data::Date.new()
|
37
|
+
date.date = Date.strptime(rate_sets["time"], "%Y-%m-%d")
|
38
|
+
|
39
|
+
date.currencies = rate_sets["Cube"].map do |rate|
|
40
|
+
currency = Fxer::Exchange::Data::Date::Currency.new()
|
41
|
+
currency.key = rate["currency"]
|
42
|
+
currency.rate = rate["rate"]
|
43
|
+
currency
|
44
|
+
end
|
45
|
+
date.currencies << european_currency
|
46
|
+
|
47
|
+
@rate_set.dates << date
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# european_currency creates the Euro currency object, and
|
53
|
+
# returns that static currency object.
|
54
|
+
#
|
55
|
+
# Since it is always the base of the ECB currency data,
|
56
|
+
# the Euro is always set to 1.
|
57
|
+
#
|
58
|
+
def european_currency
|
59
|
+
currency = Fxer::Exchange::Data::Date::Currency.new()
|
60
|
+
currency.key = "EUR"
|
61
|
+
currency.rate = 1
|
62
|
+
currency
|
63
|
+
end
|
64
|
+
|
65
|
+
#
|
66
|
+
# fetch_rates gathers the entire available data set. (For ECB,
|
67
|
+
# that's 90 days worth of exchange rate data.)
|
68
|
+
# First, fetch_rates tries to gather the data from file,
|
69
|
+
# then directly from the source URL when it cannot.
|
70
|
+
#
|
71
|
+
def fetch_rates
|
72
|
+
@raw_data = local_data
|
73
|
+
@raw_data ||= from_url
|
74
|
+
end
|
75
|
+
|
76
|
+
#
|
77
|
+
# from_url opens the ECB XML feed, converts to hash, and
|
78
|
+
# returns a Hash representing the relevant portion.
|
79
|
+
#
|
80
|
+
def from_url
|
81
|
+
hashify(open(config[:ecb_fx_rate_url]))
|
82
|
+
end
|
83
|
+
|
84
|
+
#
|
85
|
+
# local_data accesses the provided data file, converts to hash and
|
86
|
+
# returns a Hash representing the relevant portion, but
|
87
|
+
# only if one is indeed provided.
|
88
|
+
#
|
89
|
+
def local_data
|
90
|
+
return false unless @store && File.exists?(@store)
|
91
|
+
|
92
|
+
return hashify(File.open(@store))
|
93
|
+
end
|
94
|
+
|
95
|
+
#
|
96
|
+
# hashify converts uses activesupport to convert XML to hash,
|
97
|
+
# then accesses and returns a Hash representing the relevant portion.
|
98
|
+
#
|
99
|
+
def hashify(raw)
|
100
|
+
Hash.from_xml(raw)["Envelope"]["Cube"]["Cube"]
|
101
|
+
end
|
102
|
+
|
103
|
+
#
|
104
|
+
# config accesses the yaml file containing the
|
105
|
+
# default Ecb configuration.
|
106
|
+
#
|
107
|
+
def config
|
108
|
+
@config_path = File.join(FXER_CONFIGURATION_PATH, "ecb.yml")
|
109
|
+
@config ||= YAML.load_file(@config_path)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module Fxer
|
2
|
+
class Exchange
|
3
|
+
|
4
|
+
INVALID_SOURCE_MESSAGE = "Please provide one of these valid source keys:"
|
5
|
+
|
6
|
+
class ExchangeConfiguration
|
7
|
+
attr_accessor :source, :store, :permissive
|
8
|
+
|
9
|
+
#
|
10
|
+
# This configuration object allows for hash, block or object setting
|
11
|
+
# of configuration options.
|
12
|
+
#
|
13
|
+
# initialize sets the default source and protects the app from attempting
|
14
|
+
# to use invalid source keys.
|
15
|
+
#
|
16
|
+
def initialize
|
17
|
+
set_defaults
|
18
|
+
end
|
19
|
+
|
20
|
+
#
|
21
|
+
# In order to ensure that a key passed at any arbitrary point is valid
|
22
|
+
# the setter is overwritten to validate the key.
|
23
|
+
#
|
24
|
+
def source=(key)
|
25
|
+
@source = validate_source_key(key)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
#
|
31
|
+
# set_defaults does what is says on the tin, setting source and store
|
32
|
+
# to their default values.
|
33
|
+
#
|
34
|
+
# Note that a nil value for the store path will bypass the functions
|
35
|
+
# that access locally stored data.
|
36
|
+
#
|
37
|
+
def set_defaults
|
38
|
+
@source = config[:default_source_key]
|
39
|
+
@store = config[:default_store_path]
|
40
|
+
@permissive = config[:default_permission]
|
41
|
+
end
|
42
|
+
|
43
|
+
#
|
44
|
+
# config accesses the yaml file containing the default configuration,
|
45
|
+
# and returns a Hash of its values.
|
46
|
+
#
|
47
|
+
def config
|
48
|
+
@config_path ||= File.join(FXER_CONFIGURATION_PATH, "fxer.yml")
|
49
|
+
@config ||= YAML.load_file(@config_path)[:exchange]
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
#
|
54
|
+
# validate_source_key takes an argument
|
55
|
+
# 1. key, a symbol representing
|
56
|
+
# and returns the key if it is within the array of valid keys
|
57
|
+
#
|
58
|
+
# This stops any request that specifies a key that that is not
|
59
|
+
# encoded in our application as a valid bank data source.
|
60
|
+
#
|
61
|
+
def validate_source_key(key)
|
62
|
+
unless config[:valid_source_keys].include?(key)
|
63
|
+
raise "#{INVALID_SOURCE_MESSAGE} #{config[:valid_source_keys]}."
|
64
|
+
end
|
65
|
+
|
66
|
+
key
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/fxer/fetcher.rb
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
module Fxer
|
2
|
+
class Fetcher
|
3
|
+
class Ecb
|
4
|
+
class << self
|
5
|
+
|
6
|
+
#
|
7
|
+
# download fetches the most recent data from the ECB URL if
|
8
|
+
# today's data isn't already present in the user's chosen
|
9
|
+
# directory (otherwise it aborts).
|
10
|
+
#
|
11
|
+
# After downloading the ECB data, it checks if the data contains
|
12
|
+
# data not yet accounted for in the directory (otherwise it aborts).
|
13
|
+
#
|
14
|
+
# Then it saves that data to a new XML file in the user's
|
15
|
+
# chosen directory.
|
16
|
+
#
|
17
|
+
def download
|
18
|
+
set_data_parameters
|
19
|
+
|
20
|
+
return true if abort_if_current(Date.today.to_s)
|
21
|
+
|
22
|
+
fetch_data
|
23
|
+
|
24
|
+
return true if abort_if_current(@date)
|
25
|
+
|
26
|
+
save_data
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
#
|
32
|
+
# set_data_parameters fetches and assigns the ECB URL from config.
|
33
|
+
# And it assigns the user's chosen rate directory, falling back to
|
34
|
+
# the working directory.
|
35
|
+
#
|
36
|
+
def set_data_parameters
|
37
|
+
config_path = File.join(Fxer::FXER_CONFIGURATION_PATH, "ecb.yml")
|
38
|
+
@url = YAML.load_file(config_path)[:ecb_fx_rate_url]
|
39
|
+
@dir = ENV['FXER_RATE_DATA_DIRECTORY'] || Dir.pwd
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# save_data to an XML file named after the @date
|
44
|
+
#
|
45
|
+
def save_data
|
46
|
+
@path = File.join(@dir, "#{@date.to_s}.xml")
|
47
|
+
|
48
|
+
puts "\tData found. Saving data for '#{@date.to_s}' to '#{@path}' ..."
|
49
|
+
open(@path, "wb") { |f| f.write(@data) }
|
50
|
+
|
51
|
+
puts "\tSuccess!\n\n"
|
52
|
+
end
|
53
|
+
|
54
|
+
#
|
55
|
+
# fetch_data from the URL, and fetch the file's
|
56
|
+
# most recent date from that data
|
57
|
+
#
|
58
|
+
def fetch_data
|
59
|
+
puts "\n\n\tGoing to fetch data from '#{@url}'"
|
60
|
+
@data = open(@url) { |io| io.read }
|
61
|
+
@date = Hash.from_xml(@data)["Envelope"]["Cube"]["Cube"].first["time"]
|
62
|
+
end
|
63
|
+
|
64
|
+
#
|
65
|
+
# abort_if_current takes one argument:
|
66
|
+
# 1. date - a string representing the date a file is named for,
|
67
|
+
# and returns a boolean of that file's existence.
|
68
|
+
#
|
69
|
+
def abort_if_current(date)
|
70
|
+
return false unless File.exist?(File.join(@dir, "#{date.to_s}.xml"))
|
71
|
+
|
72
|
+
puts "\n\n\tThe most recent data already exists in #{@dir}. Exiting ...\n\n"
|
73
|
+
true
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
end
|
data/lib/fxer/version.rb
ADDED
metadata
ADDED
@@ -0,0 +1,154 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: fxer
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Sam Nissen
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-05-04 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activesupport
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 5.2.3
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 5.2.3
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 2.1.4
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 2.1.4
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 13.0.1
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 13.0.1
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake-release
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.2'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.9'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.9'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: nokogiri
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '1.10'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '1.10'
|
97
|
+
description:
|
98
|
+
email:
|
99
|
+
- scnissen@gmail.com
|
100
|
+
executables:
|
101
|
+
- fxer
|
102
|
+
- fxer-fetcher
|
103
|
+
extensions: []
|
104
|
+
extra_rdoc_files: []
|
105
|
+
files:
|
106
|
+
- ".gitignore"
|
107
|
+
- ".rspec"
|
108
|
+
- ".travis.yml"
|
109
|
+
- Gemfile
|
110
|
+
- LICENSE.txt
|
111
|
+
- README.md
|
112
|
+
- Rakefile
|
113
|
+
- bin/console
|
114
|
+
- bin/setup
|
115
|
+
- config/ecb.yml
|
116
|
+
- config/fxer.yml
|
117
|
+
- exe/fxer
|
118
|
+
- exe/fxer-fetcher
|
119
|
+
- fxer.gemspec
|
120
|
+
- lib/exchange_rate.rb
|
121
|
+
- lib/fxer.rb
|
122
|
+
- lib/fxer/exchange.rb
|
123
|
+
- lib/fxer/exchange/data.rb
|
124
|
+
- lib/fxer/exchange/data/date.rb
|
125
|
+
- lib/fxer/exchange/data/date/currency.rb
|
126
|
+
- lib/fxer/exchange/data_source.rb
|
127
|
+
- lib/fxer/exchange/data_source/ecb.rb
|
128
|
+
- lib/fxer/exchange/exchange_configuration.rb
|
129
|
+
- lib/fxer/fetcher.rb
|
130
|
+
- lib/fxer/version.rb
|
131
|
+
homepage: https://github.com/samnissen/fxer
|
132
|
+
licenses:
|
133
|
+
- MIT
|
134
|
+
metadata: {}
|
135
|
+
post_install_message:
|
136
|
+
rdoc_options: []
|
137
|
+
require_paths:
|
138
|
+
- lib
|
139
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
140
|
+
requirements:
|
141
|
+
- - ">="
|
142
|
+
- !ruby/object:Gem::Version
|
143
|
+
version: '0'
|
144
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
145
|
+
requirements:
|
146
|
+
- - ">="
|
147
|
+
- !ruby/object:Gem::Version
|
148
|
+
version: '0'
|
149
|
+
requirements: []
|
150
|
+
rubygems_version: 3.1.2
|
151
|
+
signing_key:
|
152
|
+
specification_version: 4
|
153
|
+
summary: Convert currency based on external sources' rates
|
154
|
+
test_files: []
|