timeprice 0.2.0 → 0.4.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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/DATA_LICENSES.md +2 -1
  4. data/README.md +15 -6
  5. data/data/cpi/eu.json +23 -1
  6. data/data/cpi/jp.json +18 -2
  7. data/data/cpi/uk.json +23 -1
  8. data/data/cpi/us.json +29 -1
  9. data/data/cpi/vn.json +362 -34
  10. data/data/fx/usd/1983.json +7 -8
  11. data/data/fx/usd/1986.json +7 -8
  12. data/data/fx/usd/1987.json +7 -8
  13. data/data/fx/usd/1988.json +7 -8
  14. data/data/fx/usd/1989.json +7 -8
  15. data/data/fx/usd/1990.json +7 -8
  16. data/data/fx/usd/1991.json +7 -8
  17. data/data/fx/usd/1992.json +7 -8
  18. data/data/fx/usd/1993.json +7 -8
  19. data/data/fx/usd/1994.json +7 -8
  20. data/data/fx/usd/1995.json +7 -8
  21. data/data/fx/usd/1996.json +7 -8
  22. data/data/fx/usd/1997.json +7 -8
  23. data/data/fx/usd/1998.json +7 -8
  24. data/data/fx/usd/1999.json +266 -525
  25. data/data/fx/usd/2000.json +262 -517
  26. data/data/fx/usd/2001.json +261 -512
  27. data/data/fx/usd/2002.json +262 -514
  28. data/data/fx/usd/2003.json +262 -514
  29. data/data/fx/usd/2004.json +266 -522
  30. data/data/fx/usd/2005.json +264 -521
  31. data/data/fx/usd/2006.json +262 -514
  32. data/data/fx/usd/2007.json +262 -514
  33. data/data/fx/usd/2008.json +263 -516
  34. data/data/fx/usd/2009.json +263 -516
  35. data/data/fx/usd/2010.json +265 -523
  36. data/data/fx/usd/2011.json +264 -521
  37. data/data/fx/usd/2012.json +263 -516
  38. data/data/fx/usd/2013.json +262 -514
  39. data/data/fx/usd/2014.json +262 -514
  40. data/data/fx/usd/2015.json +263 -516
  41. data/data/fx/usd/2016.json +264 -521
  42. data/data/fx/usd/2017.json +262 -514
  43. data/data/fx/usd/2018.json +262 -514
  44. data/data/fx/usd/2019.json +262 -514
  45. data/data/fx/usd/2020.json +264 -518
  46. data/data/fx/usd/2021.json +265 -523
  47. data/data/fx/usd/2022.json +264 -521
  48. data/data/fx/usd/2023.json +262 -514
  49. data/data/fx/usd/2024.json +263 -516
  50. data/data/fx/usd/2025.json +5 -5
  51. data/data/fx/usd/2026.json +5 -5
  52. data/lib/timeprice/cli/formatting.rb +34 -0
  53. data/lib/timeprice/cli/presenters/compare.rb +46 -0
  54. data/lib/timeprice/cli/presenters/exchange.rb +45 -0
  55. data/lib/timeprice/cli/presenters/inflation.rb +37 -0
  56. data/lib/timeprice/cli/presenters/sources.rb +65 -0
  57. data/lib/timeprice/cli.rb +83 -114
  58. data/lib/timeprice/compare.rb +17 -34
  59. data/lib/timeprice/cpi_lookup.rb +64 -0
  60. data/lib/timeprice/data_loader.rb +13 -6
  61. data/lib/timeprice/exchange.rb +35 -17
  62. data/lib/timeprice/granularity.rb +46 -0
  63. data/lib/timeprice/inflation.rb +20 -71
  64. data/lib/timeprice/point.rb +30 -11
  65. data/lib/timeprice/sources/coverage.rb +71 -0
  66. data/lib/timeprice/sources.rb +7 -54
  67. data/lib/timeprice/supported.rb +12 -5
  68. data/lib/timeprice/version.rb +1 -1
  69. metadata +9 -1
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "granularity"
5
+
6
+ module Timeprice
7
+ # CpiPoint pairs a CPI index value with the granularity of how it was
8
+ # resolved. See {Granularity} for the full set of possible tags.
9
+ CpiPoint = Data.define(:value, :granularity)
10
+
11
+ # Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
12
+ # country's parsed CPI data hash. Knowing the JSON shape ("monthly" /
13
+ # "annual" string keys) is isolated here — Inflation just asks for points.
14
+ class CpiLookup
15
+ def initialize(data)
16
+ @data = data
17
+ @monthly = data["monthly"] || {}
18
+ @annual = data["annual"] || {}
19
+ end
20
+
21
+ # @param key [String] "YYYY" or "YYYY-MM"
22
+ # @return [CpiPoint]
23
+ # @raise [DataNotFound] if no CPI value covers `key`
24
+ # @raise [ArgumentError] on malformed `key`
25
+ def at(key)
26
+ key = key.to_s
27
+ case key
28
+ when /\A\d{4}-\d{2}\z/ then monthly_or_annual_fallback(key)
29
+ when /\A\d{4}\z/ then annual_or_monthly_average(key)
30
+ else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def monthly_or_annual_fallback(month_key)
37
+ return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
38
+
39
+ year = month_key[0, 4]
40
+ raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
41
+
42
+ CpiPoint.new(value: @annual[year], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
43
+ end
44
+
45
+ def annual_or_monthly_average(year)
46
+ return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
47
+
48
+ months = @monthly.select { |k, _| k.start_with?("#{year}-") }
49
+ raise DataNotFound, missing_message(year) if months.empty?
50
+
51
+ avg = months.values.sum.to_f / months.size
52
+ CpiPoint.new(value: avg, granularity: Granularity::ANNUAL_FROM_MONTHLY_AVG)
53
+ end
54
+
55
+ def missing_message(key)
56
+ country = @data["country"]
57
+ ranges = []
58
+ ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
59
+ ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
60
+ hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
61
+ "No CPI data for #{key.inspect} in #{country}#{hint}"
62
+ end
63
+ end
64
+ end
@@ -2,13 +2,14 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "errors"
5
+ require_relative "supported"
5
6
 
6
7
  module Timeprice
7
8
  # Loads and caches the bundled JSON data files. Override the search root
8
9
  # by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
9
10
  # {DataLoader.data_root=}.
10
11
  module DataLoader
11
- SUPPORTED_SCHEMA_VERSION = 1
12
+ SUPPORTED_SCHEMA_VERSION = 2
12
13
 
13
14
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
14
15
 
@@ -40,11 +41,10 @@ module Timeprice
40
41
  # @raise [DataNotFound] if the file is missing
41
42
  # @raise [UnsupportedSchemaVersion] if the file uses a future schema
42
43
  def load_cpi(country)
43
- @cpi_cache ||= {}
44
44
  key = country.to_s.downcase
45
45
  code = country.to_s.upcase
46
- @cpi_cache[[data_root, key]] ||= begin
47
- raise UnsupportedCountry, code unless SUPPORTED_COUNTRIES.include?(code)
46
+ cpi_cache[[data_root, key]] ||= begin
47
+ raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
48
48
 
49
49
  path = File.join(data_root, "cpi", "#{key}.json")
50
50
  unless File.exist?(path)
@@ -61,9 +61,8 @@ module Timeprice
61
61
  # @return [Hash] parsed JSON with a "rates" map of date → currency → Float
62
62
  # @raise [DataNotFound] if the per-year file is missing
63
63
  def load_fx_year(year)
64
- @fx_cache ||= {}
65
64
  key = year.to_i
66
- @fx_cache[[data_root, key]] ||= begin
65
+ fx_cache[[data_root, key]] ||= begin
67
66
  path = File.join(data_root, "fx", "usd", "#{key}.json")
68
67
  raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
69
68
 
@@ -73,6 +72,14 @@ module Timeprice
73
72
 
74
73
  private
75
74
 
75
+ def cpi_cache
76
+ @cpi_cache ||= {}
77
+ end
78
+
79
+ def fx_cache
80
+ @fx_cache ||= {}
81
+ end
82
+
76
83
  def parse_with_schema(path)
77
84
  data = JSON.parse(File.read(path))
78
85
  version = data["schema_version"]
@@ -3,10 +3,12 @@
3
3
  require "date"
4
4
  require_relative "errors"
5
5
  require_relative "data_loader"
6
+ require_relative "supported"
7
+ require_relative "granularity"
6
8
 
7
9
  module Timeprice
8
10
  ExchangeResult = Data.define(
9
- :amount, :original_amount, :from, :to, :date, :effective_date, :rate
11
+ :amount, :original_amount, :from, :to, :date, :effective_date, :rate, :granularity
10
12
  )
11
13
 
12
14
  # Historical FX conversion using bundled per-year USD-base rate files.
@@ -31,12 +33,12 @@ module Timeprice
31
33
  def convert(amount:, from:, to:, date:)
32
34
  from = from.to_s.upcase
33
35
  to = to.to_s.upcase
34
- raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from)
35
- raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
36
+ raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
37
+ raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
36
38
 
