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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc65e20b859501552fbc5b53a7d6d3f833159d941ed5afc52bd1e9b51153d0fe
4
- data.tar.gz: 93f795ee7466123a98dd56d6ef202d19de8e2e68ea0588cfcfd6a1172142d681
3
+ metadata.gz: 160a9501bc1004df45cd71202f3ae07711068adade2118cfe4605a2e92d92f6f
4
+ data.tar.gz: 68ea05103245f2bb1fb02d701fd5db6e1bb814a2b47d20f6eb9011d94a57b269
5
5
  SHA512:
6
- metadata.gz: d94e236e90f83d00a5e9ce871f74353aaf67245649ad7c3d21f69515b06049fecf4170844b4decea5533a3a82a9ceb444a5906f868535181ce0f13ce684119a5
7
- data.tar.gz: f456c4a3b346031d6d69c2a55de77177022c9cabe7e1f656a8221dad767427786bc12d2838a81e67c514d6d9b86c76a33151f8f58095b672960f0f3b3f5c0bae
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.1.1] - 2026-05-11
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 "#{s[:name]}"
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 KRW IDR HUF CLP].freeze
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
- COUNTRY_TO_CURRENCY[country.to_s.upcase] || country.to_s.upcase
207
+ Supported.currency_for_country(country) || country.to_s.upcase
210
208
  end
211
209
  end
212
210
  end
@@ -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 PLAN.md §2 last bullet and §7.
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
- CURRENCY_TO_COUNTRY = {
31
- "USD" => "US",
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: Numeric
41
- # from: [currency, date_or_year] e.g. ["USD", "2010"] or ["USD", "2010-06"]
42
- # to: [currency, date_or_year]
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.to_f,
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
- raise UnsupportedCountry, country.to_s.upcase unless File.exist?(path)
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
- raise UnsupportedSchemaVersion.new(version, path)
54
- end
79
+ raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
80
+
55
81
  data
56
82
  end
57
83
  end
@@ -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
- SUPPORTED_COUNTRIES = %w[US UK EU JP VN].freeze
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: #{SUPPORTED_COUNTRIES.join(", ")})")
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: #{SUPPORTED_CURRENCIES.join(", ")})")
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
 
@@ -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
- # date: "YYYY-MM-DD".
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
- Date.parse(date)
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
@@ -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.to_f
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
- if annual.key?(year)
62
- [annual[year], :annual]
63
- else
64
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual)
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
@@ -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| (v.keys & %w[EUR GBP JPY]).any? }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.1
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