timeprice 0.6.0 → 0.8.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -0
  3. data/README.md +73 -2
  4. data/data/cpi/au.json +419 -0
  5. data/data/cpi/br.json +529 -0
  6. data/data/cpi/ca.json +1501 -0
  7. data/data/cpi/ch.json +549 -0
  8. data/data/cpi/cn.json +487 -0
  9. data/data/cpi/cz.json +500 -0
  10. data/data/cpi/eu.json +1 -1
  11. data/data/cpi/hk.json +351 -0
  12. data/data/cpi/hu.json +537 -0
  13. data/data/cpi/id.json +550 -0
  14. data/data/cpi/il.json +549 -0
  15. data/data/cpi/in.json +549 -0
  16. data/data/cpi/jp.json +2 -2
  17. data/data/cpi/kr.json +550 -0
  18. data/data/cpi/mx.json +550 -0
  19. data/data/cpi/my.json +429 -0
  20. data/data/cpi/no.json +549 -0
  21. data/data/cpi/nz.json +94 -0
  22. data/data/cpi/ph.json +309 -0
  23. data/data/cpi/pl.json +539 -0
  24. data/data/cpi/ru.json +487 -0
  25. data/data/cpi/se.json +549 -0
  26. data/data/cpi/sg.json +369 -0
  27. data/data/cpi/th.json +309 -0
  28. data/data/cpi/tr.json +549 -0
  29. data/data/cpi/uk.json +1 -1
  30. data/data/cpi/us.json +1007 -5
  31. data/data/cpi/vn.json +32 -32
  32. data/data/cpi/za.json +549 -0
  33. data/data/fx/usd/1999.json +5982 -262
  34. data/data/fx/usd/2000.json +6999 -258
  35. data/data/fx/usd/2001.json +7142 -257
  36. data/data/fx/usd/2002.json +7170 -258
  37. data/data/fx/usd/2003.json +7170 -258
  38. data/data/fx/usd/2004.json +7282 -262
  39. data/data/fx/usd/2005.json +7226 -260
  40. data/data/fx/usd/2006.json +7170 -258
  41. data/data/fx/usd/2007.json +7169 -258
  42. data/data/fx/usd/2008.json +7184 -259
  43. data/data/fx/usd/2009.json +6941 -259
  44. data/data/fx/usd/2010.json +6995 -261
  45. data/data/fx/usd/2011.json +6968 -260
  46. data/data/fx/usd/2012.json +6941 -259
  47. data/data/fx/usd/2013.json +6914 -258
  48. data/data/fx/usd/2014.json +6914 -258
  49. data/data/fx/usd/2015.json +6941 -259
  50. data/data/fx/usd/2016.json +6968 -260
  51. data/data/fx/usd/2017.json +6914 -258
  52. data/data/fx/usd/2018.json +7148 -258
  53. data/data/fx/usd/2019.json +7170 -258
  54. data/data/fx/usd/2020.json +7226 -260
  55. data/data/fx/usd/2021.json +7254 -261
  56. data/data/fx/usd/2022.json +7226 -260
  57. data/data/fx/usd/2023.json +7170 -258
  58. data/data/fx/usd/2024.json +7198 -259
  59. data/data/fx/usd/2025.json +7170 -258
  60. data/data/fx/usd/2026.json +2561 -92
  61. data/data/fx/usd/_annual.json +46 -1
  62. data/data/manifest.json +565 -2
  63. data/lib/timeprice/cli/presenters/compare.rb +44 -8
  64. data/lib/timeprice/cli.rb +24 -7
  65. data/lib/timeprice/compare/series.rb +120 -0
  66. data/lib/timeprice/compare.rb +108 -15
  67. data/lib/timeprice/cpi_lookup.rb +14 -8
  68. data/lib/timeprice/data_loader.rb +8 -17
  69. data/lib/timeprice/date.rb +62 -0
  70. data/lib/timeprice/exchange.rb +49 -23
  71. data/lib/timeprice/forecast/cagr.rb +96 -0
  72. data/lib/timeprice/forecast/cpi_forecaster.rb +90 -0
  73. data/lib/timeprice/forecast/fx_forecaster.rb +173 -0
  74. data/lib/timeprice/forecast.rb +21 -0
  75. data/lib/timeprice/inflation.rb +12 -4
  76. data/lib/timeprice/metadata.rb +121 -0
  77. data/lib/timeprice/metadata_snapshot.rb +23 -0
  78. data/lib/timeprice/point.rb +11 -3
  79. data/lib/timeprice/schema.rb +78 -0
  80. data/lib/timeprice/sources.rb +1 -1
  81. data/lib/timeprice/supported.rb +16 -0
  82. data/lib/timeprice/version.rb +1 -1
  83. data/lib/timeprice.rb +48 -3
  84. metadata +49 -2
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Timeprice
6
+ module Forecast
7
+ # Pure math: trailing CAGR and σ of year-over-year changes.
8
+ #
9
+ # The series is a hash mapping date strings (`"YYYY"` or `"YYYY-MM"`) to
10
+ # numeric values. The trailing window is anchored on `last_date` and
11
+ # extends `window_years` backward. CAGR is the annualized geometric
12
+ # return between the first and last samples in the window. Sigma is the
13
+ # sample stdev of 1-year-spaced returns within the window.
14
+ #
15
+ # No I/O, no DataLoader. Pure function — call from anywhere.
16
+ #
17
+ # @api private
18
+ module Cagr
19
+ module_function
20
+
21
+ # @param series [Hash{String => Numeric}]
22
+ # @param last_date [String] anchor ("YYYY" or "YYYY-MM")
23
+ # @param window_years [Integer]
24
+ # @return [Hash] { cagr: Float, sigma_yoy: Float, window_start: String,
25
+ # window_end: String, samples: Integer }
26
+ def compute(series:, last_date:, window_years:)
27
+ end_date = parse(last_date)
28
+ start_date = shift_years(end_date, -window_years)
29
+
30
+ sorted = series
31
+ .select { |k, _| within?(k, start_date, end_date) }
32
+ .sort_by { |k, _| parse(k) }
33
+
34
+ fail ArgumentError, "need at least 2 points in window" if sorted.size < 2
35
+
36
+ cagr = annualised_return(sorted)
37
+
38
+ {
39
+ cagr: cagr,
40
+ sigma_yoy: stdev_of_yoy(sorted),
41
+ window_start: sorted.first.first,
42
+ window_end: sorted.last.first,
43
+ samples: sorted.size,
44
+ }
45
+ end
46
+
47
+ def annualised_return(sorted)
48
+ first_v = sorted.first.last.to_f
49
+ last_v = sorted.last.last.to_f
50
+
51
+ fail ArgumentError, "first window value must be positive (got #{first_v})" unless first_v.positive?
52
+ fail ArgumentError, "last window value must be positive (got #{last_v})" unless last_v.positive?
53
+
54
+ years_elapsed = (parse(sorted.last.first) - parse(sorted.first.first)) / 365.2425
55
+ fail ArgumentError, "window has zero elapsed time" unless years_elapsed.positive?
56
+
57
+ ((last_v / first_v)**(1.0 / years_elapsed)) - 1.0
58
+ end
59
+
60
+ def parse(s)
61
+ s = s.to_s
62
+ return ::Date.new(s.to_i, 1, 1) if s.length == 4
63
+
64
+ y, m = s.split("-").map(&:to_i)
65
+ ::Date.new(y, m, 1)
66
+ end
67
+
68
+ def shift_years(date, years)
69
+ ::Date.new(date.year + years, date.month, 1)
70
+ end
71
+
72
+ def within?(key, start_date, end_date)
73
+ d = parse(key)
74
+ d.between?(start_date, end_date)
75
+ end
76
+
77
+ # Stdev of simple (arithmetic) 1-year-spaced returns within the window.
78
+ # Returns 0.0 when fewer than 2 paired samples exist.
79
+ def stdev_of_yoy(sorted)
80
+ by_date = sorted.to_h
81
+ returns = sorted.filter_map do |key, value|
82
+ prior_key = shift_years(parse(key), -1).strftime(key.length == 4 ? "%Y" : "%Y-%m")
83
+ prior = by_date[prior_key]
84
+ next unless prior&.positive?
85
+
86
+ (value.to_f / prior) - 1.0
87
+ end
88
+ return 0.0 if returns.size < 2
89
+
90
+ mean = returns.sum / returns.size
91
+ variance = returns.sum { |r| (r - mean)**2 } / (returns.size - 1)
92
+ Math.sqrt(variance)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../forecast"
4
+ require_relative "../data_loader"
5
+ require_relative "../errors"
6
+ require_relative "cagr"
7
+
8
+ module Timeprice
9
+ module Forecast
10
+ # Project a country's CPI index forward from the last bundled data point.
11
+ #
12
+ # @api private
13
+ module CpiForecaster
14
+ module_function
15
+
16
+ DEFAULT_WINDOW_YEARS = 10
17
+ HORIZON_CAP_YEARS = 5
18
+
19
+ # @param country [String]
20
+ # @param target [String] "YYYY" or "YYYY-MM"
21
+ # @param window_years [Integer]
22
+ # @return [Forecast::Result]
23
+ # @raise [DataNotFound] if the CPI series has no usable monthly or annual data
24
+ def project(country:, target:, window_years: DEFAULT_WINDOW_YEARS)
25
+ series = load_series(country)
26
+ last_key, last_value = last_entry(series)
27
+ horizon_months = months_between(last_key, target)
28
+ warnings = build_warnings(series, last_key, window_years, horizon_months)
29
+ stats = Cagr.compute(series: series, last_date: last_key, window_years: window_years)
30
+ build_result(last_key: last_key, last_value: last_value, target: target,
31
+ horizon_months: horizon_months, window_years: window_years,
32
+ stats: stats, warnings: warnings)
33
+ end
34
+
35
+ # Prefer monthly when present; fall back to annual.
36
+ def pick_series(data)
37
+ monthly = data.dig("series", "monthly") || {}
38
+ return monthly unless monthly.empty?
39
+
40
+ data.dig("series", "annual") || {}
41
+ end
42
+
43
+ def months_between(from_key, to_key)
44
+ f = Cagr.parse(from_key)
45
+ t = Cagr.parse(to_key)
46
+ ((t.year - f.year) * 12) + (t.month - f.month)
47
+ end
48
+
49
+ def load_series(country)
50
+ data = DataLoader.load_cpi(country.to_s.upcase)
51
+ series = pick_series(data)
52
+ fail DataNotFound, "no CPI series for #{country}" if series.empty?
53
+
54
+ series
55
+ end
56
+
57
+ def last_entry(series)
58
+ last_key = series.keys.max_by { |k| Cagr.parse(k) }
59
+ [last_key, series[last_key].to_f]
60
+ end
61
+
62
+ def build_warnings(series, last_key, window_years, horizon_months)
63
+ warnings = []
64
+ earliest = series.keys.map { |k| Cagr.parse(k).year }.min
65
+ warnings << "insufficient_window" if Cagr.parse(last_key).year - window_years < earliest
66
+ warnings << "horizon_exceeds_cap" if horizon_months > HORIZON_CAP_YEARS * 12
67
+ warnings.uniq
68
+ end
69
+
70
+ def build_result(last_key:, last_value:, target:, horizon_months:, window_years:, stats:, warnings:)
71
+ years_forward = horizon_months / 12.0
72
+ value = last_value * ((1.0 + stats[:cagr])**years_forward)
73
+ low = last_value * ((1.0 + stats[:cagr] - stats[:sigma_yoy])**years_forward)
74
+ high = last_value * ((1.0 + stats[:cagr] + stats[:sigma_yoy])**years_forward)
75
+
76
+ Forecast::Result.new(
77
+ value: value, low: low, high: high,
78
+ projection_method: "cagr_trailing",
79
+ window_years: window_years,
80
+ sigma_pct: stats[:sigma_yoy],
81
+ last_known_date: last_key,
82
+ target_date: target,
83
+ horizon_months: horizon_months,
84
+ basis_kind: :cpi,
85
+ warnings: warnings.uniq
86
+ )
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../forecast"
4
+ require_relative "../data_loader"
5
+ require_relative "../errors"
6
+ require_relative "cagr"
7
+
8
+ module Timeprice
9
+ module Forecast
10
+ # Project a currency-pair FX rate forward using the same trailing-CAGR
11
+ # mechanism as CpiForecaster, but with FX-appropriate defaults: 5-year
12
+ # window, 2-year horizon cap.
13
+ #
14
+ # The annual series is reconstructed by reading each bundled year file
15
+ # and pulling the year-end (or annual-average where present) rate. The
16
+ # daily granularity that {Exchange} works with is too noisy to anchor a
17
+ # multi-year extrapolation — annualizing first is the whole point.
18
+ #
19
+ # @api private
20
+ module FxForecaster
21
+ module_function
22
+
23
+ DEFAULT_WINDOW_YEARS = 5
24
+ HORIZON_CAP_YEARS = 2
25
+
26
+ def project(from:, to:, target:, window_years: DEFAULT_WINDOW_YEARS)
27
+ from = from.to_s.upcase
28
+ to = to.to_s.upcase
29
+
30
+ series = load_annual_series(from, to)
31
+ fail DataNotFound, "no FX series for #{from}->#{to}" if series.empty?
32
+
33
+ last_key = series.keys.max_by { |k| Cagr.parse(k) }
34
+ last_value = series[last_key].to_f
35
+ horizon_months = months_between(last_key, target)
36
+ earliest_year = series.keys.map { |k| Cagr.parse(k).year }.min
37
+ warnings = []
38
+ warnings << "insufficient_window" if Cagr.parse(last_key).year - window_years < earliest_year
39
+ warnings << "horizon_exceeds_cap" if horizon_months > HORIZON_CAP_YEARS * 12
40
+
41
+ stats = Cagr.compute(series: series, last_date: last_key, window_years: window_years)
42
+
43
+ build_result(
44
+ last_key: last_key, last_value: last_value,
45
+ target: target, horizon_months: horizon_months,
46
+ window_years: window_years, stats: stats, warnings: warnings
47
+ )
48
+ end
49
+
50
+ # Build an annual {year_string => rate} series by reading each FX year
51
+ # file and computing a daily average for the requested currency pair.
52
+ # Falls back to the _annual.json file for years where the daily file
53
+ # does not cover the requested pair. Returns {} if no data is found.
54
+ def load_annual_series(from, to)
55
+ root = File.join(DataLoader.data_root, "fx", "usd")
56
+ return {} unless File.directory?(root)
57
+
58
+ fallback = DataLoader.load_fx_annual_fallback
59
+ years = Dir.children(root).filter_map do |f|
60
+ Regexp.last_match(1).to_i if f =~ /\A(\d{4})\.json\z/
61
+ end.sort
62
+
63
+ years.each_with_object({}) do |year, acc|
64
+ rate = pick_year_rate(DataLoader.load_fx_year(year), from, to)
65
+ rate ||= pick_annual_fallback_rate(fallback, year, from, to) if fallback
66
+ acc[year.to_s] = rate if rate
67
+ end
68
+ end
69
+
70
+ # Compute an annual average rate for +from+->+to+ from a year-file
71
+ # payload. Year-file rates are stored as USD->currency daily rates.
72
+ #
73
+ # from == "USD" => average rates[date][to] across all dates
74
+ # to == "USD" => average rates[date][from], then invert
75
+ # else (cross) => per-date to/from ratio, then average
76
+ def pick_year_rate(payload, from, to)
77
+ daily = payload["rates"]
78
+ return nil unless daily.is_a?(Hash) && !daily.empty?
79
+
80
+ if from == "USD"
81
+ daily_avg(daily.values, to)
82
+ elsif to == "USD"
83
+ invert_avg(daily.values, from)
84
+ else
85
+ cross_avg(daily.values, from, to)
86
+ end
87
+ end
88
+
89
+ # Extract an annual rate for +from+->+to+ from the _annual.json fallback.
90
+ # Fallback stores USD->currency annual averages keyed by year string.
91
+ def pick_annual_fallback_rate(fallback, year, from, to)
92
+ ann = fallback.dig("annual", year.to_s)
93
+ return nil unless ann.is_a?(Hash)
94
+
95
+ if from == "USD"
96
+ ann[to]&.to_f
97
+ elsif to == "USD"
98
+ invert_scalar(ann[from]&.to_f)
99
+ else
100
+ cross_scalar(ann[from]&.to_f, ann[to]&.to_f)
101
+ end
102
+ end
103
+
104
+ def months_between(from_key, to_key)
105
+ f = Cagr.parse(from_key)
106
+ t = Cagr.parse(to_key)
107
+ ((t.year - f.year) * 12) + (t.month - f.month)
108
+ end
109
+
110
+ def build_result(last_key:, last_value:, target:, horizon_months:, window_years:, stats:, warnings:)
111
+ years_forward = horizon_months / 12.0
112
+ value = last_value * ((1.0 + stats[:cagr])**years_forward)
113
+ low = last_value * ((1.0 + stats[:cagr] - stats[:sigma_yoy])**years_forward)
114
+ high = last_value * ((1.0 + stats[:cagr] + stats[:sigma_yoy])**years_forward)
115
+
116
+ Forecast::Result.new(
117
+ value: value, low: low, high: high,
118
+ projection_method: "cagr_trailing",
119
+ window_years: window_years,
120
+ sigma_pct: stats[:sigma_yoy],
121
+ last_known_date: last_key,
122
+ target_date: target,
123
+ horizon_months: horizon_months,
124
+ basis_kind: :fx,
125
+ warnings: warnings.uniq
126
+ )
127
+ end
128
+
129
+ # --- private helpers ---
130
+
131
+ # Average USD->+currency+ values across all day_rates hashes.
132
+ def daily_avg(day_rates_list, currency)
133
+ vals = day_rates_list.filter_map { |dr| dr[currency]&.to_f }
134
+ return nil if vals.empty?
135
+
136
+ vals.sum / vals.size
137
+ end
138
+
139
+ # Average USD->+currency+ then invert to get currency->USD.
140
+ def invert_avg(day_rates_list, currency)
141
+ mean = daily_avg(day_rates_list, currency)
142
+ invert_scalar(mean)
143
+ end
144
+
145
+ # Average the per-day cross rate (USD->to / USD->from) for dates where
146
+ # both currencies are present.
147
+ def cross_avg(day_rates_list, from, to)
148
+ pairs = day_rates_list.filter_map do |dr|
149
+ usd_from = dr[from]&.to_f
150
+ usd_to = dr[to]&.to_f
151
+ cross_scalar(usd_from, usd_to)
152
+ end
153
+ return nil if pairs.empty?
154
+
155
+ pairs.sum / pairs.size
156
+ end
157
+
158
+ # Invert a single USD->X scalar to X->USD; nil if zero or nil.
159
+ def invert_scalar(val)
160
+ return nil unless val&.positive?
161
+
162
+ 1.0 / val
163
+ end
164
+
165
+ # Compute cross rate from two USD->X scalars; nil if either is nil/zero.
166
+ def cross_scalar(usd_from, usd_to)
167
+ return nil unless usd_from&.positive? && usd_to
168
+
169
+ usd_to / usd_from
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timeprice
4
+ # Forward-projection of CPI / FX series past the last bundled data point.
5
+ #
6
+ # Method: trailing-window CAGR for the point estimate, ±1σ of trailing
7
+ # year-over-year changes for the band. Pure-Ruby, deterministic, no
8
+ # network calls or stats dependencies.
9
+ #
10
+ # All results are explicitly tagged so callers never confuse them with
11
+ # measured data. The {Forecast::Result#warnings} array surfaces horizon-cap
12
+ # violations and insufficient-window conditions.
13
+ module Forecast
14
+ Result = Data.define(
15
+ :value, :low, :high,
16
+ :projection_method, :window_years, :sigma_pct,
17
+ :last_known_date, :target_date, :horizon_months,
18
+ :basis_kind, :warnings
19
+ )
20
+ end
21
+ end
@@ -4,6 +4,7 @@ require_relative "errors"
4
4
  require_relative "data_loader"
