timeprice 0.4.0 → 0.6.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/DATA_LICENSES.md +16 -1
  4. data/README.md +29 -5
  5. data/data/cpi/eu.json +406 -403
  6. data/data/cpi/jp.json +78 -75
  7. data/data/cpi/uk.json +513 -510
  8. data/data/cpi/us.json +488 -485
  9. data/data/cpi/vn.json +342 -339
  10. data/data/fx/usd/1999.json +23 -7
  11. data/data/fx/usd/2000.json +23 -7
  12. data/data/fx/usd/2001.json +23 -7
  13. data/data/fx/usd/2002.json +23 -7
  14. data/data/fx/usd/2003.json +23 -7
  15. data/data/fx/usd/2004.json +23 -7
  16. data/data/fx/usd/2005.json +23 -7
  17. data/data/fx/usd/2006.json +23 -7
  18. data/data/fx/usd/2007.json +23 -7
  19. data/data/fx/usd/2008.json +23 -7
  20. data/data/fx/usd/2009.json +23 -7
  21. data/data/fx/usd/2010.json +23 -7
  22. data/data/fx/usd/2011.json +23 -7
  23. data/data/fx/usd/2012.json +23 -7
  24. data/data/fx/usd/2013.json +23 -7
  25. data/data/fx/usd/2014.json +23 -7
  26. data/data/fx/usd/2015.json +23 -7
  27. data/data/fx/usd/2016.json +23 -7
  28. data/data/fx/usd/2017.json +23 -7
  29. data/data/fx/usd/2018.json +23 -7
  30. data/data/fx/usd/2019.json +23 -7
  31. data/data/fx/usd/2020.json +23 -7
  32. data/data/fx/usd/2021.json +23 -7
  33. data/data/fx/usd/2022.json +23 -7
  34. data/data/fx/usd/2023.json +23 -7
  35. data/data/fx/usd/2024.json +23 -7
  36. data/data/fx/usd/2025.json +24 -5
  37. data/data/fx/usd/2026.json +24 -5
  38. data/data/fx/usd/_annual.json +145 -0
  39. data/data/manifest.json +90 -0
  40. data/lib/timeprice/cli.rb +3 -3
  41. data/lib/timeprice/compare.rb +1 -1
  42. data/lib/timeprice/cpi_lookup.rb +64 -18
  43. data/lib/timeprice/data_loader.rb +47 -8
  44. data/lib/timeprice/errors.rb +4 -4
  45. data/lib/timeprice/exchange.rb +8 -8
  46. data/lib/timeprice/granularity.rb +41 -10
  47. data/lib/timeprice/inflation.rb +5 -5
  48. data/lib/timeprice/sources/coverage.rb +27 -32
  49. data/lib/timeprice/supported.rb +39 -22
  50. data/lib/timeprice/version.rb +1 -1
  51. data/lib/timeprice.rb +2 -2
  52. metadata +3 -15
  53. data/data/fx/usd/1983.json +0 -11
  54. data/data/fx/usd/1986.json +0 -11
  55. data/data/fx/usd/1987.json +0 -11
  56. data/data/fx/usd/1988.json +0 -11
  57. data/data/fx/usd/1989.json +0 -11
  58. data/data/fx/usd/1990.json +0 -11
  59. data/data/fx/usd/1991.json +0 -11
  60. data/data/fx/usd/1992.json +0 -11
  61. data/data/fx/usd/1993.json +0 -11
  62. data/data/fx/usd/1994.json +0 -11
  63. data/data/fx/usd/1995.json +0 -11
  64. data/data/fx/usd/1996.json +0 -11
  65. data/data/fx/usd/1997.json +0 -11
  66. data/data/fx/usd/1998.json +0 -11
@@ -0,0 +1,90 @@
1
+ {
2
+ "countries": [
3
+ {
4
+ "code": "EU",
5
+ "cpi_file": "cpi/eu.json",
6
+ "currency": "EUR",
7
+ "granularities": [
8
+ "monthly",
9
+ "annual"
10
+ ]
11
+ },
12
+ {
13
+ "code": "JP",
14
+ "cpi_file": "cpi/jp.json",
15
+ "currency": "JPY",
16
+ "granularities": [
17
+ "annual"
18
+ ]
19
+ },
20
+ {
21
+ "code": "UK",
22
+ "cpi_file": "cpi/uk.json",
23
+ "currency": "GBP",
24
+ "granularities": [
25
+ "monthly",
26
+ "annual"
27
+ ]
28
+ },
29
+ {
30
+ "code": "US",
31
+ "cpi_file": "cpi/us.json",
32
+ "currency": "USD",
33
+ "granularities": [
34
+ "monthly",
35
+ "annual"
36
+ ]
37
+ },
38
+ {
39
+ "code": "VN",
40
+ "cpi_file": "cpi/vn.json",
41
+ "currency": "VND",
42
+ "granularities": [
43
+ "monthly",
44
+ "annual"
45
+ ]
46
+ }
47
+ ],
48
+ "fx": {
49
+ "annual_file": "fx/usd/_annual.json",
50
+ "base": "USD",
51
+ "currencies": [
52
+ "EUR",
53
+ "GBP",
54
+ "JPY",
55
+ "VND"
56
+ ],
57
+ "daily_years": [
58
+ 1999,
59
+ 2000,
60
+ 2001,
61
+ 2002,
62
+ 2003,
63
+ 2004,
64
+ 2005,
65
+ 2006,
66
+ 2007,
67
+ 2008,
68
+ 2009,
69
+ 2010,
70
+ 2011,
71
+ 2012,
72
+ 2013,
73
+ 2014,
74
+ 2015,
75
+ 2016,
76
+ 2017,
77
+ 2018,
78
+ 2019,
79
+ 2020,
80
+ 2021,
81
+ 2022,
82
+ 2023,
83
+ 2024,
84
+ 2025,
85
+ 2026
86
+ ]
87
+ },
88
+ "generated_at": "2026-05-11",
89
+ "schema_version": 4
90
+ }
data/lib/timeprice/cli.rb CHANGED
@@ -76,9 +76,9 @@ module Timeprice
76
76
  end
77
77
 
78
78
  desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
79
- method_option :from, type: :string, required: true, desc: "Source date (YYYY or YYYY-MM)"
80
- method_option :to, type: :string, required: true, desc: "Target date (YYYY or YYYY-MM)"
81
- method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN)"
79
+ method_option :from, type: :string, required: true, desc: "Source date (YYYY, YYYY-MM, or YYYY-Qn)"
80
+ method_option :to, type: :string, required: true, desc: "Target date (YYYY, YYYY-MM, or YYYY-Qn)"
81
+ method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN, AU, CA, KR, CN, RU)"
82
82
  def inflation(amount)
83
83
  with_error_handling do
84
84
  result = Timeprice.inflation(
@@ -38,7 +38,7 @@ module Timeprice
38
38
  # accepts a {Point} or a 2-tuple like `["USD", "2010"]` or `["USD", "2010-06"]`
39
39
  # @param to [Timeprice::Point, Array(String, String)] destination point
40
40
  # @return [CompareResult]
41
- # @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
41
+ # @raise [UnsupportedCurrency] if either currency is not in {Supported.currencies}
42
42
  def run(amount:, from:, to:)
43
43
  from_point, to_point, to_country = resolve_points(from, to)
44
44
 
@@ -8,54 +8,100 @@ module Timeprice
8
8
  # resolved. See {Granularity} for the full set of possible tags.
9
9
  CpiPoint = Data.define(:value, :granularity)
10
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.
11
+ # Resolves CPI keys ("YYYY", "YYYY-MM", or "YYYY-Qn") to a CpiPoint against
12
+ # a single country's parsed CPI data hash. Knowing the JSON shape ("monthly"
13
+ # / "quarterly" / "annual" string keys) is isolated here — Inflation just
14
+ # asks for points.
14
15
  class CpiLookup
16
+ QUARTER_RE = /\A(\d{4})-Q([1-4])\z/
17
+
15
18
  def initialize(data)
16
19
  @data = data
17
- @monthly = data["monthly"] || {}
18
- @annual = data["annual"] || {}
20
+ @monthly = data.dig("series", "monthly") || {}
21
+ @quarterly = data.dig("series", "quarterly") || {}
22
+ @annual = data.dig("series", "annual") || {}
19
23
  end
20
24
 
21
- # @param key [String] "YYYY" or "YYYY-MM"
25
+ # @param key [String] "YYYY", "YYYY-MM", or "YYYY-Qn"
22
26
  # @return [CpiPoint]
23
27
  # @raise [DataNotFound] if no CPI value covers `key`
24
28
  # @raise [ArgumentError] on malformed `key`
25
29
  def at(key)
26
30
  key = key.to_s
27
31
  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)"