37
39
  d = parse_date(date)
38
40
 
39
- rate, eff_date = resolve_rate(from, to, d)
41
+ rate, eff_date, granularity = resolve_rate(from, to, d)
40
42
  ExchangeResult.new(
41
43
  amount: amount.to_f * rate,
42
44
  original_amount: amount.to_f,
@@ -44,40 +46,44 @@ module Timeprice
44
46
  to: to,
45
47
  date: d.to_s,
46
48
  effective_date: eff_date.to_s,
47
- rate: rate
49
+ rate: rate,
50
+ granularity: granularity
48
51
  )
49
52
  end
50
53
 
51
- # Returns [rate (Float), effective_date (Date)].
54
+ # Returns [rate (Float), effective_date (Date), granularity (Symbol)].
55
+ # Granularity is :daily when the rate came from a per-date entry, :annual
56
+ # when it came from the per-year `annual` fallback block. Triangulation
57
+ # merges both legs via Granularity.merge (worst-precision-wins).
52
58
  # Handles:
53
59
  # - identity (from == to)
54
60
  # - direct lookup of USD-base rate
55
61
  # - inverse (foreign → USD)
56
62
  # - triangulation through USD (both legs must resolve to SAME effective date)
57
63
  def resolve_rate(from, to, d)
58
- return [1.0, d] if from == to
64
+ return [1.0, d, Granularity::DAILY] if from == to
59
65
 
60
66
  if from == BASE
61
- rate, eff = lookup_usd_base(to, d)
62
- [rate, eff]
67
+ lookup_usd_base(to, d)
63
68
  elsif to == BASE
64
- rate, eff = lookup_usd_base(from, d)
65
- [1.0 / rate, eff]
69
+ rate, eff, gran = lookup_usd_base(from, d)
70
+ [1.0 / rate, eff, gran]
66
71
  else
67
72
  # Triangulation: from → USD → to, both legs at the same effective date.
68
- usd_to_from, eff_a = lookup_usd_base(from, d)
69
- usd_to_to, eff_b = lookup_usd_base(to, d)
73
+ usd_to_from, eff_a, gran_a = lookup_usd_base(from, d)
74
+ usd_to_to, eff_b, gran_b = lookup_usd_base(to, d)
70
75
  if eff_a != eff_b
71
76
  raise DataNotFound,
72
77
  "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
73
78
  "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
74
79
  end
75
- [usd_to_to / usd_to_from, eff_a]
80
+ [usd_to_to / usd_to_from, eff_a, Granularity.merge(gran_a, gran_b)]
76
81
  end
77
82
  end
78
83
 
79
- # Walk back up to MAX_FALLBACK_DAYS to find a rate.
80
- # Returns [rate, effective_date].
84
+ # Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall
85
+ # back to the year file's top-level `annual` block.
86
+ # Returns [rate, effective_date, granularity].
81
87
  def lookup_usd_base(currency, d)
82
88
  (0..MAX_FALLBACK_DAYS).each do |offset|
83
89
  candidate = d - offset
@@ -93,11 +99,23 @@ module Timeprice
93
99
  rate = rates_for_day[currency]
94
100
  next unless rate
95
101
 