5
5
  require_relative "cpi_lookup"
6
6
  require_relative "granularity"
7
+ require_relative "date"
7
8
 
8
9
  module Timeprice
9
10
  # Value object returned by Inflation.adjust. See {Granularity} for the set
@@ -22,6 +23,11 @@ module Timeprice
22
23
  end
23
24
 
24
25
  # CPI-based inflation adjustment for the {Supported.countries} list.
26
+ #
27
+ # @api private
28
+ # The supported public entry point is {Timeprice.inflation}. Direct
29
+ # references to this module will move to `Timeprice::Internal::Inflation`
30
+ # in a future release.
25
31
  module Inflation
26
32
  module_function
27
33
 
@@ -37,16 +43,18 @@ module Timeprice
37
43
  # @raise [UnsupportedCountry] if `country` is not supported
38
44
  # @raise [DataNotFound] if no CPI data covers the requested period
39
45
  def adjust(amount:, from:, to:, country:)
46
+ from = Timeprice::Date.coerce(from)
47
+ to = Timeprice::Date.coerce(to)
40
48
  lookup = CpiLookup.new(DataLoader.load_cpi(country))
41
- from_point = lookup.at(from)
42
- to_point = lookup.at(to)
49
+ from_point = lookup.at(from.to_s)
50
+ to_point = lookup.at(to.to_s)
43
51
 
