timeprice 0.1.2 → 0.3.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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "errors"
4
4
  require_relative "data_loader"
5
+ require_relative "cpi_lookup"
5
6
 
6
7
  module Timeprice
7
8
  # Value object returned by Inflation.adjust.
@@ -14,85 +15,60 @@ module Timeprice
14
15
  InflationResult = Data.define(
15
16
  :amount, :original_amount, :from, :to, :country,
16
17
  :from_index, :to_index, :granularity
17
- )
18
+ ) do
19
+ # The country's primary currency (e.g. "USD" for "US"). Falls back to the
20
+ # uppercased country code if the country isn't in the supported map —
21
+ # callers can still render *some* unit rather than crashing.
22
+ def country_currency_label
23
+ require_relative "supported"
24
+ Supported.currency_for_country(country) || country.to_s.upcase
25
+ end
26
+ end
18
27
 
28
+ # CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
19
29
  module Inflation
20
30
  module_function
21
31
 
22
32
  # Adjust `amount` from date `from` to date `to` using country CPI.
23
33
  #
24
34
  # Dates accept "YYYY" or "YYYY-MM".
35
+ #
36
+ # @param amount [Numeric]
37
+ # @param from [String] source date ("YYYY" or "YYYY-MM")
38
+ # @param to [String] target date ("YYYY" or "YYYY-MM")
39
+ # @param country [String] country code (see {Supported::COUNTRIES})
40
+ # @return [InflationResult]
41
+ # @raise [UnsupportedCountry] if `country` is not supported
42
+ # @raise [DataNotFound] if no CPI data covers the requested period
25
43
  def adjust(amount:, from:, to:, country:)
26
- data = DataLoader.load_cpi(country)
27
- from_index, from_gran = lookup_index(data, from)
28
- to_index, to_gran = lookup_index(data, to)
44
+ lookup = CpiLookup.new(DataLoader.load_cpi(country))
45
+ from_point = lookup.at(from)
46
+ to_point = lookup.at(to)
29
47
 
30
- ratio = to_index.to_f / from_index
48
+ ratio = to_point.value.to_f / from_point.value
31
49
  InflationResult.new(
32
50
  amount: amount.to_f * ratio,
33
51
  original_amount: amount.to_f,
34
52
  from: from,
35
53
  to: to,
36
54
  country: country.to_s.upcase,
37
- from_index: from_index,
38
- to_index: to_index,
39
- granularity: merge_granularity(from_gran, to_gran)
55
+ from_index: from_point.value,
56
+ to_index: to_point.value,
57
+ granularity: merge_granularity(from_point.granularity, to_point.granularity)
40
58
  )
41
59
  end
42
60
 
43
61
  # Inflation rate as decimal (e.g. 0.42 = 42%).
62
+ #
63
+ # @param from [String]
64
+ # @param to [String]
65
+ # @param country [String]
66
+ # @return [Float] decimal rate (positive means inflation, negative deflation)
44
67
  def rate(from:, to:, country:)
45
68
  result = adjust(amount: 1.0, from: from, to: to, country: country)
46
69
  result.amount - 1.0
47
70
  end
48
71
 
49
- # Returns [index_value, granularity_symbol]
50
- def lookup_index(data, key)
51
- key = key.to_s
52
- monthly = data["monthly"] || {}
53
- annual = data["annual"] || {}
54
-
55
- case key
56
- when /\A\d{4}-\d{2}\z/
57
- if monthly.key?(key)
58
- [monthly[key], :monthly]
59
- else
60
- year = key[0, 4]
61
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
62
-
63
- [annual[year], :annual]
64
-
65
- end
66
- when /\A\d{4}\z/
67
- if annual.key?(key)
68
- [annual[key], :annual]
69
- else
70
- months = monthly.select { |k, _| k.start_with?("#{key}-") }
71
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
72
-
73
- avg = months.values.sum.to_f / months.size
74
- [avg, :annual_from_monthly_avg]
75
- end
76
- else
77
- raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
78
- end
79
- end
80
-
81
- def missing_cpi_message(key, data, monthly, annual)
82
- country = data["country"]
83
- ranges = []
84
- if monthly.any?
85
- ks = monthly.keys.sort
86
- ranges << "monthly #{ks.first}..#{ks.last}"
87
- end
88
- if annual.any?
89
- ks = annual.keys.sort
90
- ranges << "annual #{ks.first}..#{ks.last}"
91
- end
92
- hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
93
- "No CPI data for #{key.inspect} in #{country}#{hint}"
94
- end
95
-
96
72
  # If either end fell back to annual_from_monthly_avg, propagate that label;
