timeprice 0.3.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/DATA_LICENSES.md +2 -1
  4. data/README.md +2 -2
  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/presenters/compare.rb +3 -1
  53. data/lib/timeprice/cli/presenters/inflation.rb +2 -1
  54. data/lib/timeprice/compare.rb +2 -1
  55. data/lib/timeprice/cpi_lookup.rb +7 -5
  56. data/lib/timeprice/data_loader.rb +1 -1
  57. data/lib/timeprice/exchange.rb +32 -15
  58. data/lib/timeprice/granularity.rb +46 -0
  59. data/lib/timeprice/inflation.rb +4 -17
  60. data/lib/timeprice/sources.rb +5 -5
  61. data/lib/timeprice/version.rb +1 -1
  62. metadata +2 -1
@@ -1,5 +1,9 @@
1
1
  {
2
+ "schema_version": 2,
2
3
  "base": "USD",
4
+ "year": 2025,
5
+ "source": "Frankfurter (ECB) — daily reference rates",
6
+ "updated_at": "2026-05-11",
3
7
  "rates": {
4
8
  "2025-01-02": {
5
9
  "EUR": 0.9689,
@@ -1276,9 +1280,5 @@
1276
1280
  "GBP": 0.74264,
1277
1281
  "JPY": 156.67
1278
1282
  }
1279
- },
1280
- "schema_version": 1,
1281
- "source": "Frankfurter (ECB) — daily reference rates",
1282
- "updated_at": "2026-05-11",
1283
- "year": 2025
1283
+ }
1284
1284
  }
@@ -1,5 +1,9 @@
1
1
  {
2
+ "schema_version": 2,
2
3
  "base": "USD",
4
+ "year": 2026,
5
+ "source": "Frankfurter (ECB) — daily reference rates",
6
+ "updated_at": "2026-05-11",
3
7
  "rates": {
4
8
  "2026-01-02": {
5
9
  "EUR": 0.85317,
@@ -441,9 +445,5 @@
441
445
  "GBP": 0.73472,
442
446
  "JPY": 156.76
443
447
  }
444
- },
445
- "schema_version": 1,
446
- "source": "Frankfurter (ECB) — daily reference rates",
447
- "updated_at": "2026-05-11",
448
- "year": 2026
448
+ }
449
449
  }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../formatting"
4
+ require_relative "../../granularity"
4
5
 
5
6
  module Timeprice
6
7
  class CLI < Thor
@@ -35,7 +36,8 @@ module Timeprice
35
36
  "#{final} in #{@result.to_date}",
36
37
  " #{original} (#{@result.from_date})",
37
38
  format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
38
- format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date, @result.granularity),
39
+ format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date,
40
+ Granularity.humanize(@result.granularity)),
39
41
  ]
40
42
  end
41
43
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../formatting"
4
+ require_relative "../../granularity"
4
5
 
5
6
  module Timeprice
6
7
  class CLI < Thor
@@ -27,7 +28,7 @@ module Timeprice
27
28
  format(" %s %s (%s) -> %s %s (%s)",
28
29
  fmt_money(@result.original_amount, @ccy), @ccy, @result.from,
29
30
  fmt_money(@result.amount, @ccy), @ccy, @result.to),
30
- " #{@result.country} · #{@result.granularity} CPI",
31
+ " #{@result.country} · #{Granularity.humanize(@result.granularity)} CPI",
31
32
  ]
32
33
  end
33
34
  end
@@ -5,6 +5,7 @@ require_relative "supported"
5
5
  require_relative "point"
6
6
  require_relative "inflation"
7
7
  require_relative "exchange"
8
+ require_relative "granularity"
8
9
 
9
10
  module Timeprice
10
11
  CompareResult = Data.define(
@@ -70,7 +71,7 @@ module Timeprice
70
71
  fx_rate: fx_result.rate,
71
72
  cpi_ratio: infl.to_index.to_f / infl.from_index,
72
73
  converted_amount: converted,
73
- granularity: infl.granularity
74
+ granularity: Granularity.merge(fx_result.granularity, infl.granularity)
74
75
  )