96
- return [rate.to_f, candidate]
102
+ return [rate.to_f, candidate, Granularity::DAILY]
97
103
  end
104
+
105
+ annual_rate = annual_fallback(currency, d.year)
106
+ return [annual_rate, d, Granularity::ANNUAL] if annual_rate
107
+
98
108
  raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
99
109
  end
100
110
 
111
+ # Consult the year file's top-level `annual` block. Returns Float or nil.
112
+ def annual_fallback(currency, year)
113
+ year_data = DataLoader.load_fx_year(year)
114
+ year_data.dig("annual", currency)&.to_f
115
+ rescue DataNotFound
116
+ nil
117
+ end
118
+
101
119
  def parse_date(date)
102
120
  case date
103
121
  when Date then date
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timeprice
4
+ # Closed set of CPI-resolution granularities and the rules for combining /
5
+ # rendering them. Owns the lattice so callers don't hand-maintain it.
6
+ module Granularity
7
+ DAILY = :daily
8
+ MONTHLY = :monthly
9
+ ANNUAL = :annual
10
+ ANNUAL_FROM_MONTHLY_AVG = :annual_from_monthly_avg
11
+ MONTHLY_FROM_ANNUAL_FALLBACK = :monthly_from_annual_fallback
12
+
13
+ # Most-degraded first — `merge` returns the first match.
14
+ # DAILY is the highest-precision FX tag; MONTHLY is the highest-precision
15
+ # CPI tag. Compare uses merge() across both legs, so the most-degraded
16
+ # tag in either leg wins.
17
+ PRECEDENCE = [
18
+ MONTHLY_FROM_ANNUAL_FALLBACK,
19
+ ANNUAL_FROM_MONTHLY_AVG,
20
+ ANNUAL,
21
+ MONTHLY,
22
+ DAILY,
23
+ ].freeze
24
+
25
+ HUMAN_LABELS = {
26
+ DAILY => "daily",
27
+ MONTHLY => "monthly",
28
+ ANNUAL => "annual",
29
+ ANNUAL_FROM_MONTHLY_AVG => "annual (avg of months)",
30
+ MONTHLY_FROM_ANNUAL_FALLBACK => "annual (month unavailable)",
31
+ }.freeze
32
+
33
+ module_function
34
+
35
+ # Worst-precision-wins merge across two or more endpoint granularities.
36
+ def merge(*tags)
37
+ PRECEDENCE.find { |t| tags.include?(t) } || MONTHLY
38
+ end
39
+
40
+ # Human-readable label for CLI output. Falls through to the symbol's
41
+ # string form so an unknown tag still renders something.
42
+ def humanize(tag)
43
+ HUMAN_LABELS.fetch(tag, tag.to_s)
44
+ end
45
+ end
46
+ end
@@ -2,19 +2,24 @@
2
2
 
3
3
  require_relative "errors"
4
4
  require_relative "data_loader"
5
+ require_relative "cpi_lookup"
6
+ require_relative "granularity"
5
7
 
6
8
  module Timeprice
7
- # Value object returned by Inflation.adjust.
8
- #
9
- # granularity is one of:
10
- # :monthly — both ends resolved on monthly data
11
- # :annual — at least one end resolved on annual data
12
- # :annual_from_monthly_avg — at least one end was an annual request resolved
13
- # by averaging 12 months of monthly data
9
+ # Value object returned by Inflation.adjust. See {Granularity} for the set
10
+ # of possible `granularity` values and the worst-precision-wins merge rule.
14
11
  InflationResult = Data.define(
15
12
  :amount, :original_amount, :from, :to, :country,
16
13
  :from_index, :to_index, :granularity
17
- )
14
+ ) do
15
+ # The country's primary currency (e.g. "USD" for "US"). Falls back to the
16
+ # uppercased country code if the country isn't in the supported map —
17
+ # callers can still render *some* unit rather than crashing.
18
+ def country_currency_label
19
+ require_relative "supported"
20
+ Supported.currency_for_country(country) || country.to_s.upcase
21
+ end
22
+ end
18
23
 
19
24
  # CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
20
25
  module Inflation