97
73
  # else if either is annual, propagate :annual; else :monthly.
98
74
  def merge_granularity(a, b)
@@ -0,0 +1,62 @@
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
+ case input
25
+ in Point
26
+ input
27
+ in [_, _]
28
+ a, b = input.map(&:to_s)
29
+ currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
30
+ date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
31
+ raise ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
32
+
33
+ new(currency: currency.upcase, date: date)
34
+ else
35
+ raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
36
+ end
37
+ end
38
+
39
+ def self.malformed_pair_message(input)
40
+ "Could not detect currency + date in #{input.inspect} " \
41
+ "(expected a 3-letter currency and a YYYY[-MM[-DD]] date)"
42
+ end
43
+
44
+ # Resolve `date` to a full YYYY-MM-DD for FX lookup.
45
+ #
46
+ # Coarser grains anchor to a representative day:
47
+ # - "YYYY" → mid-year (YYYY-06-30)
48
+ # - "YYYY-MM" → mid-month (YYYY-MM-15)
49
+ # - "YYYY-MM-DD" → passes through
50
+ #
51
+ # @return [String]
52
+ # @raise [ArgumentError] if `date` doesn't match any supported shape
53
+ def fx_anchor_date
54
+ case date.to_s
55
+ when /\A\d{4}\z/ then "#{date}-06-30"
56
+ when /\A\d{4}-\d{2}\z/ then "#{date}-15"
57
+ when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
58
+ else raise ArgumentError, "Invalid date for Point: #{date.inspect}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../data_loader"
5
+
6
+ module Timeprice
7
+ module Sources
8
+ # Computes coverage strings for bundled data sources at runtime. All
9
+ # filesystem reads happen here so the Sources attribution registry stays
10
+ # a pure data table.
11
+ module Coverage
12
+ module_function
13
+
14
+ # @param src [Hash] one entry from Sources::ATTRIBUTIONS
15
+ # @return [String]
16
+ def for(src)
17
+ case src[:kind]
18
+ when "cpi" then cpi(src[:country])
19
+ when "fx" then fx(src[:id])
20
+ else "n/a"
21
+ end
22
+ rescue StandardError => e
23
+ "(coverage unavailable: #{e.message})"
24
+ end
25
+
26
+ def cpi(country)
27
+ data = DataLoader.load_cpi(country)
28
+ monthly = (data["monthly"] || {}).keys.sort
29
+ annual = (data["annual"] || {}).keys.sort
30
+ parts = []
31
+ parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
32
+ parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
33
+ parts.join(", ")
34
+ end
35
+
36
+ def fx(id)
37
+ years = fx_years
38
+ return "no data" if years.empty?
39
+
40
+ id == "fx_vnd" ? vnd_summary(years) : ecb_summary(years)
41
+ end
42
+
43
+ def fx_years
44
+ Dir[File.join(fx_root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
45
+ end
46
+
47
+ def vnd_summary(years)
48
+ with_vnd = years.select { |y| year_has_currency?(y, %w[VND]) }
49
+ return "no VND data" if with_vnd.empty?
50
+
51
+ "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
52
+ end
53
+
54
+ def ecb_summary(years)
55
+ ecb_years = years.select { |y| year_has_currency?(y, %w[EUR GBP JPY]) }
56
+ return "no ECB data" if ecb_years.empty?
57
+
58
+ "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
59
+ end
60
+
61
+ def year_has_currency?(year, codes)
62
+ rates = JSON.parse(File.read(File.join(fx_root, "#{year}.json")))["rates"]
63
+ rates.any? { |_, v| v.keys.intersect?(codes) }
64
+ end
65
+
66
+ def fx_root
67
+ File.join(DataLoader.data_root, "fx", "usd")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "data_loader"
3
+ require_relative "sources/coverage"
4
4
 
5
5
  module Timeprice
6
6
  # Enumerate bundled data sources with license/attribution and the actual
@@ -77,55 +77,9 @@ 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
- ATTRIBUTIONS.map { |s| s.merge(coverage: coverage_for(s)) }
82
- end
83
-
84
- def coverage_for(src)
85
- case src[:kind]
86
- when "cpi" then cpi_coverage(src[:country])
87
- when "fx" then fx_coverage(src[:id])
88
- else "n/a"
89
- end
90
- rescue StandardError => e
91
- "(coverage unavailable: #{e.message})"
92
- end
93
-
94
- def cpi_coverage(country)
95
- data = DataLoader.load_cpi(country)
96
- monthly = (data["monthly"] || {}).keys.sort
97
- annual = (data["annual"] || {}).keys.sort
98
- parts = []
99
- parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
100
- parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
101
- parts.join(", ")
102
- end
103
-
104
- def fx_coverage(id)
105
- root = File.join(DataLoader.data_root, "fx", "usd")
106
- years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
107
- return "no data" if years.empty?
108
-
109
- case id
110
- when "fx_vnd"
111
- # VND broadcast-from-annual covers earlier years too.
112
- with_vnd = years.select do |y|
113
- d = JSON.parse(File.read(File.join(root, "#{y}.json")))
114
- d["rates"].any? { |_, v| v.key?("VND") }
115
- end
116
- return "no VND data" if with_vnd.empty?
117
-
118
- "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
119
- else
120
- # ECB pairs (EUR/GBP/JPY) start 1999
121
- ecb_years = years.select do |y|
122
- d = JSON.parse(File.read(File.join(root, "#{y}.json")))
123
- d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
124
- end
125
- return "no ECB data" if ecb_years.empty?
126
-
127
- "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
128
- end
82
+ ATTRIBUTIONS.map { |s| s.merge(coverage: Coverage.for(s)) }
129
83
  end
130
84
  end
131
85
  end
@@ -0,0 +1,62 @@
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
+ # Currencies with no minor unit — formatted as whole numbers.
25
+ ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
26
+
27
+ module_function
28
+
29
+ # ISO 4217 minor-unit count for a currency. Falls back to 2 for unknown
30
+ # codes so callers can still render *some* value rather than crashing.
31
+ #
32
+ # @param currency [String]
33
+ # @return [Integer]
34
+ def decimals_for(currency)
35
+ ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
36
+ end
37
+
38
+ # @param country [String]
39
+ # @return [Boolean]
40
+ def country?(country)
41
+ COUNTRIES.include?(country.to_s.upcase)
42
+ end
43
+
44
+ # @param currency [String]
45
+ # @return [Boolean]
46
+ def currency?(currency)
47
+ CURRENCIES.include?(currency.to_s.upcase)
48
+ end
49
+
50
+ # @param currency [String] ISO 4217 code (e.g. "USD")
51
+ # @return [String, nil] country code, or nil if unsupported
52
+ def country_for_currency(currency)
53
+ CURRENCY_TO_COUNTRY[currency.to_s.upcase]
54
+ end
55
+
56
+ # @param country [String] country code (e.g. "US")
57
+ # @return [String, nil] currency code, or nil if unsupported
58
+ def currency_for_country(country)
59
+ COUNTRY_TO_CURRENCY[country.to_s.upcase]
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.1.2"
4
+ VERSION = "0.3.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.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -157,12 +157,21 @@ files:
157
157
  - exe/timeprice
158
158
  - lib/timeprice.rb
159
159
  - lib/timeprice/cli.rb
160
+ - lib/timeprice/cli/formatting.rb
161
+ - lib/timeprice/cli/presenters/compare.rb
162
+ - lib/timeprice/cli/presenters/exchange.rb
163
+ - lib/timeprice/cli/presenters/inflation.rb
164
+ - lib/timeprice/cli/presenters/sources.rb
160
165
  - lib/timeprice/compare.rb
166
+ - lib/timeprice/cpi_lookup.rb
161
167
  - lib/timeprice/data_loader.rb
162
168
  - lib/timeprice/errors.rb
163
169
  - lib/timeprice/exchange.rb
164
170
  - lib/timeprice/inflation.rb
171
+ - lib/timeprice/point.rb
165
172
  - lib/timeprice/sources.rb
173
+ - lib/timeprice/sources/coverage.rb
174
+ - lib/timeprice/supported.rb
166
175
  - lib/timeprice/version.rb
167
176
  homepage: https://github.com/patrick204nqh/timeprice
168
177
  licenses: