fxer 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/.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: []
|