timeprice 0.1.2 → 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 +14 -0
- data/README.md +85 -0
- data/lib/timeprice/cli.rb +1 -5
- data/lib/timeprice/compare.rb +27 -19
- data/lib/timeprice/data_loader.rb +19 -0
- data/lib/timeprice/errors.rb +12 -5
- data/lib/timeprice/exchange.rb +12 -1
- data/lib/timeprice/inflation.rb +14 -0
- data/lib/timeprice/point.rb +43 -0
- data/lib/timeprice/sources.rb +1 -0
- data/lib/timeprice/supported.rb +55 -0
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +40 -0
- metadata +3 -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,6 +5,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
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
|
+
|
|
8
22
|
## [0.1.2] - 2026-05-11
|
|
9
23
|
|
|
10
24
|
### Added
|
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
|
@@ -203,12 +203,8 @@ end
|
|
|
203
203
|
# bloating the value object — the result doesn't carry currency, only country.
|
|
204
204
|
module Timeprice
|
|
205
205
|
class InflationResult
|
|
206
|
-
COUNTRY_TO_CURRENCY = {
|
|
207
|
-
"US" => "USD", "UK" => "GBP", "EU" => "EUR", "JP" => "JPY", "VN" => "VND"
|
|
208
|
-
}.freeze
|
|
209
|
-
|
|
210
206
|
def country_currency_label
|
|
211
|
-
|
|
207
|
+
Supported.currency_for_country(country) || country.to_s.upcase
|
|
212
208
|
end
|
|
213
209
|
end
|
|
214
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
|
|
|
@@ -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)
|
|
@@ -84,6 +79,19 @@ module Timeprice
|
|
|
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,26 +4,41 @@ 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
|
|
@@ -41,6 +56,10 @@ module Timeprice
|
|
|
41
56
|
end
|
|
42
57
|
end
|
|
43
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
|
|
44
63
|
def load_fx_year(year)
|
|
45
64
|
@fx_cache ||= {}
|
|
46
65
|
key = year.to_i
|
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,7 +20,14 @@ 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
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -16,12 +16,21 @@ 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)
|
|
@@ -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
|
|
@@ -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
|
@@ -77,6 +77,7 @@ module Timeprice
|
|
|
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
|
|
@@ -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
|
|
@@ -162,7 +162,9 @@ files:
|
|
|
162
162
|
- lib/timeprice/errors.rb
|
|
163
163
|
- lib/timeprice/exchange.rb
|
|
164
164
|
- lib/timeprice/inflation.rb
|
|
165
|
+
- lib/timeprice/point.rb
|
|
165
166
|
- lib/timeprice/sources.rb
|
|
167
|
+
- lib/timeprice/supported.rb
|
|
166
168
|
- lib/timeprice/version.rb
|
|
167
169
|
homepage: https://github.com/patrick204nqh/timeprice
|
|
168
170
|
licenses:
|