32
+ when QUARTER_RE then quarterly_or_fallbacks(key)
33
+ when /\A\d{4}-\d{2}\z/ then monthly_or_fallbacks(key)
34
+ when /\A\d{4}\z/ then annual_or_derived(key)
35
+ else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY, YYYY-MM, or YYYY-Qn)"
31
36
  end
32
37
  end
33
38
 
34
39
  private
35
40
 
36
- def monthly_or_annual_fallback(month_key)
41
+ def monthly_or_fallbacks(month_key)
37
42
  return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
38
43
 
39
- year = month_key[0, 4]
40
- raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
44
+ year, month = month_key.split("-").map(&:to_i)
45
+ qkey = format("%04d-Q%d", year, ((month - 1) / 3) + 1)
46
+ if @quarterly.key?(qkey)
47
+ return CpiPoint.new(value: @quarterly[qkey], granularity: Granularity::MONTHLY_FROM_QUARTERLY_FALLBACK)
48
+ end
49
+
50
+ year_key = month_key[0, 4]
51
+ raise DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
41
52
 
42
- CpiPoint.new(value: @annual[year], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
53
+ CpiPoint.new(value: @annual[year_key], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
43
54
  end
44
55
 
45
- def annual_or_monthly_average(year)
56
+ def quarterly_or_fallbacks(quarter_key)
57
+ if @quarterly.key?(quarter_key)
58
+ return CpiPoint.new(value: @quarterly[quarter_key],
59
+ granularity: Granularity::QUARTERLY)
60
+ end
61
+
62
+ year_int, q = quarter_key.match(QUARTER_RE).captures.map(&:to_i)
63
+ first_month = ((q - 1) * 3) + 1
64
+ last_month = q * 3
65
+ months = (first_month..last_month).map { |m| format("%04d-%02d", year_int, m) }
66
+ .map { |k| @monthly[k] }
67
+ .compact
68
+ if months.size == 3
69
+ return CpiPoint.new(value: months.sum.to_f / 3,
70
+ granularity: Granularity::QUARTERLY_FROM_MONTHLY_AVG)
71
+ end
72
+
73
+ year = quarter_key[0, 4]
74
+ raise DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
75
+
76
+ CpiPoint.new(value: @annual[year], granularity: Granularity::QUARTERLY_FROM_ANNUAL_FALLBACK)
77
+ end
78
+
79
+ def annual_or_derived(year)
46
80
  return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
47
81
 
48
- months = @monthly.select { |k, _| k.start_with?("#{year}-") }
49
- raise DataNotFound, missing_message(year) if months.empty?
82
+ months = @monthly.select { |k, _| k.start_with?("#{year}-") }
83
+ quarters = @quarterly.select { |k, _| k.start_with?("#{year}-Q") }
84
+
85
+ # Prefer complete-period averages over partials, and within each, prefer
86
+ # monthly resolution. Partial tags distinguish biased estimates (e.g.
87
+ # only Jan-Feb populated) from a true full-year mean.
88
+ return average(months, 12, Granularity::ANNUAL_FROM_MONTHLY_AVG) if months.size == 12
89
+ return average(quarters, 4, Granularity::ANNUAL_FROM_QUARTERLY_AVG) if quarters.size == 4
90
+ return average(months, months.size, Granularity::ANNUAL_FROM_PARTIAL_MONTHS) if months.any?
91
+ return average(quarters, quarters.size, Granularity::ANNUAL_FROM_PARTIAL_QUARTERS) if quarters.any?
92
+
93
+ raise DataNotFound, missing_message(year)
94
+ end
50
95
 
51
- avg = months.values.sum.to_f / months.size
52
- CpiPoint.new(value: avg, granularity: Granularity::ANNUAL_FROM_MONTHLY_AVG)
96
+ def average(series, divisor, granularity)
97
+ CpiPoint.new(value: series.values.sum.to_f / divisor, granularity: granularity)
53
98
  end
54
99
 
55
100
  def missing_message(key)
56
101
  country = @data["country"]
57
102
  ranges = []
58
103
  ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
104
+ ranges << "quarterly #{@quarterly.keys.min}..#{@quarterly.keys.max}" if @quarterly.any?
59
105
  ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
60
106
  hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
61
107
  "No CPI data for #{key.inspect} in #{country}#{hint}"
@@ -2,19 +2,22 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "errors"
5
- require_relative "supported"
6
5
 
7
6
  module Timeprice
8
7
  # Loads and caches the bundled JSON data files. Override the search root
9
8
  # by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
10
9
  # {DataLoader.data_root=}.
11
10
  module DataLoader
12
- SUPPORTED_SCHEMA_VERSION = 2
11
+ SUPPORTED_SCHEMA_VERSION = 4
12
+
13
+ # Files written by older toolchains remain readable: v3 is monthly+annual
14
+ # only; v4 adds an optional `series.quarterly` block.
15
+ SUPPORTED_SCHEMA_VERSIONS = [3, 4].freeze
13
16
 
14
17
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
15
18
 
16
19
  class << self
17
- # @return [String] absolute path to the directory containing `cpi/` and `fx/`
20
+ # @return [String] absolute path to the directory containing `cpi/`, `fx/`, `manifest.json`.
18
21
  def data_root
19
22
  ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
20
23
  end
@@ -32,19 +35,36 @@ module Timeprice
32
35
  def clear_cache!
33
36
  @cpi_cache = {}
34
37
  @fx_cache = {}
38
+ @manifest_cache = {}
39
+ @annual_fallback_cache = {}
40
+ end
41
+
42
+ # Load the top-level manifest describing the bundled dataset.
43
+ # @return [Hash]
44
+ # @raise [DataNotFound] if `manifest.json` is missing
45
+ def load_manifest
46
+ manifest_cache[data_root] ||= begin
47
+ path = File.join(data_root, "manifest.json")
48
+ unless File.exist?(path)
49
+ raise DataNotFound, "manifest.json missing (looked in #{path}). " \
50
+ "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
51
+ end
52
+
53
+ parse_with_schema(path)
54
+ end
35
55
  end
36
56
 
37
57
  # Load the CPI series for a supported country.
38
58
  # @param country [String]
39
- # @return [Hash] parsed JSON with "monthly" / "annual" / metadata keys
40
- # @raise [UnsupportedCountry] if `country` is not in {Supported::COUNTRIES}
59
+ # @return [Hash] parsed JSON with "series" / "index" / "provenance" / "providers"
60
+ # @raise [UnsupportedCountry] if `country` is not in {Supported.countries}
41
61
  # @raise [DataNotFound] if the file is missing
42
62
  # @raise [UnsupportedSchemaVersion] if the file uses a future schema
43
63
  def load_cpi(country)
44
64
  key = country.to_s.downcase
45
65
  code = country.to_s.upcase
46
66
  cpi_cache[[data_root, key]] ||= begin
47
- raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
67
+ raise UnsupportedCountry, code unless Supported.country?(code)
48
68
 
49
69
  path = File.join(data_root, "cpi", "#{key}.json")
50
70
  unless File.exist?(path)
@@ -58,7 +78,7 @@ module Timeprice
58
78
 
59
79
  # Load the FX rates for a year.
60
80
  # @param year [Integer, String]
61
- # @return [Hash] parsed JSON with a "rates" map of date → currency → Float
81
+ # @return [Hash] parsed JSON with `rates` (and optional `annual`) blocks
62
82
  # @raise [DataNotFound] if the per-year file is missing
63
83
  def load_fx_year(year)
64
84
  key = year.to_i
@@ -70,6 +90,17 @@ module Timeprice
70
90
  end
71
91
  end
72
92
 
93
+ # Load the sparse historical FX annual-only fallback file, if present.
94
+ # Returns nil when no fallback file ships with this data root.
95
+ # @return [Hash, nil]
96
+ def load_fx_annual_fallback
97
+ return @annual_fallback_cache[data_root] if @annual_fallback_cache&.key?(data_root)
98
+
99
+ @annual_fallback_cache ||= {}
100
+ path = File.join(data_root, "fx", "usd", "_annual.json")
101
+ @annual_fallback_cache[data_root] = File.exist?(path) ? parse_with_schema(path) : nil
102
+ end
103
+
73
104
  private
74
105
 
75
106
  def cpi_cache
@@ -80,13 +111,21 @@ module Timeprice
80
111
  @fx_cache ||= {}
81
112
  end
82
113
 
114
+ def manifest_cache
115
+ @manifest_cache ||= {}
116
+ end
117
+
83
118
  def parse_with_schema(path)
84
119
  data = JSON.parse(File.read(path))
85
120
  version = data["schema_version"]
86
- raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
121
+ raise UnsupportedSchemaVersion.new(version, path) unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
87
122
 
88
123
  data
89
124
  end
90
125
  end
91
126
  end
92
127
  end
128
+
129
+ # Supported is loaded by the top-level entry point. Referenced lazily inside
130
+ # load_cpi to avoid a require cycle (Supported reads the manifest via DataLoader).
131
+ require_relative "supported" unless defined?(Timeprice::Supported)
@@ -7,23 +7,23 @@ module Timeprice
7
7
  # to handle anything the gem can throw at you.
8
8
  class Error < StandardError; end
9
9
 
10
- # Raised when a country code is not in {Supported::COUNTRIES}.
10
+ # Raised when a country code is not in {Supported.countries}.
11
11
  class UnsupportedCountry < Error
12
12
  attr_reader :country
13
13
 
14
14
  def initialize(country)
15
15
  @country = country
16
- super("Unsupported country: #{country.inspect} (supported: #{Supported::COUNTRIES.join(", ")})")
16
+ super("Unsupported country: #{country.inspect} (supported: #{Supported.countries.join(", ")})")
17
17
  end
18
18
  end
19
19
 
20
- # Raised when a currency code is not in {Supported::CURRENCIES}.
20
+ # Raised when a currency code is not in {Supported.currencies}.
21
21
  class UnsupportedCurrency < Error
22
22
  attr_reader :currency
23
23
 
24
24
  def initialize(currency)
25
25
  @currency = currency
26
- super("Unsupported currency: #{currency.inspect} (supported: #{Supported::CURRENCIES.join(", ")})")
26
+ super("Unsupported currency: #{currency.inspect} (supported: #{Supported.currencies.join(", ")})")
27
27
  end
28
28
  end
29
29
 
@@ -33,8 +33,8 @@ module Timeprice
33
33
  def convert(amount:, from:, to:, date:)
34
34
  from = from.to_s.upcase
35
35
  to = to.to_s.upcase
36
- raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
37
- raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
36
+ raise UnsupportedCurrency, from unless Supported.currency?(from)
37
+ raise UnsupportedCurrency, to unless Supported.currency?(to)
38
38
 
39
39
  d = parse_date(date)
40
40
 
@@ -82,7 +82,7 @@ module Timeprice
82
82
  end
83
83
 
84
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.
85
+ # back to data/fx/usd/_annual.json (the single source of annual FX truth).
86
86
  # Returns [rate, effective_date, granularity].
87
87
  def lookup_usd_base(currency, d)
88
88
  (0..MAX_FALLBACK_DAYS).each do |offset|
@@ -108,12 +108,12 @@ module Timeprice
108
108
  raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
109
109
  end
110
110
 
111
- # Consult the year file's top-level `annual` block. Returns Float or nil.
111
+ # Consult data/fx/usd/_annual.json. Returns Float or nil.
112
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
113
+ fallback = DataLoader.load_fx_annual_fallback
114
+ return nil unless fallback
115
+
116
+ fallback.dig("annual", year.to_s, currency)&.to_f
117
117
  end
118
118
 
119
119
  def parse_date(date)
@@ -4,20 +4,44 @@ module Timeprice
4
4
  # Closed set of CPI-resolution granularities and the rules for combining /
5
5
  # rendering them. Owns the lattice so callers don't hand-maintain it.
6
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
7
+ DAILY = :daily
8
+ MONTHLY = :monthly
9
+ QUARTERLY = :quarterly
10
+ ANNUAL = :annual
11
+ ANNUAL_FROM_MONTHLY_AVG = :annual_from_monthly_avg
12
+ ANNUAL_FROM_QUARTERLY_AVG = :annual_from_quarterly_avg
13
+ ANNUAL_FROM_PARTIAL_MONTHS = :annual_from_partial_months
14
+ ANNUAL_FROM_PARTIAL_QUARTERS = :annual_from_partial_quarters
15
+ QUARTERLY_FROM_ANNUAL_FALLBACK = :quarterly_from_annual_fallback
16
+ QUARTERLY_FROM_MONTHLY_AVG = :quarterly_from_monthly_avg
17
+ MONTHLY_FROM_QUARTERLY_FALLBACK = :monthly_from_quarterly_fallback
18
+ MONTHLY_FROM_ANNUAL_FALLBACK = :monthly_from_annual_fallback
12
19
 
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.
20
+ # Most-degraded first — `merge` returns the first match. DAILY is the
21
+ # highest-precision FX tag; MONTHLY is the highest-precision CPI tag.
22
+ # Compare uses merge() across both legs, so the most-degraded tag in
23
+ # either leg wins.
24
+ #
25
+ # Ordering rationale (worst → best):
26
+ # 1. Cross-grain fallbacks where the asked resolution is finer than
27
+ # what's available (annual stretched to month/quarter).
28
+ # 2. Partial-period averages — asked annual but only some months/
29
+ # quarters in the year are populated. Highly biased by seasonality.
30
+ # 3. Same-or-coarser fallback (quarter stretched to month).
31
+ # 4. Full-period derived averages (complete 4-quarter or 12-month mean
32
+ # standing in for the asked coarser resolution).
33
+ # 5. Native series at the asked resolution.
17
34
  PRECEDENCE = [
18
35
  MONTHLY_FROM_ANNUAL_FALLBACK,
36
+ ANNUAL_FROM_PARTIAL_QUARTERS,
37
+ ANNUAL_FROM_PARTIAL_MONTHS,
38
+ QUARTERLY_FROM_ANNUAL_FALLBACK,
39
+ MONTHLY_FROM_QUARTERLY_FALLBACK,
40
+ ANNUAL_FROM_QUARTERLY_AVG,
41
+ QUARTERLY_FROM_MONTHLY_AVG,
19
42
  ANNUAL_FROM_MONTHLY_AVG,
20
43
  ANNUAL,
44
+ QUARTERLY,
21
45
  MONTHLY,
22
46
  DAILY,
23
47
  ].freeze
@@ -25,9 +49,16 @@ module Timeprice
25
49
  HUMAN_LABELS = {
26
50
  DAILY => "daily",
27
51
  MONTHLY => "monthly",
52
+ QUARTERLY => "quarterly",
28
53
  ANNUAL => "annual",
29
54
  ANNUAL_FROM_MONTHLY_AVG => "annual (avg of months)",
30
- MONTHLY_FROM_ANNUAL_FALLBACK => "annual (month unavailable)",
55
+ ANNUAL_FROM_QUARTERLY_AVG => "annual (avg of quarters)",
56
+ ANNUAL_FROM_PARTIAL_MONTHS => "annual (partial-year, avg of available months)",
57
+ ANNUAL_FROM_PARTIAL_QUARTERS => "annual (partial-year, avg of available quarters)",
58
+ QUARTERLY_FROM_ANNUAL_FALLBACK => "quarter (annual fallback)",
59
+ QUARTERLY_FROM_MONTHLY_AVG => "quarter (avg of months)",
60
+ MONTHLY_FROM_QUARTERLY_FALLBACK => "month (quarter unavailable)",
61
+ MONTHLY_FROM_ANNUAL_FALLBACK => "month (annual fallback)",
31
62
  }.freeze
32
63
 
33
64
  module_function
@@ -21,18 +21,18 @@ module Timeprice
21
21
  end
22
22
  end
23
23
 
24
- # CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
24
+ # CPI-based inflation adjustment for the {Supported.countries} list.
25
25
  module Inflation
26
26
  module_function
27
27
 
28
28
  # Adjust `amount` from date `from` to date `to` using country CPI.
29
29
  #
30
- # Dates accept "YYYY" or "YYYY-MM".
30
+ # Dates accept "YYYY", "YYYY-MM", or "YYYY-Qn" (Q1..Q4).
31
31
  #
32
32
  # @param amount [Numeric]
33
- # @param from [String] source date ("YYYY" or "YYYY-MM")
34
- # @param to [String] target date ("YYYY" or "YYYY-MM")
35
- # @param country [String] country code (see {Supported::COUNTRIES})
33
+ # @param from [String] source date ("YYYY", "YYYY-MM", or "YYYY-Qn")
34
+ # @param to [String] target date ("YYYY", "YYYY-MM", or "YYYY-Qn")
35
+ # @param country [String] country code (see {Supported.countries})
36
36
  # @return [InflationResult]
37
37
  # @raise [UnsupportedCountry] if `country` is not supported
38
38
  # @raise [DataNotFound] if no CPI data covers the requested period
@@ -5,9 +5,10 @@ require_relative "../data_loader"
5
5
 
6
6
  module Timeprice
7
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.
8
+ # Computes coverage strings for bundled data sources at runtime by reading
9
+ # the structured `provenance` blocks in v3 data files. The Sources
10
+ # attribution registry stays a pure data table; Coverage is the only
11
+ # place that touches the filesystem.
11
12
  module Coverage
12
13
  module_function
13
14
 
@@ -25,46 +26,40 @@ module Timeprice
25
26
 
26
27
  def cpi(country)
27
28
  data = DataLoader.load_cpi(country)
28
- monthly = (data["monthly"] || {}).keys.sort
29
- annual = (data["annual"] || {}).keys.sort
29
+ monthly = data.dig("series", "monthly") || {}
30
+ annual = data.dig("series", "annual") || {}
30
31
  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?
32
+ parts << "monthly #{monthly.keys.min}..#{monthly.keys.max} (#{monthly.size})" if monthly.any?
33
+ parts << "annual #{annual.keys.min}..#{annual.keys.max} (#{annual.size})" if annual.any?
33
34
  parts.join(", ")
34
35
  end
35
36
 
36
37
  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}"
38
+ case id
39
+ when "fx_ecb" then ecb_summary
40
+ when "fx_vnd" then vnd_summary
41
+ else "n/a"
42
+ end
52
43
  end
53
44
 
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?
45
+ # Frankfurter (ECB) → daily EUR/GBP/JPY in per-year files. Range derived
46
+ # from the manifest's `daily_years` list.
47
+ def ecb_summary
48
+ years = DataLoader.load_manifest.dig("fx", "daily_years") || []
49
+ return "no ECB data" if years.empty?
57
50
 
58
- "USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
51
+ "USD↔EUR/GBP/JPY daily #{years.first}..#{years.last}"
59
52
  end
60
53
 
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
54
+ # All annual FX (today only VND) lives in data/fx/usd/_annual.json.
55
+ def vnd_summary
56
+ fallback = DataLoader.load_fx_annual_fallback
57
+ years = (fallback&.dig("annual") || {})
58
+ .select { |_y, ccy_hash| ccy_hash.key?("VND") }
59
+ .keys.map(&:to_i).sort
60
+ return "no VND data" if years.empty?
65
61
 
66
- def fx_root
67
- File.join(DataLoader.data_root, "fx", "usd")
62
+ "USD↔VND #{years.first}..#{years.last}"
68
63
  end
69
64
  end
70
65
  end