44
52
  ratio = to_point.value.to_f / from_point.value
45
53
  InflationResult.new(
46
54
  amount: amount.to_f * ratio,
47
55
  original_amount: amount.to_f,
48
- from: from,
49
- to: to,
56
+ from: from.to_s,
57
+ to: to.to_s,
50
58
  country: country.to_s.upcase,
51
59
  from_index: from_point.value,
52
60
  to_index: to_point.value,
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "data_loader"
4
+ require_relative "supported"
5
+ require_relative "version"
6
+ require_relative "metadata_snapshot"
7
+
8
+ module Timeprice
9
+ # Describes the bundled dataset so external surfaces (the website, other
10
+ # tools) can render dropdowns, date pickers, and version pills without
11
+ # hardcoding country lists, currency lists, or date ranges.
12
+ #
13
+ # See {Timeprice.metadata} for the public entry point.
14
+ #
15
+ # @api private
16
+ # Direct references will move to `Timeprice::Internal::Metadata` in a
17
+ # future release.
18
+ module Metadata
19
+ # ISO 3166-style display names for the countries shipped today.
20
+ COUNTRY_NAMES = {
21
+ "AU" => "Australia",
22
+ "CA" => "Canada",
23
+ "CN" => "China",
24
+ "EU" => "Eurozone",
25
+ "JP" => "Japan",
26
+ "KR" => "South Korea",
27
+ "RU" => "Russia",
28
+ "UK" => "United Kingdom",
29
+ "US" => "United States",
30
+ "VN" => "Vietnam",
31
+ }.freeze
32
+
33
+ # ISO 4217 display names for the currencies shipped today.
34
+ CURRENCY_NAMES = {
35
+ "AUD" => "Australian dollar",
36
+ "CAD" => "Canadian dollar",
37
+ "CNY" => "Chinese yuan",
38
+ "EUR" => "Euro",
39
+ "GBP" => "British pound",
40
+ "JPY" => "Japanese yen",
41
+ "KRW" => "South Korean won",
42
+ "RUB" => "Russian ruble",
43
+ "USD" => "US dollar",
44
+ "VND" => "Vietnamese dong",
45
+ }.freeze
46
+
47
+ module_function
48
+
49
+ # Build the metadata snapshot.
50
+ # @return [MetadataSnapshot]
51
+ def build
52
+ manifest = DataLoader.load_manifest
53
+ countries = (manifest["countries"] || []).map { |c| country_entry(c) }
54
+ currencies = Supported.currencies.map { |code| { code: code, name: CURRENCY_NAMES[code] || code } }
55
+ MetadataSnapshot.new(
56
+ version: VERSION,
57
+ generated_at: manifest["generated_at"],
58
+ countries: deep_freeze(countries),
59
+ currencies: deep_freeze(currencies),
60
+ fx: deep_freeze(fx_entry(manifest))
61
+ )
62
+ end
63
+
64
+ # Range info comes from the manifest (`cpi_ranges`), pre-computed at
65
+ # manifest generation time. Falls back to walking the CPI file for any
66
+ # country missing the field — older manifests, or local data roots
67
+ # produced by hand.
68
+ def country_entry(country)
69
+ code = country["code"]
70
+ ranges = country["cpi_ranges"] || derive_cpi_ranges(code)
71
+ per_granularity = ranges.each_with_object({}) do |(gran, range), acc|
72
+ acc[gran.to_sym] = { min: range["min"], max: range["max"] }
73
+ end
74
+ {
75
+ code: code,
76
+ name: COUNTRY_NAMES[code] || code,
77
+ currency: country["currency"],
78
+ granularities: country["granularities"] || per_granularity.keys.map(&:to_s),
79
+ cpi: per_granularity,
80
+ }
81
+ end
82
+
83
+ def derive_cpi_ranges(code)
84
+ cpi = DataLoader.load_cpi(code)
85
+ series = cpi["series"] || {}
86
+ series.each_with_object({}) do |(granularity, points), acc|
87
+ next unless points.is_a?(Hash) && !points.empty?
88
+
89
+ keys = points.keys.sort
90
+ acc[granularity] = { "min" => keys.first, "max" => keys.last }
91
+ end
92
+ end
93
+
94
+ # Bounds come from the manifest (`fx.daily_min`/`fx.daily_max`). Older
95
+ # manifests without those keys: peek at the earliest/latest year files.
96
+ def fx_entry(manifest)
97
+ fx = manifest["fx"] || {}
98
+ base = fx["base"]
99
+ years = fx["daily_years"] || []
100
+ return { base: base, daily_min: nil, daily_max: nil } if years.empty?
101
+
102
+ daily_min = fx["daily_min"]
103
+ daily_max = fx["daily_max"]
104
+ if daily_min.nil? || daily_max.nil?
105
+ first = DataLoader.load_fx_year(years.min)
106
+ last = DataLoader.load_fx_year(years.max)
107
+ daily_min ||= (first["rates"] || {}).keys.min
108
+ daily_max ||= (last["rates"] || {}).keys.max
109
+ end
110
+ { base: base, daily_min: daily_min, daily_max: daily_max }
111
+ end
112
+
113
+ def deep_freeze(value)
114
+ case value
115
+ when Hash then value.each_value { |v| deep_freeze(v) }.freeze
116
+ when Array then value.each { |v| deep_freeze(v) }.freeze
117
+ else value.frozen? ? value : value.freeze
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Timeprice
6
+ # Frozen value object describing the bundled dataset: version, refresh
7
+ # date, country list with CPI ranges, currency list with display names,
8
+ # and FX coverage. Replaces the previous Hash return shape on
9
+ # {Timeprice.metadata}.
10
+ #
11
+ # `[]`, `to_h`, and `to_json` are kept compatible with the old Hash
12
+ # interface so downstream consumers (the website, this gem's specs)
13
+ # don't need a coordinated rewrite.
14
+ MetadataSnapshot = Data.define(:version, :generated_at, :countries, :currencies, :fx) do
15
+ def [](key)
16
+ to_h[key]
17
+ end
18
+
19
+ def to_json(*args)
20
+ to_h.to_json(*args)
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "date"
4
+
3
5
  module Timeprice
4
6
  # A (currency, date) pair used as input to {Timeprice.compare}.
5
7
  #
@@ -13,6 +15,12 @@ module Timeprice
13
15
  # Timeprice::Point.coerce(["USD", "2010"])
14
16
  # Timeprice::Point.coerce(["2010", "USD"])
15
17
  Point = Data.define(:currency, :date) do
18
+ # Canonical constructor. Accepts a stdlib-string or Timeprice::Date
19
+ # for the date argument; stores the canonical string form.
20
+ def self.parse(currency, date)
21
+ new(currency: currency.to_s.upcase, date: Timeprice::Date.coerce(date).to_s)
22
+ end
23
+
16
24
  # Coerce input into a Point. Accepts:
17
25
  # - {Point} (returned as-is)
18
26
  # - 2-element Array of [currency, date] in either order
@@ -28,11 +36,11 @@ module Timeprice
28
36
  a, b = input.map(&:to_s)
29
37
  currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
30
38
  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?
39
+ fail ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
32
40
 
33
41
  new(currency: currency.upcase, date: date)
34
42
  else
35
- raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
43
+ fail ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
36
44
  end
37
45
  end
38
46
 
@@ -55,7 +63,7 @@ module Timeprice
55
63
  when /\A\d{4}\z/ then "#{date}-06-30"
56
64
  when /\A\d{4}-\d{2}\z/ then "#{date}-15"
57
65
  when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
58
- else raise ArgumentError, "Invalid date for Point: #{date.inspect}"
66
+ else fail ArgumentError, "Invalid date for Point: #{date.inspect}"
59
67
  end
60
68
  end
61
69
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Timeprice
6
+ # Single source of truth for the on-disk v4 CPI/manifest format. Both the
7
+ # reader ({DataLoader}) and the writer (today: pipeline `CountryFile`)
8
+ # route through here so the schema lives in exactly one place.
9
+ module Schema
10
+ CURRENT_VERSION = 4
11
+ SUPPORTED_VERSIONS = [3, 4].freeze
12
+
13
+ KEY_SCHEMA_VERSION = "schema_version"
14
+ KEY_COUNTRY = "country"
15
+ KEY_INDEX = "index"
16
+ KEY_SERIES = "series"
17
+ KEY_PROVENANCE = "provenance"
18
+ KEY_PROVIDERS = "providers"
19
+
20
+ GRANULARITIES = %i[monthly quarterly annual].freeze
21
+
22
+ BASE_YEAR_RE = /\A(?<period>.+?)=100(?:\s*\(rebased\s+(?<rebased>\d{4}-\d{2}-\d{2})\))?\z/
23
+
24
+ module_function
25
+
26
+ def supported?(version)
27
+ SUPPORTED_VERSIONS.include?(version)
28
+ end
29
+
30
+ def assert_supported!(version, path)
31
+ return if supported?(version)
32
+
33
+ fail UnsupportedSchemaVersion.new(version, path)
34
+ end
35
+
36
+ # Build a CPI payload ready for JSON.dump. Series keys are emitted in a
37
+ # stable order (annual, monthly[, quarterly]) so file diffs stay tight.
38
+ def dump_cpi(country:, base_year:, monthly:, annual:, providers:, provenance:, quarterly: {})
39
+ series = { "annual" => annual, "monthly" => monthly }
40
+ series["quarterly"] = quarterly unless quarterly.empty?
41
+ {
42
+ KEY_SCHEMA_VERSION => CURRENT_VERSION,
43
+ KEY_COUNTRY => country.to_s.upcase,
44
+ KEY_INDEX => serialise_base_year(base_year),
45
+ KEY_SERIES => series,
46
+ KEY_PROVENANCE => provenance,
47
+ KEY_PROVIDERS => providers,
48
+ }
49
+ end
50
+
51
+ # Validate a parsed payload (read from disk) against the schema, then
52
+ # return it unchanged. Raises UnsupportedSchemaVersion if the version
53
+ # field is missing or unknown.
54
+ def load_cpi(parsed, path:)
55
+ assert_supported!(parsed[KEY_SCHEMA_VERSION], path)
56
+ parsed
57
+ end
58
+
59
+ def serialise_base_year(str)
60
+ m = BASE_YEAR_RE.match(str.to_s)
61
+ if m
62
+ { "base_period" => m[:period], "rebased_at" => m[:rebased] }
63
+ else
64
+ { "base_period" => str.to_s, "rebased_at" => nil }
65
+ end
66
+ end
67
+
68
+ def deserialise_base_year(index)
69
+ return nil unless index.is_a?(Hash)
70
+
71
+ period = index["base_period"]
72
+ rebased = index["rebased_at"]
73
+ return nil if period.nil?
74
+
75
+ rebased ? "#{period}=100 (rebased #{rebased})" : "#{period}=100"
76
+ end
77
+ end
78
+ end