@@ -32,20 +37,20 @@ module Timeprice
32
37
  # @raise [UnsupportedCountry] if `country` is not supported
33
38
  # @raise [DataNotFound] if no CPI data covers the requested period
34
39
  def adjust(amount:, from:, to:, country:)
35
- data = DataLoader.load_cpi(country)
36
- from_index, from_gran = lookup_index(data, from)
37
- to_index, to_gran = lookup_index(data, to)
40
+ lookup = CpiLookup.new(DataLoader.load_cpi(country))
41
+ from_point = lookup.at(from)
42
+ to_point = lookup.at(to)
38
43
 
39
- ratio = to_index.to_f / from_index
44
+ ratio = to_point.value.to_f / from_point.value
40
45
  InflationResult.new(
41
46
  amount: amount.to_f * ratio,
42
47
  original_amount: amount.to_f,
43
48
  from: from,
44
49
  to: to,
45
50
  country: country.to_s.upcase,
46
- from_index: from_index,
47
- to_index: to_index,
48
- granularity: merge_granularity(from_gran, to_gran)
51
+ from_index: from_point.value,
52
+ to_index: to_point.value,
53
+ granularity: Granularity.merge(from_point.granularity, to_point.granularity)
49
54
  )
50
55
  end
51
56
 
@@ -59,61 +64,5 @@ module Timeprice
59
64
  result = adjust(amount: 1.0, from: from, to: to, country: country)
60
65
  result.amount - 1.0
61
66
  end
62
-
63
- # Returns [index_value, granularity_symbol]
64
- def lookup_index(data, key)
65
- key = key.to_s
66
- monthly = data["monthly"] || {}
67
- annual = data["annual"] || {}
68
-
69
- case key
70
- when /\A\d{4}-\d{2}\z/
71
- if monthly.key?(key)
72
- [monthly[key], :monthly]
73
- else
74
- year = key[0, 4]
75
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
76
-
77
- [annual[year], :annual]
78
-
79
- end
80
- when /\A\d{4}\z/
81
- if annual.key?(key)
82
- [annual[key], :annual]
83
- else
84
- months = monthly.select { |k, _| k.start_with?("#{key}-") }
85
- raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
86
-
87
- avg = months.values.sum.to_f / months.size
88
- [avg, :annual_from_monthly_avg]
89
- end
90
- else
91
- raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
92
- end
93
- end
94
-
95
- def missing_cpi_message(key, data, monthly, annual)
96
- country = data["country"]
97
- ranges = []
98
- if monthly.any?
99
- ks = monthly.keys.sort
100
- ranges << "monthly #{ks.first}..#{ks.last}"
101
- end
102
- if annual.any?
103
- ks = annual.keys.sort
104
- ranges << "annual #{ks.first}..#{ks.last}"
105
- end
106
- hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
107
- "No CPI data for #{key.inspect} in #{country}#{hint}"
108
- end
109
-
110
- # If either end fell back to annual_from_monthly_avg, propagate that label;
111
- # else if either is annual, propagate :annual; else :monthly.
112
- def merge_granularity(a, b)
113
- return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
114
- return :annual if a == :annual || b == :annual
115
-
116
- :monthly
117
- end
118
67
  end
119
68
  end
@@ -21,23 +21,42 @@ module Timeprice
21
21
  # @return [Point]
22
22
  # @raise [ArgumentError] if shape can't be recognised
23
23
  def self.coerce(input)
24
- return input if input.is_a?(Point)
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?
25
32
 
26
- unless input.is_a?(Array) && input.size == 2
33
+ new(currency: currency.upcase, date: date)
34
+ else
27
35
  raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
28
36
  end
37
+ end
29
38
 
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/) }
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
33
43
 
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)"
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}"
38
59
  end
39
-
40
- new(currency: currency.upcase, date: date)
41
60
  end
42
61
  end
43
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
@@ -48,10 +48,10 @@ module Timeprice
48
48
  id: "vn_cpi",
49
49
  kind: "cpi",
50
50
  country: "VN",
