timeprice 0.1.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +27 -1
- data/README.md +85 -0
- data/lib/timeprice/cli.rb +5 -7
- data/lib/timeprice/compare.rb +29 -21
- data/lib/timeprice/data_loader.rb +30 -4
- data/lib/timeprice/errors.rb +12 -5
- data/lib/timeprice/exchange.rb +21 -2
- data/lib/timeprice/inflation.rb +21 -6
- data/lib/timeprice/point.rb +43 -0
- data/lib/timeprice/sources.rb +13 -9
- data/lib/timeprice/supported.rb +55 -0
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +40 -0
- metadata +46 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 160a9501bc1004df45cd71202f3ae07711068adade2118cfe4605a2e92d92f6f
|
|
4
|
+
data.tar.gz: 68ea05103245f2bb1fb02d701fd5db6e1bb814a2b47d20f6eb9011d94a57b269
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdddfd9cbcf4cefad6ee73134eb8ef3e5ad5b863aa8d5a78fa789b8890cefec0690cf3c1022f1b596c2bdeaf6f9a8f1122d8ff6ef9adfb7dd393e0aa9346afb6
|
|
7
|
+
data.tar.gz: 4342d02e30f4058f6e7a98eedab2fd0fa54b14031f061bf4d17b093d02f6a1e5cd16ea1f994e7d868cc893fe29119bdae1a398724665f14502c9b135ee1396b0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,33 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
-
## [0.
|
|
8
|
+
## [0.2.0] - 2026-05-11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `Timeprice::Point` value object for compare inputs; `Point.coerce` accepts `Point` instances or 2-tuples in either `[currency, date]` or `[date, currency]` order.
|
|
12
|
+
- `Timeprice::Supported` module — canonical home for `COUNTRIES`, `CURRENCIES`, and the bidirectional currency↔country map. Replaces the duplicated maps in `Compare` and the CLI's `InflationResult` monkey-patch.
|
|
13
|
+
- `Sources::Base` class extracted from the CPI fetchers; `BLS`, `ONS`, and `Eurostat` now subclass it and implement only `fetch` returning `[monthly, annual]`. The drift-check, rebase, merge, write, and summary-log flow is shared.
|
|
14
|
+
- Per-fetcher GitHub Actions `::warning file=…,title=…::` annotations in `scripts/update_data.rb`, so individual fetcher failures show up on the workflow run with a link to the responsible source file.
|
|
15
|
+
- README "Using from Rails / Rake" section covering service objects, Sidekiq, Rake tasks, and `TIMEPRICE_DATA_ROOT`.
|
|
16
|
+
- YARD documentation on the public API (`Timeprice.{inflation,exchange,compare}`, `Inflation`, `Exchange`, `Compare`, `DataLoader`, `Sources`, error classes, `Supported`, `Point`).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- `SUPPORTED_COUNTRIES` / `SUPPORTED_CURRENCIES` are now thin aliases for `Supported::COUNTRIES` / `Supported::CURRENCIES`; existing consumers keep working unchanged.
|
|
20
|
+
- `Compare::CURRENCY_TO_COUNTRY` is now an alias for `Supported::CURRENCY_TO_COUNTRY`.
|
|
21
|
+
|
|
22
|
+
## [0.1.2] - 2026-05-11
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- RuboCop with `rubocop-rake` + `rubocop-rspec`, wired into Rake (`rake default` runs spec + rubocop) and CI (separate `RuboCop` job alongside `RSpec`).
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- `DataLoader.load_cpi` now distinguishes between "country isn't supported" (`UnsupportedCountry`) and "data file is missing on disk" (`DataNotFound` with the path the loader looked at). Previously both surfaced as `UnsupportedCountry`, masking install / `TIMEPRICE_DATA_ROOT` misconfigurations.
|
|
29
|
+
- `Timeprice.exchange` now rejects invalid calendar dates (e.g. `2021-02-29`) with `ArgumentError` instead of leaking a `Date::Error`. Honors the public error contract.
|
|
30
|
+
- Trimmed `ZERO_DECIMAL_CURRENCIES` to currencies actually supported by the gem (JPY, VND). Removed aspirational entries (KRW, IDR, HUF, CLP).
|
|
31
|
+
- Inline source comments now reference README sections (`README.md "Compare semantics"`) instead of `PLAN.md` (which is intentionally not shipped in the gem).
|
|
32
|
+
- `CONTRIBUTING.md` updated to match the single-Ruby CI; both `rspec` and `rubocop` must be green.
|
|
33
|
+
|
|
34
|
+
|
|
9
35
|
|
|
10
36
|
### Changed
|
|
11
37
|
- CLI output formatting: currency-aware decimals (no `.0000` on JPY/VND), magnitude-aware FX rate precision (no `91.180000` for a 91.18 rate).
|
data/README.md
CHANGED
|
@@ -155,6 +155,91 @@ inflated = Timeprice.inflation(amount: 100, from: "2010", to: "2024", country: "
|
|
|
155
155
|
converted = Timeprice.exchange(amount: inflated, from: "USD", to: "VND", date: "2024-06-30").amount
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
## Using from Rails / Rake
|
|
159
|
+
|
|
160
|
+
`timeprice` is a plain Ruby library — no Railtie, no engine, no autoload magic. It works
|
|
161
|
+
the same way as `BigDecimal` or `JSON`: require it once, call the module functions.
|
|
162
|
+
|
|
163
|
+
### In a Rails app
|
|
164
|
+
|
|
165
|
+
Add the gem to your `Gemfile`:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
gem "timeprice", "~> 0.1"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Then call it directly from controllers, jobs, presenters, or service objects. The library
|
|
172
|
+
is thread-safe (data files are loaded once and cached as frozen hashes), so it's safe to
|
|
173
|
+
call from threaded servers (Puma) and Sidekiq workers:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# app/services/historical_price.rb
|
|
177
|
+
class HistoricalPrice
|
|
178
|
+
def self.in_today_dollars(amount, year)
|
|
179
|
+
Timeprice.inflation(
|
|
180
|
+
amount: amount,
|
|
181
|
+
from: year.to_s,
|
|
182
|
+
to: Date.current.strftime("%Y-%m"),
|
|
183
|
+
country: "US"
|
|
184
|
+
).amount
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Errors all inherit from `Timeprice::Error`, so a single rescue covers everything:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
rescue Timeprice::Error => e
|
|
193
|
+
Rails.logger.warn("timeprice lookup failed: #{e.message}")
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Result objects respond to `#to_h`, so they serialize cleanly in JSON APIs:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
def show
|
|
202
|
+
render json: Timeprice.exchange(amount: 100, from: "USD", to: "EUR", date: params[:date]).to_h
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### In a Rake task
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# lib/tasks/inflation.rake
|
|
210
|
+
require "timeprice"
|
|
211
|
+
|
|
212
|
+
namespace :inflation do
|
|
213
|
+
desc "Print 1990→today inflation for the supported countries"
|
|
214
|
+
task :report do
|
|
215
|
+
today = Date.today.strftime("%Y-%m")
|
|
216
|
+
%w[US UK EU JP VN].each do |c|
|
|
217
|
+
r = Timeprice.inflation(amount: 100, from: "1990", to: today, country: c)
|
|
218
|
+
puts "#{c}: 100 in 1990 → #{r.amount.round(2)} in #{today} (#{r.granularity})"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Configuring the data root
|
|
225
|
+
|
|
226
|
+
By default the gem reads from its bundled `data/` directory. To point at a different
|
|
227
|
+
checkout (useful for testing a new data refresh before releasing it), set
|
|
228
|
+
`TIMEPRICE_DATA_ROOT`:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
TIMEPRICE_DATA_ROOT=/path/to/timeprice/data bundle exec rake inflation:report
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Or programmatically:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
Timeprice::DataLoader.data_root = "/path/to/timeprice/data"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Reassigning `data_root` clears the in-memory cache, so it's safe to call between requests
|
|
241
|
+
in development.
|
|
242
|
+
|
|
158
243
|
## Data sources & attribution
|
|
159
244
|
|
|
160
245
|
`timeprice` redistributes data from several public sources. Each is governed by its own
|
data/lib/timeprice/cli.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Timeprice
|
|
|
73
73
|
say JSON.generate(list)
|
|
74
74
|
else
|
|
75
75
|
list.each do |s|
|
|
76
|
-
say
|
|
76
|
+
say s[:name].to_s
|
|
77
77
|
say " id: #{s[:id]}"
|
|
78
78
|
say " license: #{s[:license]}"
|
|
79
79
|
say " license_url: #{s[:license_url]}"
|
|
@@ -95,7 +95,7 @@ module Timeprice
|
|
|
95
95
|
|
|
96
96
|
no_commands do
|
|
97
97
|
# Currencies with no minor unit — render whole numbers, no decimals.
|
|
98
|
-
ZERO_DECIMAL_CURRENCIES = %w[JPY VND
|
|
98
|
+
ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
|
|
99
99
|
|
|
100
100
|
def with_error_handling
|
|
101
101
|
yield
|
|
@@ -109,6 +109,7 @@ module Timeprice
|
|
|
109
109
|
|
|
110
110
|
def parse_compare_token(token, label:)
|
|
111
111
|
raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
|
|
112
|
+
|
|
112
113
|
parts = token.strip.split(/\s+/)
|
|
113
114
|
unless parts.size == 2
|
|
114
115
|
raise ArgumentError,
|
|
@@ -142,6 +143,7 @@ module Timeprice
|
|
|
142
143
|
# answer actually used annual data — that's where users want a heads-up.
|
|
143
144
|
def granularity_suffix(granularity)
|
|
144
145
|
return "" if granularity == :monthly
|
|
146
|
+
|
|
145
147
|
" (granularity: #{granularity})"
|
|
146
148
|
end
|
|
147
149
|
|
|
@@ -201,12 +203,8 @@ end
|
|
|
201
203
|
# bloating the value object — the result doesn't carry currency, only country.
|
|
202
204
|
module Timeprice
|
|
203
205
|
class InflationResult
|
|
204
|
-
COUNTRY_TO_CURRENCY = {
|
|
205
|
-
"US" => "USD", "UK" => "GBP", "EU" => "EUR", "JP" => "JPY", "VN" => "VND"
|
|
206
|
-
}.freeze
|
|
207
|
-
|
|
208
206
|
def country_currency_label
|
|
209
|
-
|
|
207
|
+
Supported.currency_for_country(country) || country.to_s.upcase
|
|
210
208
|
end
|
|
211
209
|
end
|
|
212
210
|
end
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "supported"
|
|
5
|
+
require_relative "point"
|
|
4
6
|
require_relative "inflation"
|
|
5
7
|
require_relative "exchange"
|
|
6
8
|
|
|
@@ -16,7 +18,7 @@ module Timeprice
|
|
|
16
18
|
# Compare combines FX and inflation across two (currency, date) points.
|
|
17
19
|
#
|
|
18
20
|
# CONVENTION (critical): convert at SOURCE date first, then inflate in
|
|
19
|
-
# destination currency. See
|
|
21
|
+
# destination currency. See README.md "Compare semantics" section.
|
|
20
22
|
#
|
|
21
23
|
# This preserves purchasing-power equivalence in the destination economy.
|
|
22
24
|
# The naive alternative (inflate in source currency first, then convert at
|
|
@@ -26,29 +28,22 @@ module Timeprice
|
|
|
26
28
|
# If a future refactor flips the order, the regression test in
|
|
27
29
|
# spec/timeprice/compare_spec.rb will fail.
|
|
28
30
|
module Compare
|
|
29
|
-
# Map ISO currency → CPI country code.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"GBP" => "UK",
|
|
33
|
-
"EUR" => "EU",
|
|
34
|
-
"JPY" => "JP",
|
|
35
|
-
"VND" => "VN"
|
|
36
|
-
}.freeze
|
|
31
|
+
# Map ISO currency → CPI country code. Kept as a back-compat alias;
|
|
32
|
+
# the canonical map lives in {Supported::CURRENCY_TO_COUNTRY}.
|
|
33
|
+
CURRENCY_TO_COUNTRY = Supported::CURRENCY_TO_COUNTRY
|
|
37
34
|
|
|
38
35
|
module_function
|
|
39
36
|
|
|
40
|
-
# amount
|
|
41
|
-
#
|
|
42
|
-
#
|
|
37
|
+
# Compare an amount across two (currency, date) points.
|
|
38
|
+
#
|
|
39
|
+
# @param amount [Numeric]
|
|
40
|
+
# @param from [Timeprice::Point, Array(String, String)] source point;
|
|
41
|
+
# accepts a {Point} or a 2-tuple like `["USD", "2010"]` or `["USD", "2010-06"]`
|
|
42
|
+
# @param to [Timeprice::Point, Array(String, String)] destination point
|
|
43
|
+
# @return [CompareResult]
|
|
44
|
+
# @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
|
|
43
45
|
def run(amount:, from:, to:)
|
|
44
|
-
from_currency, from_date = from
|
|
45
|
-
to_currency, to_date = to
|
|
46
|
-
from_currency = from_currency.to_s.upcase
|
|
47
|
-
to_currency = to_currency.to_s.upcase
|
|
48
|
-
|
|
49
|
-
to_country = CURRENCY_TO_COUNTRY[to_currency] ||
|
|
50
|
-
(raise UnsupportedCurrency, to_currency)
|
|
51
|
-
CURRENCY_TO_COUNTRY[from_currency] || (raise UnsupportedCurrency, from_currency)
|
|
46
|
+
from_currency, from_date, to_currency, to_date, to_country = resolve_points(from, to)
|
|
52
47
|
|
|
53
48
|
# Step 1: convert at source date into destination currency.
|
|
54
49
|
fx_date = normalize_fx_date(from_date)
|
|
@@ -78,12 +73,25 @@ module Timeprice
|
|
|
78
73
|
to_date: to_date.to_s,
|
|
79
74
|
country: to_country,
|
|
80
75
|
fx_rate: fx_result.rate,
|
|
81
|
-
cpi_ratio: infl.to_index.to_f / infl.from_index
|
|
76
|
+
cpi_ratio: infl.to_index.to_f / infl.from_index,
|
|
82
77
|
converted_amount: converted,
|
|
83
78
|
granularity: infl.granularity
|
|
84
79
|
)
|
|
85
80
|
end
|
|
86
81
|
|
|
82
|
+
# Coerce both points and resolve to_country. Returns a 5-element tuple.
|
|
83
|
+
def resolve_points(from, to)
|
|
84
|
+
from_point = Point.coerce(from)
|
|
85
|
+
to_point = Point.coerce(to)
|
|
86
|
+
from_currency = from_point.currency
|
|
87
|
+
to_currency = to_point.currency
|
|
88
|
+
to_country = Supported.country_for_currency(to_currency) ||
|
|
89
|
+
(raise UnsupportedCurrency, to_currency)
|
|
90
|
+
Supported.country_for_currency(from_currency) || (raise UnsupportedCurrency, from_currency)
|
|
91
|
+
|
|
92
|
+
[from_currency, from_point.date, to_currency, to_point.date, to_country]
|
|
93
|
+
end
|
|
94
|
+
|
|
87
95
|
# If the user gave a year like "2010", anchor FX to mid-year (2010-06-30).
|
|
88
96
|
# If they gave "YYYY-MM", anchor to the 15th. Full dates pass through.
|
|
89
97
|
def normalize_fx_date(date)
|
|
@@ -4,42 +4,69 @@ require "json"
|
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
|
|
6
6
|
module Timeprice
|
|
7
|
+
# Loads and caches the bundled JSON data files. Override the search root
|
|
8
|
+
# by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
|
|
9
|
+
# {DataLoader.data_root=}.
|
|
7
10
|
module DataLoader
|
|
8
11
|
SUPPORTED_SCHEMA_VERSION = 1
|
|
9
12
|
|
|
10
13
|
DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
|
|
11
14
|
|
|
12
15
|
class << self
|
|
16
|
+
# @return [String] absolute path to the directory containing `cpi/` and `fx/`
|
|
13
17
|
def data_root
|
|
14
18
|
ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
|
|
15
19
|
end
|
|
16
20
|
|
|
21
|
+
# Override the data root and clear caches. Mostly useful in tests.
|
|
22
|
+
# @param path [String]
|
|
23
|
+
# @return [void]
|
|
17
24
|
def data_root=(path)
|
|
18
25
|
@data_root = path
|
|
19
26
|
clear_cache!
|
|
20
27
|
end
|
|
21
28
|
|
|
29
|
+
# Drop in-memory caches of parsed data files.
|
|
30
|
+
# @return [void]
|
|
22
31
|
def clear_cache!
|
|
23
32
|
@cpi_cache = {}
|
|
24
33
|
@fx_cache = {}
|
|
25
34
|
end
|
|
26
35
|
|
|
36
|
+
# Load the CPI series for a supported country.
|
|
37
|
+
# @param country [String]
|
|
38
|
+
# @return [Hash] parsed JSON with "monthly" / "annual" / metadata keys
|
|
39
|
+
# @raise [UnsupportedCountry] if `country` is not in {Supported::COUNTRIES}
|
|
40
|
+
# @raise [DataNotFound] if the file is missing
|
|
41
|
+
# @raise [UnsupportedSchemaVersion] if the file uses a future schema
|
|
27
42
|
def load_cpi(country)
|
|
28
43
|
@cpi_cache ||= {}
|
|
29
44
|
key = country.to_s.downcase
|
|
45
|
+
code = country.to_s.upcase
|
|
30
46
|
@cpi_cache[[data_root, key]] ||= begin
|
|
47
|
+
raise UnsupportedCountry, code unless SUPPORTED_COUNTRIES.include?(code)
|
|
48
|
+
|
|
31
49
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
32
|
-
|
|
50
|
+
unless File.exist?(path)
|
|
51
|
+
raise DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
|
|
52
|
+
"Check TIMEPRICE_DATA_ROOT or reinstall the gem."
|
|
53
|
+
end
|
|
54
|
+
|
|
33
55
|
parse_with_schema(path)
|
|
34
56
|
end
|
|
35
57
|
end
|
|
36
58
|
|
|
59
|
+
# Load the FX rates for a year.
|
|
60
|
+
# @param year [Integer, String]
|
|
61
|
+
# @return [Hash] parsed JSON with a "rates" map of date → currency → Float
|
|
62
|
+
# @raise [DataNotFound] if the per-year file is missing
|
|
37
63
|
def load_fx_year(year)
|
|
38
64
|
@fx_cache ||= {}
|
|
39
65
|
key = year.to_i
|
|
40
66
|
@fx_cache[[data_root, key]] ||= begin
|
|
41
67
|
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
42
68
|
raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
69
|
+
|
|
43
70
|
parse_with_schema(path)
|
|
44
71
|
end
|
|
45
72
|
end
|
|
@@ -49,9 +76,8 @@ module Timeprice
|
|
|
49
76
|
def parse_with_schema(path)
|
|
50
77
|
data = JSON.parse(File.read(path))
|
|
51
78
|
version = data["schema_version"]
|
|
52
|
-
unless version == SUPPORTED_SCHEMA_VERSION
|
|
53
|
-
|
|
54
|
-
end
|
|
79
|
+
raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
|
|
80
|
+
|
|
55
81
|
data
|
|
56
82
|
end
|
|
57
83
|
end
|
data/lib/timeprice/errors.rb
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "supported"
|
|
4
|
+
|
|
3
5
|
module Timeprice
|
|
6
|
+
# Base class for every error this library raises. Catch `Timeprice::Error`
|
|
7
|
+
# to handle anything the gem can throw at you.
|
|
4
8
|
class Error < StandardError; end
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
SUPPORTED_CURRENCIES = %w[USD GBP EUR JPY VND].freeze
|
|
8
|
-
|
|
10
|
+
# Raised when a country code is not in {Supported::COUNTRIES}.
|
|
9
11
|
class UnsupportedCountry < Error
|
|
10
12
|
attr_reader :country
|
|
11
13
|
|
|
12
14
|
def initialize(country)
|
|
13
15
|
@country = country
|
|
14
|
-
super("Unsupported country: #{country.inspect} (supported: #{
|
|
16
|
+
super("Unsupported country: #{country.inspect} (supported: #{Supported::COUNTRIES.join(", ")})")
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# Raised when a currency code is not in {Supported::CURRENCIES}.
|
|
18
21
|
class UnsupportedCurrency < Error
|
|
19
22
|
attr_reader :currency
|
|
20
23
|
|
|
21
24
|
def initialize(currency)
|
|
22
25
|
@currency = currency
|
|
23
|
-
super("Unsupported currency: #{currency.inspect} (supported: #{
|
|
26
|
+
super("Unsupported currency: #{currency.inspect} (supported: #{Supported::CURRENCIES.join(", ")})")
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
30
|
+
# Raised when a requested date falls outside the bundled data range.
|
|
27
31
|
class DateOutOfRange < Error
|
|
28
32
|
attr_reader :date, :range
|
|
29
33
|
|
|
@@ -34,12 +38,15 @@ module Timeprice
|
|
|
34
38
|
end
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
# Raised when a CPI or FX lookup has no usable data point.
|
|
37
42
|
class DataNotFound < Error
|
|
38
43
|
def initialize(message = "Data not found")
|
|
39
44
|
super
|
|
40
45
|
end
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
# Raised when a bundled data file declares a schema_version this gem
|
|
49
|
+
# doesn't know how to parse (forward-compat guard).
|
|
43
50
|
class UnsupportedSchemaVersion < Error
|
|
44
51
|
attr_reader :version, :path
|
|
45
52
|
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -9,6 +9,10 @@ module Timeprice
|
|
|
9
9
|
:amount, :original_amount, :from, :to, :date, :effective_date, :rate
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
+
# Historical FX conversion using bundled per-year USD-base rate files.
|
|
13
|
+
# Handles identity (USD→USD), direct lookup, inverse, and triangulation
|
|
14
|
+
# through USD. Weekend/holiday dates fall back up to {MAX_FALLBACK_DAYS}
|
|
15
|
+
# days to the nearest prior trading day.
|
|
12
16
|
module Exchange
|
|
13
17
|
BASE = "USD"
|
|
14
18
|
MAX_FALLBACK_DAYS = 7
|
|
@@ -16,12 +20,20 @@ module Timeprice
|
|
|
16
20
|
module_function
|
|
17
21
|
|
|
18
22
|
# Convert `amount` from currency `from` to currency `to` on `date`.
|
|
19
|
-
#
|
|
23
|
+
#
|
|
24
|
+
# @param amount [Numeric]
|
|
25
|
+
# @param from [String] ISO 4217 source currency
|
|
26
|
+
# @param to [String] ISO 4217 destination currency
|
|
27
|
+
# @param date [String, Date] date as "YYYY-MM-DD" or a Date instance
|
|
28
|
+
# @return [ExchangeResult]
|
|
29
|
+
# @raise [UnsupportedCurrency] if `from` or `to` is not supported
|
|
30
|
+
# @raise [DataNotFound] if no FX point exists within {MAX_FALLBACK_DAYS}
|
|
20
31
|
def convert(amount:, from:, to:, date:)
|
|
21
32
|
from = from.to_s.upcase
|
|
22
33
|
to = to.to_s.upcase
|
|
23
34
|
raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from)
|
|
24
35
|
raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
|
|
36
|
+
|
|
25
37
|
d = parse_date(date)
|
|
26
38
|
|
|
27
39
|
rate, eff_date = resolve_rate(from, to, d)
|
|
@@ -77,8 +89,10 @@ module Timeprice
|
|
|
77
89
|
end
|
|
78
90
|
rates_for_day = year_data.dig("rates", candidate.to_s)
|
|
79
91
|
next unless rates_for_day
|
|
92
|
+
|
|
80
93
|
rate = rates_for_day[currency]
|
|
81
94
|
next unless rate
|
|
95
|
+
|
|
82
96
|
return [rate.to_f, candidate]
|
|
83
97
|
end
|
|
84
98
|
raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
|
|
@@ -91,7 +105,12 @@ module Timeprice
|
|
|
91
105
|
unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
92
106
|
raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
|
|
93
107
|
end
|
|
94
|
-
|
|
108
|
+
|
|
109
|
+
begin
|
|
110
|
+
Date.parse(date)
|
|
111
|
+
rescue Date::Error
|
|
112
|
+
raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
|
|
113
|
+
end
|
|
95
114
|
else
|
|
96
115
|
raise ArgumentError, "Invalid date: #{date.inspect}"
|
|
97
116
|
end
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -16,18 +16,27 @@ module Timeprice
|
|
|
16
16
|
:from_index, :to_index, :granularity
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
# CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
|
|
19
20
|
module Inflation
|
|
20
21
|
module_function
|
|
21
22
|
|
|
22
23
|
# Adjust `amount` from date `from` to date `to` using country CPI.
|
|
23
24
|
#
|
|
24
25
|
# Dates accept "YYYY" or "YYYY-MM".
|
|
26
|
+
#
|
|
27
|
+
# @param amount [Numeric]
|
|
28
|
+
# @param from [String] source date ("YYYY" or "YYYY-MM")
|
|
29
|
+
# @param to [String] target date ("YYYY" or "YYYY-MM")
|
|
30
|
+
# @param country [String] country code (see {Supported::COUNTRIES})
|
|
31
|
+
# @return [InflationResult]
|
|
32
|
+
# @raise [UnsupportedCountry] if `country` is not supported
|
|
33
|
+
# @raise [DataNotFound] if no CPI data covers the requested period
|
|
25
34
|
def adjust(amount:, from:, to:, country:)
|
|
26
35
|
data = DataLoader.load_cpi(country)
|
|
27
36
|
from_index, from_gran = lookup_index(data, from)
|
|
28
37
|
to_index, to_gran = lookup_index(data, to)
|
|
29
38
|
|
|
30
|
-
ratio = to_index.to_f / from_index
|
|
39
|
+
ratio = to_index.to_f / from_index
|
|
31
40
|
InflationResult.new(
|
|
32
41
|
amount: amount.to_f * ratio,
|
|
33
42
|
original_amount: amount.to_f,
|
|
@@ -41,6 +50,11 @@ module Timeprice
|
|
|
41
50
|
end
|
|
42
51
|
|
|
43
52
|
# Inflation rate as decimal (e.g. 0.42 = 42%).
|
|
53
|
+
#
|
|
54
|
+
# @param from [String]
|
|
55
|
+
# @param to [String]
|
|
56
|
+
# @param country [String]
|
|
57
|
+
# @return [Float] decimal rate (positive means inflation, negative deflation)
|
|
44
58
|
def rate(from:, to:, country:)
|
|
45
59
|
result = adjust(amount: 1.0, from: from, to: to, country: country)
|
|
46
60
|
result.amount - 1.0
|
|
@@ -58,11 +72,10 @@ module Timeprice
|
|
|
58
72
|
[monthly[key], :monthly]
|
|
59
73
|
else
|
|
60
74
|
year = key[0, 4]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
75
|
+
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
|
|
76
|
+
|
|
77
|
+
[annual[year], :annual]
|
|
78
|
+
|
|
66
79
|
end
|
|
67
80
|
when /\A\d{4}\z/
|
|
68
81
|
if annual.key?(key)
|
|
@@ -70,6 +83,7 @@ module Timeprice
|
|
|
70
83
|
else
|
|
71
84
|
months = monthly.select { |k, _| k.start_with?("#{key}-") }
|
|
72
85
|
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
|
|
86
|
+
|
|
73
87
|
avg = months.values.sum.to_f / months.size
|
|
74
88
|
[avg, :annual_from_monthly_avg]
|
|
75
89
|
end
|
|
@@ -98,6 +112,7 @@ module Timeprice
|
|
|
98
112
|
def merge_granularity(a, b)
|
|
99
113
|
return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
|
|
100
114
|
return :annual if a == :annual || b == :annual
|
|
115
|
+
|
|
101
116
|
:monthly
|
|
102
117
|
end
|
|
103
118
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Timeprice
|
|
4
|
+
# A (currency, date) pair used as input to {Timeprice.compare}.
|
|
5
|
+
#
|
|
6
|
+
# The library accepts either a Point or a 2-element array. Arrays may be
|
|
7
|
+
# ordered either way (`["USD", "2010"]` or `["2010", "USD"]`) — the year
|
|
8
|
+
# and currency are detected by shape. This mirrors what the CLI already
|
|
9
|
+
# tolerates and removes the only "which slot is which?" footgun.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# Timeprice::Point.new(currency: "USD", date: "2010")
|
|
13
|
+
# Timeprice::Point.coerce(["USD", "2010"])
|
|
14
|
+
# Timeprice::Point.coerce(["2010", "USD"])
|
|
15
|
+
Point = Data.define(:currency, :date) do
|
|
16
|
+
# Coerce input into a Point. Accepts:
|
|
17
|
+
# - {Point} (returned as-is)
|
|
18
|
+
# - 2-element Array of [currency, date] in either order
|
|
19
|
+
#
|
|
20
|
+
# @param input [Point, Array]
|
|
21
|
+
# @return [Point]
|
|
22
|
+
# @raise [ArgumentError] if shape can't be recognised
|
|
23
|
+
def self.coerce(input)
|
|
24
|
+
return input if input.is_a?(Point)
|
|
25
|
+
|
|
26
|
+
unless input.is_a?(Array) && input.size == 2
|
|
27
|
+
raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
a, b = input.map(&:to_s)
|
|
31
|
+
currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
|
|
32
|
+
date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
|
|
33
|
+
|
|
34
|
+
if currency.nil? || date.nil?
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"Could not detect currency + date in #{input.inspect} " \
|
|
37
|
+
"(expected a 3-letter currency and a YYYY[-MM[-DD]] date)"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
new(currency: currency.upcase, date: date)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/timeprice/sources.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Timeprice
|
|
|
15
15
|
name: "U.S. Bureau of Labor Statistics — CPI-U (series CUUR0000SA0)",
|
|
16
16
|
license: "U.S. Government work — public domain",
|
|
17
17
|
license_url: "https://www.bls.gov/bls/linksite.htm",
|
|
18
|
-
attribution: "Data: U.S. Bureau of Labor Statistics"
|
|
18
|
+
attribution: "Data: U.S. Bureau of Labor Statistics",
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
id: "uk_cpi",
|
|
@@ -24,7 +24,7 @@ module Timeprice
|
|
|
24
24
|
name: "UK Office for National Statistics — CPI all-items (series D7BT)",
|
|
25
25
|
license: "Open Government Licence v3.0",
|
|
26
26
|
license_url: "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/",
|
|
27
|
-
attribution: "Contains public sector information licensed under the Open Government Licence v3.0"
|
|
27
|
+
attribution: "Contains public sector information licensed under the Open Government Licence v3.0",
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
id: "eu_hicp",
|
|
@@ -33,7 +33,7 @@ module Timeprice
|
|
|
33
33
|
name: "Eurostat — HICP prc_hicp_midx (Euro area, all items)",
|
|
34
34
|
license: "Eurostat reuse policy (free reuse with attribution)",
|
|
35
35
|
license_url: "https://ec.europa.eu/eurostat/about-us/policies/copyright",
|
|
36
|
-
attribution: "Source: Eurostat"
|
|
36
|
+
attribution: "Source: Eurostat",
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
id: "jp_cpi",
|
|
@@ -42,7 +42,7 @@ module Timeprice
|
|
|
42
42
|
name: "World Bank — FP.CPI.TOTL (annual, JP fallback)",
|
|
43
43
|
license: "CC BY 4.0",
|
|
44
44
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
45
|
-
attribution: "Source: World Bank, FP.CPI.TOTL"
|
|
45
|
+
attribution: "Source: World Bank, FP.CPI.TOTL",
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
id: "vn_cpi",
|
|
@@ -51,7 +51,7 @@ module Timeprice
|
|
|
51
51
|
name: "World Bank — FP.CPI.TOTL (annual)",
|
|
52
52
|
license: "CC BY 4.0",
|
|
53
53
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
54
|
-
attribution: "Source: World Bank, FP.CPI.TOTL"
|
|
54
|
+
attribution: "Source: World Bank, FP.CPI.TOTL",
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
id: "fx_ecb",
|
|
@@ -60,7 +60,7 @@ module Timeprice
|
|
|
60
60
|
name: "European Central Bank reference rates (via Frankfurter)",
|
|
61
61
|
license: "ECB reference rates — free reuse",
|
|
62
62
|
license_url: "https://www.ecb.europa.eu/services/disclaimer/html/index.en.html",
|
|
63
|
-
attribution: "FX data: European Central Bank reference rates via Frankfurter"
|
|
63
|
+
attribution: "FX data: European Central Bank reference rates via Frankfurter",
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
id: "fx_vnd",
|
|
@@ -69,14 +69,15 @@ module Timeprice
|
|
|
69
69
|
name: "World Bank — PA.NUS.FCRF (VND annual average, broadcast daily)",
|
|
70
70
|
license: "CC BY 4.0",
|
|
71
71
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
72
|
-
attribution: "VND FX: World Bank, PA.NUS.FCRF"
|
|
73
|
-
}
|
|
72
|
+
attribution: "VND FX: World Bank, PA.NUS.FCRF",
|
|
73
|
+
},
|
|
74
74
|
].freeze
|
|
75
75
|
|
|
76
76
|
module_function
|
|
77
77
|
|
|
78
78
|
# Returns an array of hashes with :id, :kind, :name, :license, :license_url,
|
|
79
79
|
# :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
|
|
80
|
+
# @return [Array<Hash>]
|
|
80
81
|
def list
|
|
81
82
|
ATTRIBUTIONS.map { |s| s.merge(coverage: coverage_for(s)) }
|
|
82
83
|
end
|
|
@@ -105,6 +106,7 @@ module Timeprice
|
|
|
105
106
|
root = File.join(DataLoader.data_root, "fx", "usd")
|
|
106
107
|
years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
107
108
|
return "no data" if years.empty?
|
|
109
|
+
|
|
108
110
|
case id
|
|
109
111
|
when "fx_vnd"
|
|
110
112
|
# VND broadcast-from-annual covers earlier years too.
|
|
@@ -113,14 +115,16 @@ module Timeprice
|
|
|
113
115
|
d["rates"].any? { |_, v| v.key?("VND") }
|
|
114
116
|
end
|
|
115
117
|
return "no VND data" if with_vnd.empty?
|
|
118
|
+
|
|
116
119
|
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
117
120
|
else
|
|
118
121
|
# ECB pairs (EUR/GBP/JPY) start 1999
|
|
119
122
|
ecb_years = years.select do |y|
|
|
120
123
|
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
121
|
-
d["rates"].any? { |_, v|
|
|
124
|
+
d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
|
|
122
125
|
end
|
|
123
126
|
return "no ECB data" if ecb_years.empty?
|
|
127
|
+
|
|
124
128
|
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
125
129
|
end
|
|
126
130
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Timeprice
|
|
4
|
+
# Canonical lists of supported country and currency codes, plus the
|
|
5
|
+
# bidirectional currency↔country map used by `Compare` and CLI output.
|
|
6
|
+
#
|
|
7
|
+
# Everything that needs to know "which currency pairs with which CPI series"
|
|
8
|
+
# must read it from here — duplicating the map elsewhere has bitten us before
|
|
9
|
+
# when a new country was added in one place and forgotten in the other.
|
|
10
|
+
module Supported
|
|
11
|
+
COUNTRIES = %w[US UK EU JP VN].freeze
|
|
12
|
+
CURRENCIES = %w[USD GBP EUR JPY VND].freeze
|
|
13
|
+
|
|
14
|
+
COUNTRY_TO_CURRENCY = {
|
|
15
|
+
"US" => "USD",
|
|
16
|
+
"UK" => "GBP",
|
|
17
|
+
"EU" => "EUR",
|
|
18
|
+
"JP" => "JPY",
|
|
19
|
+
"VN" => "VND",
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
CURRENCY_TO_COUNTRY = COUNTRY_TO_CURRENCY.invert.freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param country [String]
|
|
27
|
+
# @return [Boolean]
|
|
28
|
+
def country?(country)
|
|
29
|
+
COUNTRIES.include?(country.to_s.upcase)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param currency [String]
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def currency?(currency)
|
|
35
|
+
CURRENCIES.include?(currency.to_s.upcase)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param currency [String] ISO 4217 code (e.g. "USD")
|
|
39
|
+
# @return [String, nil] country code, or nil if unsupported
|
|
40
|
+
def country_for_currency(currency)
|
|
41
|
+
CURRENCY_TO_COUNTRY[currency.to_s.upcase]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param country [String] country code (e.g. "US")
|
|
45
|
+
# @return [String, nil] currency code, or nil if unsupported
|
|
46
|
+
def currency_for_country(country)
|
|
47
|
+
COUNTRY_TO_CURRENCY[country.to_s.upcase]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Back-compat aliases — keep the old top-level constants pointing at the
|
|
52
|
+
# canonical lists so existing requires of "errors" keep working.
|
|
53
|
+
SUPPORTED_COUNTRIES = Supported::COUNTRIES
|
|
54
|
+
SUPPORTED_CURRENCIES = Supported::CURRENCIES
|
|
55
|
+
end
|
data/lib/timeprice/version.rb
CHANGED
data/lib/timeprice.rb
CHANGED
|
@@ -1,24 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "timeprice/version"
|
|
4
|
+
require_relative "timeprice/supported"
|
|
4
5
|
require_relative "timeprice/errors"
|
|
6
|
+
require_relative "timeprice/point"
|
|
5
7
|
require_relative "timeprice/data_loader"
|
|
6
8
|
require_relative "timeprice/inflation"
|
|
7
9
|
require_relative "timeprice/exchange"
|
|
8
10
|
require_relative "timeprice/compare"
|
|
9
11
|
require_relative "timeprice/sources"
|
|
10
12
|
|
|
13
|
+
# Offline historical inflation & FX for Ruby.
|
|
14
|
+
#
|
|
15
|
+
# Top-level module functions wrap the three core operations: inflation
|
|
16
|
+
# adjustment, currency exchange, and a combined "compare" that does both
|
|
17
|
+
# in the right order. Each returns an immutable `Data.define` value object.
|
|
18
|
+
#
|
|
19
|
+
# @example Inflation
|
|
20
|
+
# Timeprice.inflation(amount: 100, from: "1990-01", to: "2024-01", country: "US")
|
|
21
|
+
# @example FX
|
|
22
|
+
# Timeprice.exchange(amount: 100, from: "USD", to: "JPY", date: "2010-06-15")
|
|
23
|
+
# @example Compare
|
|
24
|
+
# Timeprice.compare(amount: 100, from: ["USD", "2010"], to: ["VND", "2024"])
|
|
11
25
|
module Timeprice
|
|
12
26
|
module_function
|
|
13
27
|
|
|
28
|
+
# Inflation-adjust an amount between two dates using a country's CPI.
|
|
29
|
+
#
|
|
30
|
+
# @param amount [Numeric] the original amount
|
|
31
|
+
# @param from [String] source date as "YYYY" or "YYYY-MM"
|
|
32
|
+
# @param to [String] target date as "YYYY" or "YYYY-MM"
|
|
33
|
+
# @param country [String] country code from {Supported::COUNTRIES}
|
|
34
|
+
# @return [InflationResult]
|
|
35
|
+
# @raise [UnsupportedCountry] if `country` is not supported
|
|
36
|
+
# @raise [DataNotFound] if no CPI point covers `from` or `to`
|
|
14
37
|
def inflation(amount:, from:, to:, country:)
|
|
15
38
|
Inflation.adjust(amount: amount, from: from, to: to, country: country)
|
|
16
39
|
end
|
|
17
40
|
|
|
41
|
+
# Convert an amount between currencies on a specific date.
|
|
42
|
+
#
|
|
43
|
+
# @param amount [Numeric] the original amount
|
|
44
|
+
# @param from [String] source currency (ISO 4217)
|
|
45
|
+
# @param to [String] destination currency (ISO 4217)
|
|
46
|
+
# @param date [String] date as "YYYY-MM-DD"
|
|
47
|
+
# @return [ExchangeResult]
|
|
48
|
+
# @raise [UnsupportedCurrency] if either currency is not supported
|
|
49
|
+
# @raise [DataNotFound] if no FX point exists within the fallback window
|
|
18
50
|
def exchange(amount:, from:, to:, date:)
|
|
19
51
|
Exchange.convert(amount: amount, from: from, to: to, date: date)
|
|
20
52
|
end
|
|
21
53
|
|
|
54
|
+
# Compare an amount across two (currency, date) points: convert at the
|
|
55
|
+
# source date, then inflate in the destination currency. See README.md
|
|
56
|
+
# "Compare semantics" for why this order is correct.
|
|
57
|
+
#
|
|
58
|
+
# @param amount [Numeric]
|
|
59
|
+
# @param from [Point, Array(String, String)] source point
|
|
60
|
+
# @param to [Point, Array(String, String)] destination point
|
|
61
|
+
# @return [CompareResult]
|
|
22
62
|
def compare(amount:, from:, to:)
|
|
23
63
|
Compare.run(amount: amount, from: from, to: to)
|
|
24
64
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: timeprice
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -51,6 +51,48 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '3.13'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.69'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.69'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop-rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.6'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.6'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop-rspec
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.3'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.3'
|
|
54
96
|
description: Offline historical inflation & FX for Ruby - bundled data, no API keys,
|
|
55
97
|
monthly auto-refresh.
|
|
56
98
|
email:
|
|
@@ -120,7 +162,9 @@ files:
|
|
|
120
162
|
- lib/timeprice/errors.rb
|
|
121
163
|
- lib/timeprice/exchange.rb
|
|
122
164
|
- lib/timeprice/inflation.rb
|
|
165
|
+
- lib/timeprice/point.rb
|
|
123
166
|
- lib/timeprice/sources.rb
|
|
167
|
+
- lib/timeprice/supported.rb
|
|
124
168
|
- lib/timeprice/version.rb
|
|
125
169
|
homepage: https://github.com/patrick204nqh/timeprice
|
|
126
170
|
licenses:
|
|
@@ -130,6 +174,7 @@ metadata:
|
|
|
130
174
|
bug_tracker_uri: https://github.com/patrick204nqh/timeprice/issues
|
|
131
175
|
changelog_uri: https://github.com/patrick204nqh/timeprice/blob/main/CHANGELOG.md
|
|
132
176
|
github_repo: patrick204nqh/timeprice
|
|
177
|
+
rubygems_mfa_required: 'true'
|
|
133
178
|
rdoc_options: []
|
|
134
179
|
require_paths:
|
|
135
180
|
- lib
|