75
76
  end
76
77
 
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "granularity"
4
5
 
5
6
  module Timeprice
6
7
  # CpiPoint pairs a CPI index value with the granularity of how it was
7
- # resolved (monthly, annual, or annual derived by averaging 12 months).
8
+ # resolved. See {Granularity} for the full set of possible tags.
8
9
  CpiPoint = Data.define(:value, :granularity)
9
10
 
10
11
  # Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
@@ -33,21 +34,22 @@ module Timeprice
33
34
  private
34
35
 
35
36
  def monthly_or_annual_fallback(month_key)
36
- return CpiPoint.new(value: @monthly[month_key], granularity: :monthly) if @monthly.key?(month_key)
37
+ return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
37
38
 
38
39
  year = month_key[0, 4]
39
40
  raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
40
41
 
41
- CpiPoint.new(value: @annual[year], granularity: :annual)
42
+ CpiPoint.new(value: @annual[year], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
42
43
  end
43
44
 
44
45
  def annual_or_monthly_average(year)
45
- return CpiPoint.new(value: @annual[year], granularity: :annual) if @annual.key?(year)
46
+ return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
46
47
 
47
48
  months = @monthly.select { |k, _| k.start_with?("#{year}-") }
48
49
  raise DataNotFound, missing_message(year) if months.empty?
49
50
 
50
- CpiPoint.new(value: months.values.sum.to_f / months.size, granularity: :annual_from_monthly_avg)
51
+ avg = months.values.sum.to_f / months.size
52
+ CpiPoint.new(value: avg, granularity: Granularity::ANNUAL_FROM_MONTHLY_AVG)
51
53
  end
52
54
 
53
55
  def missing_message(key)
@@ -9,7 +9,7 @@ module Timeprice
9
9
  # by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
10
10
  # {DataLoader.data_root=}.
11
11
  module DataLoader
12
- SUPPORTED_SCHEMA_VERSION = 1
12
+ SUPPORTED_SCHEMA_VERSION = 2
13
13
 
14
14
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
15
15
 
@@ -4,10 +4,11 @@ require "date"
4
4
  require_relative "errors"
5
5
  require_relative "data_loader"
6
6
  require_relative "supported"
7
+ require_relative "granularity"
7
8
 
8
9
  module Timeprice
9
10
  ExchangeResult = Data.define(
10
- :amount, :original_amount, :from, :to, :date, :effective_date, :rate
11
+ :amount, :original_amount, :from, :to, :date, :effective_date, :rate, :granularity
11
12
  )
12
13
 
13
14
  # Historical FX conversion using bundled per-year USD-base rate files.
@@ -37,7 +38,7 @@ module Timeprice
37
38
 
38
39
  d = parse_date(date)
39
40
 
40
- rate, eff_date = resolve_rate(from, to, d)
41
+ rate, eff_date, granularity = resolve_rate(from, to, d)
41
42
  ExchangeResult.new(
42
43
  amount: amount.to_f * rate,
43
44
  original_amount: amount.to_f,
@@ -45,40 +46,44 @@ module Timeprice
45
46
  to: to,
46
47
  date: d.to_s,
47
48
  effective_date: eff_date.to_s,
48
- rate: rate
49
+ rate: rate,
50
+ granularity: granularity
49
51
  )
50
52
  end
51
53
 
52
- # 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).
53
58
  # Handles:
54
59
  # - identity (from == to)
55
60
  # - direct lookup of USD-base rate
56
61
  # - inverse (foreign → USD)
57
62
  # - triangulation through USD (both legs must resolve to SAME effective date)
58
63
  def resolve_rate(from, to, d)
59
- return [1.0, d] if from == to
64
+ return [1.0, d, Granularity::DAILY] if from == to
60
65
 
61
66
  if from == BASE
62
- rate, eff = lookup_usd_base(to, d)
63
- [rate, eff]
67
+ lookup_usd_base(to, d)
64
68
  elsif to == BASE
65
- rate, eff = lookup_usd_base(from, d)
66
- [1.0 / rate, eff]
69
+ rate, eff, gran = lookup_usd_base(from, d)
70
+ [1.0 / rate, eff, gran]
67
71
  else
68
72
  # Triangulation: from → USD → to, both legs at the same effective date.
69
- usd_to_from, eff_a = lookup_usd_base(from, d)
70
- 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)
71
75
  if eff_a != eff_b
72
76
  raise DataNotFound,
73
77
  "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
74
78
  "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
75
79
  end
76
- [usd_to_to / usd_to_from, eff_a]
80
+ [usd_to_to / usd_to_from, eff_a, Granularity.merge(gran_a, gran_b)]
77
81
  end
78
82
  end
79
83
 
80
- # Walk back up to MAX_FALLBACK_DAYS to find a rate.
81
- # 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].
82
87
  def lookup_usd_base(currency, d)
83
88
  (0..MAX_FALLBACK_DAYS).each do |offset|
84
89
  candidate = d - offset
@@ -94,11 +99,23 @@ module Timeprice
94
99
  rate = rates_for_day[currency]
95
100
  next unless rate
96
101
 
97
- return [rate.to_f, candidate]
102
+ return [rate.to_f, candidate, Granularity::DAILY]
98
103
  end
104
+
105
+ annual_rate = annual_fallback(currency, d.year)
106
+ return [annual_rate, d, Granularity::ANNUAL] if annual_rate
107
+
99
108
  raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
100
109
  end
101
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
+
102
119
  def parse_date(date)
103
120
  case date
104
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
@@ -3,15 +3,11 @@
3
3
  require_relative "errors"
4
4
  require_relative "data_loader"
5
5
  require_relative "cpi_lookup"
6
+ require_relative "granularity"
6
7
 
7
8
  module Timeprice
8
- # Value object returned by Inflation.adjust.
9
- #
10
- # granularity is one of:
11
- # :monthly — both ends resolved on monthly data
12
- # :annual — at least one end resolved on annual data
13
- # :annual_from_monthly_avg — at least one end was an annual request resolved
14
- # 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.
15
11
  InflationResult = Data.define(
16
12
  :amount, :original_amount, :from, :to, :country,
17
13
  :from_index, :to_index, :granularity
@@ -54,7 +50,7 @@ module Timeprice
54
50
  country: country.to_s.upcase,
55
51
  from_index: from_point.value,
56
52
  to_index: to_point.value,
57
- granularity: merge_granularity(from_point.granularity, to_point.granularity)
53
+ granularity: Granularity.merge(from_point.granularity, to_point.granularity)
58
54
  )
59
55
  end
60
56
 
@@ -68,14 +64,5 @@ module Timeprice
68
64
  result = adjust(amount: 1.0, from: from, to: to, country: country)
69
65
  result.amount - 1.0
70
66
  end
71
-
72
- # If either end fell back to annual_from_monthly_avg, propagate that label;
73
- # else if either is annual, propagate :annual; else :monthly.
74
- def merge_granularity(a, b)
75
- return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
76
- return :annual if a == :annual || b == :annual
77
-
78
- :monthly
79
- end
80
67
  end
81
68
  end
@@ -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",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Timeprice
4
- VERSION = "0.3.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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -167,6 +167,7 @@ files:
167
167
  - lib/timeprice/data_loader.rb
168
168
  - lib/timeprice/errors.rb
169
169
  - lib/timeprice/exchange.rb
170
+ - lib/timeprice/granularity.rb
170
171
  - lib/timeprice/inflation.rb
171
172
  - lib/timeprice/point.rb
172
173
  - lib/timeprice/sources.rb