51
- name: "World Bank FP.CPI.TOTL (annual)",
52
- license: "CC BY 4.0",
53
- license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
54
- attribution: "Source: World Bank, FP.CPI.TOTL",
51
+ name: "IMF Data Portal CPI dataflow (monthly primary) + World Bank FP.CPI.TOTL (annual fallback)",
52
+ license: "IMF: free reuse with attribution; World Bank: CC BY 4.0",
53
+ license_url: "https://www.imf.org/external/terms.htm",
54
+ attribution: "Sources: IMF Data Portal CPI dataflow; World Bank FP.CPI.TOTL",
55
55
  },
56
56
  {
57
57
  id: "fx_ecb",
@@ -66,7 +66,7 @@ module Timeprice
66
66
  id: "fx_vnd",
67
67
  kind: "fx",
68
68
  country: "VN",
69
- name: "World Bank — PA.NUS.FCRF (VND annual average, broadcast daily)",
69
+ name: "World Bank — PA.NUS.FCRF (VND annual average, annual-granularity fallback)",
70
70
  license: "CC BY 4.0",
71
71
  license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
72
72
  attribution: "VND FX: World Bank, PA.NUS.FCRF",
@@ -79,54 +79,7 @@ module Timeprice
79
79
  # :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
80
80
  # @return [Array<Hash>]
81
81
  def list
82
- ATTRIBUTIONS.map { |s| s.merge(coverage: coverage_for(s)) }
83
- end
84
-
85
- def coverage_for(src)
86
- case src[:kind]
87
- when "cpi" then cpi_coverage(src[:country])
88
- when "fx" then fx_coverage(src[:id])
89
- else "n/a"
90
- end
91
- rescue StandardError => e
92
- "(coverage unavailable: #{e.message})"
93
- end
94
-
95
- def cpi_coverage(country)
96
- data = DataLoader.load_cpi(country)
97
- monthly = (data["monthly"] || {}).keys.sort
98
- annual = (data["annual"] || {}).keys.sort
99
- parts = []
100
- parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
101
- parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
102
- parts.join(", ")
103
- end
104
-
105
- def fx_coverage(id)
106
- root = File.join(DataLoader.data_root, "fx", "usd")
107
- years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
108
- return "no data" if years.empty?
109
-
110
- case id
111
- when "fx_vnd"
112
- # VND broadcast-from-annual covers earlier years too.
113
- with_vnd = years.select do |y|
114
- d = JSON.parse(File.read(File.join(root, "#{y}.json")))
115
- d["rates"].any? { |_, v| v.key?("VND") }
116
- end
117
- return "no VND data" if with_vnd.empty?
118
-
119
- "USD↔VND #{with_vnd.first}..#{with_vnd.last}"
120
- else
121
- # ECB pairs (EUR/GBP/JPY) start 1999
122
- ecb_years = years.select do |y|
123
- d = JSON.parse(File.read(File.join(root, "#{y}.json")))
124
- d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
125
- end
126
- return "no ECB data" if ecb_years.empty?
127
-
128
- "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
129
- end
82
+ ATTRIBUTIONS.map { |s| s.merge(coverage: Coverage.for(s)) }
130
83
  end
131
84
  end
132
85
  end
@@ -21,8 +21,20 @@ module Timeprice
21
21
 
22
22
  CURRENCY_TO_COUNTRY = COUNTRY_TO_CURRENCY.invert.freeze
23
23
 
24
+ # Currencies with no minor unit — formatted as whole numbers.
25
+ ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
26
+
24
27
  module_function
25
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
+
26
38
  # @param country [String]
27
39
  # @return [Boolean]
28
40
  def country?(country)
@@ -47,9 +59,4 @@ module Timeprice
47
59
  COUNTRY_TO_CURRENCY[country.to_s.upcase]
48
60
  end
49
61
  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
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -157,13 +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
170
+ - lib/timeprice/granularity.rb
164
171
  - lib/timeprice/inflation.rb
165
172
  - lib/timeprice/point.rb
166
173
  - lib/timeprice/sources.rb
174
+ - lib/timeprice/sources/coverage.rb
167
175
  - lib/timeprice/supported.rb
168
176
  - lib/timeprice/version.rb
169
177
  homepage: https://github.com/patrick204nqh/timeprice