timeprice 0.5.0 → 0.7.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/DATA_LICENSES.md +16 -1
  4. data/README.md +46 -7
  5. data/data/cpi/au.json +419 -0
  6. data/data/cpi/ca.json +1501 -0
  7. data/data/cpi/cn.json +487 -0
  8. data/data/cpi/eu.json +2 -2
  9. data/data/cpi/jp.json +2 -2
  10. data/data/cpi/kr.json +549 -0
  11. data/data/cpi/ru.json +487 -0
  12. data/data/cpi/uk.json +2 -2
  13. data/data/cpi/us.json +2 -2
  14. data/data/cpi/vn.json +27 -27
  15. data/data/fx/usd/1999.json +1043 -263
  16. data/data/fx/usd/2000.json +1275 -259
  17. data/data/fx/usd/2001.json +1278 -258
  18. data/data/fx/usd/2002.json +1283 -259
  19. data/data/fx/usd/2003.json +1283 -259
  20. data/data/fx/usd/2004.json +1303 -263
  21. data/data/fx/usd/2005.json +1293 -261
  22. data/data/fx/usd/2006.json +1283 -259
  23. data/data/fx/usd/2007.json +1283 -259
  24. data/data/fx/usd/2008.json +1288 -260
  25. data/data/fx/usd/2009.json +1288 -260
  26. data/data/fx/usd/2010.json +1298 -262
  27. data/data/fx/usd/2011.json +1293 -261
  28. data/data/fx/usd/2012.json +1288 -260
  29. data/data/fx/usd/2013.json +1283 -259
  30. data/data/fx/usd/2014.json +1283 -259
  31. data/data/fx/usd/2015.json +1288 -260
  32. data/data/fx/usd/2016.json +1293 -261
  33. data/data/fx/usd/2017.json +1283 -259
  34. data/data/fx/usd/2018.json +1283 -259
  35. data/data/fx/usd/2019.json +1283 -259
  36. data/data/fx/usd/2020.json +1293 -261
  37. data/data/fx/usd/2021.json +1298 -262
  38. data/data/fx/usd/2022.json +1293 -261
  39. data/data/fx/usd/2023.json +1283 -259
  40. data/data/fx/usd/2024.json +1288 -260
  41. data/data/fx/usd/2025.json +1283 -259
  42. data/data/fx/usd/2026.json +458 -93
  43. data/data/fx/usd/_annual.json +47 -2
  44. data/data/manifest.json +156 -8
  45. data/lib/timeprice/cli.rb +6 -6
  46. data/lib/timeprice/compare.rb +36 -3
  47. data/lib/timeprice/cpi_lookup.rb +64 -18
  48. data/lib/timeprice/data_loader.rb +8 -13
  49. data/lib/timeprice/date.rb +62 -0
  50. data/lib/timeprice/exchange.rb +49 -23
  51. data/lib/timeprice/granularity.rb +41 -10
  52. data/lib/timeprice/inflation.rb +15 -7
  53. data/lib/timeprice/metadata.rb +121 -0
  54. data/lib/timeprice/metadata_snapshot.rb +23 -0
  55. data/lib/timeprice/point.rb +11 -3
  56. data/lib/timeprice/schema.rb +78 -0
  57. data/lib/timeprice/supported.rb +1 -1
  58. data/lib/timeprice/version.rb +1 -1
  59. data/lib/timeprice.rb +14 -1
  60. metadata +24 -1
@@ -25,6 +25,7 @@
25
25
  "VND": 11202.19
26
26
  },
27
27
  "1993": {
28
+ "RUB": 0.9917,
28
29
  "VND": 10640.96
29
30
  },
30
31
  "1994": {
@@ -34,90 +35,119 @@
34
35
  "VND": 11038.25
35
36
  },
36
37
  "1996": {
38
+ "RUB": 5.1208,
37
39
  "VND": 11032.58
38
40
  },
39
41
  "1997": {
42
+ "RUB": 5.7848,
40
43
  "VND": 11683.33
41
44
  },
42
45
  "1998": {
46
+ "RUB": 9.7051,
43
47
  "VND": 13268.0
44
48
  },
45
49
  "1999": {
50
+ "RUB": 24.6199,
46
51
  "VND": 13943.17
47
52
  },
48
53
  "2000": {
54
+ "RUB": 28.1292,
49
55
  "VND": 14167.75
50
56
  },
51
57
  "2001": {
58
+ "RUB": 29.1685,
52
59
  "VND": 14725.17
53
60
  },
54
61
  "2002": {
62
+ "RUB": 31.3485,
55
63
  "VND": 15279.5
56
64
  },
57
65
  "2003": {
66
+ "RUB": 30.692,
58
67
  "VND": 15509.58
59
68
  },
60
69
  "2004": {
70
+ "RUB": 28.8137,
61
71
  "VND": 15746.0
62
72
  },
63
73
  "2005": {
74
+ "RUB": 28.2844,
64
75
  "VND": 15858.92
65
76
  },
66
77
  "2006": {
78
+ "RUB": 27.191,
67
79
  "VND": 15994.25
68
80
  },
69
81
  "2007": {
82
+ "RUB": 25.5808,
70
83
  "VND": 16105.13
71
84
  },
72
85
  "2008": {
86
+ "RUB": 24.8529,
73
87
  "VND": 16302.25
74
88
  },
75
89
  "2009": {
90
+ "RUB": 31.7404,
76
91
  "VND": 17065.08
77
92
  },
78
93
  "2010": {
94
+ "RUB": 30.3679,
79
95
  "VND": 18612.92
80
96
  },
81
97
  "2011": {
98
+ "RUB": 29.3823,
82
99
  "VND": 20509.75
83
100
  },
84
101
  "2012": {
102
+ "RUB": 30.8398,
85
103
  "VND": 20828.0
86
104
  },
87
105
  "2013": {
106
+ "RUB": 31.8371,
88
107
  "VND": 20933.42
89
108
  },
90
109
  "2014": {
110
+ "RUB": 38.3782,
91
111
  "VND": 21148.0
92
112
  },
93
113
  "2015": {
114
+ "RUB": 60.9377,
94
115
  "VND": 21697.57
95
116
  },
96
117
  "2016": {
118
+ "RUB": 67.0559,
97
119
  "VND": 21935.0
98
120
  },
99
121
  "2017": {
122
+ "RUB": 58.3428,
100
123
  "VND": 22370.09
101
124
  },
102
125
  "2018": {
126
+ "RUB": 62.6681,
103
127
  "VND": 22602.05
104
128
  },
105
129
  "2019": {
130
+ "RUB": 64.7377,
106
131
  "VND": 23050.24
107
132
  },
108
133
  "2020": {
134
+ "RUB": 72.1049,
109
135
  "VND": 23208.37
110
136
  },
111
137
  "2021": {
138
+ "RUB": 73.6544,
112
139
  "VND": 23159.78
113
140
  },
114
141
  "2022": {
142
+ "RUB": 68.4849,
115
143
  "VND": 23271.21
116
144
  },
117
145
  "2023": {
146
+ "RUB": 85.162,
118
147
  "VND": 23787.32
119
148
  },
120
149
  "2024": {
150
+ "RUB": 92.5524,
121
151
  "VND": 24164.89
122
152
  }
123
153
  },
@@ -131,15 +161,30 @@
131
161
  "provider": "world_bank",
132
162
  "series": "annual",
133
163
  "to": "2024"
164
+ },
165
+ {
166
+ "currencies": [
167
+ "RUB"
168
+ ],
169
+ "from": "1993",
170
+ "provider": "imf",
171
+ "series": "annual",
172
+ "to": "2024"
134
173
  }
135
174
  ],
136
175
  "providers": [
137
176
  {
138
- "fetched_at": "2026-05-11",
177
+ "fetched_at": "2026-05-12",
139
178
  "id": "world_bank",
140
179
  "label": "World Bank PA.NUS.FCRF",
141
180
  "status": "ok"
181
+ },
182
+ {
183
+ "fetched_at": "2026-05-12",
184
+ "id": "imf",
185
+ "label": "IMF ER dataflow XDC_USD/PA_RT (period-average, annual mean)",
186
+ "status": "ok"
142
187
  }
143
188
  ],
144
- "schema_version": 3
189
+ "schema_version": 4
145
190
  }
data/data/manifest.json CHANGED
@@ -1,5 +1,62 @@
1
1
  {
2
2
  "countries": [
3
+ {
4
+ "code": "AU",
5
+ "cpi_file": "cpi/au.json",
6
+ "currency": "AUD",
7
+ "granularities": [
8
+ "quarterly",
9
+ "annual"
10
+ ],
11
+ "cpi_ranges": {
12
+ "annual": {
13
+ "min": "1960",
14
+ "max": "2024"
15
+ },
16
+ "quarterly": {
17
+ "min": "1948-Q3",
18
+ "max": "2026-Q1"
19
+ }
20
+ }
21
+ },
22
+ {
23
+ "code": "CA",
24
+ "cpi_file": "cpi/ca.json",
25
+ "currency": "CAD",
26
+ "granularities": [
27
+ "monthly",
28
+ "annual"
29
+ ],
30
+ "cpi_ranges": {
31
+ "annual": {
32
+ "min": "1914",
33
+ "max": "2025"
34
+ },
35
+ "monthly": {
36
+ "min": "1914-01",
37
+ "max": "2026-03"
38
+ }
39
+ }
40
+ },
41
+ {
42
+ "code": "CN",
43
+ "cpi_file": "cpi/cn.json",
44
+ "currency": "CNY",
45
+ "granularities": [
46
+ "monthly",
47
+ "annual"
48
+ ],
49
+ "cpi_ranges": {
50
+ "annual": {
51
+ "min": "1986",
52
+ "max": "2025"
53
+ },
54
+ "monthly": {
55
+ "min": "1993-01",
56
+ "max": "2026-03"
57
+ }
58
+ }
59
+ },
3
60
  {
4
61
  "code": "EU",
5
62
  "cpi_file": "cpi/eu.json",
@@ -7,7 +64,17 @@
7
64
  "granularities": [
8
65
  "monthly",
9
66
  "annual"
10
- ]
67
+ ],
68
+ "cpi_ranges": {
69
+ "annual": {
70
+ "min": "1996",
71
+ "max": "2025"
72
+ },
73
+ "monthly": {
74
+ "min": "1996-01",
75
+ "max": "2025-12"
76
+ }
77
+ }
11
78
  },
12
79
  {
13
80
  "code": "JP",
@@ -15,7 +82,51 @@
15
82
  "currency": "JPY",
16
83
  "granularities": [
17
84
  "annual"
18
- ]
85
+ ],
86
+ "cpi_ranges": {
87
+ "annual": {
88
+ "min": "1960",
89
+ "max": "2024"
90
+ }
91
+ }
92
+ },
93
+ {
94
+ "code": "KR",
95
+ "cpi_file": "cpi/kr.json",
96
+ "currency": "KRW",
97
+ "granularities": [
98
+ "monthly",
99
+ "annual"
100
+ ],
101
+ "cpi_ranges": {
102
+ "annual": {
103
+ "min": "1960",
104
+ "max": "2025"
105
+ },
106
+ "monthly": {
107
+ "min": "1990-01",
108
+ "max": "2026-03"
109
+ }
110
+ }
111
+ },
112
+ {
113
+ "code": "RU",
114
+ "cpi_file": "cpi/ru.json",
115
+ "currency": "RUB",
116
+ "granularities": [
117
+ "monthly",
118
+ "annual"
119
+ ],
120
+ "cpi_ranges": {
121
+ "annual": {
122
+ "min": "1992",
123
+ "max": "2025"
124
+ },
125
+ "monthly": {
126
+ "min": "1992-01",
127
+ "max": "2026-03"
128
+ }
129
+ }
19
130
  },
20
131
  {
21
132
  "code": "UK",
@@ -24,7 +135,17 @@
24
135
  "granularities": [
25
136
  "monthly",
26
137
  "annual"
27
- ]
138
+ ],
139
+ "cpi_ranges": {
140
+ "annual": {
141
+ "min": "1988",
142
+ "max": "2025"
143
+ },
144
+ "monthly": {
145
+ "min": "1988-01",
146
+ "max": "2026-03"
147
+ }
148
+ }
28
149
  },
29
150
  {
30
151
  "code": "US",
@@ -33,7 +154,17 @@
33
154
  "granularities": [
34
155
  "monthly",
35
156
  "annual"
36
- ]
157
+ ],
158
+ "cpi_ranges": {
159
+ "annual": {
160
+ "min": "1990",
161
+ "max": "2024"
162
+ },
163
+ "monthly": {
164
+ "min": "1990-01",
165
+ "max": "2026-03"
166
+ }
167
+ }
37
168
  },
38
169
  {
39
170
  "code": "VN",
@@ -42,16 +173,31 @@
42
173
  "granularities": [
43
174
  "monthly",
44
175
  "annual"
45
- ]
176
+ ],
177
+ "cpi_ranges": {
178
+ "annual": {
179
+ "min": "1995",
180
+ "max": "2025"
181
+ },
182
+ "monthly": {
183
+ "min": "2001-12",
184
+ "max": "2026-03"
185
+ }
186
+ }
46
187
  }
47
188
  ],
48
189
  "fx": {
49
190
  "annual_file": "fx/usd/_annual.json",
50
191
  "base": "USD",
51
192
  "currencies": [
193
+ "AUD",
194
+ "CAD",
195
+ "CNY",
52
196
  "EUR",
53
197
  "GBP",
54
198
  "JPY",
199
+ "KRW",
200
+ "RUB",
55
201
  "VND"
56
202
  ],
57
203
  "daily_years": [
@@ -83,8 +229,10 @@
83
229
  2024,
84
230
  2025,
85
231
  2026
86
- ]
232
+ ],
233
+ "daily_min": "1999-01-04",
234
+ "daily_max": "2026-05-11"
87
235
  },
88
- "generated_at": "2026-05-11",
89
- "schema_version": 3
236
+ "generated_at": "2026-05-12",
237
+ "schema_version": 4
90
238
  }
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(
@@ -160,12 +160,12 @@ module Timeprice
160
160
  end
161
161
 
162
162
  def parse_compare_token(token, label:)
163
- raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
163
+ fail ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
164
164
 
165
165
  parts = token.strip.split(/\s+/)
166
166
  unless parts.size == 2
167
- raise ArgumentError,
168
- "#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
167
+ fail ArgumentError,
168
+ "#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
169
169
  end
170
170
 
171
171
  Point.coerce(parts)
@@ -28,6 +28,11 @@ module Timeprice
28
28
  #
29
29
  # If a future refactor flips the order, the regression test in
30
30
  # spec/timeprice/compare_spec.rb will fail.
31
+ #
32
+ # @api private
33
+ # The supported public entry point is {Timeprice.compare}. Direct
34
+ # references will move to `Timeprice::Internal::Compare` in a future
35
+ # release.
31
36
  module Compare
32
37
  module_function
33
38
 
@@ -52,7 +57,17 @@ module Timeprice
52
57
  converted = fx_result.amount
53
58
 
54
59
  # Step 2: inflate that destination-currency amount from source date to
55
- # destination date using destination-country CPI.
60
+ # destination date using destination-country CPI. When both points
61
+ # share a date there's no time-elapsed inflation to apply — short-
62
+ # circuit with a ratio of 1.0 so daily-grain FX dates (which CPI's
63
+ # monthly-max resolution can't accept) still resolve cleanly.
64
+ if from_point.date == to_point.date
65
+ return fx_only_result(
66
+ amount: amount, from_point: from_point, to_point: to_point,
67
+ to_country: to_country, fx_result: fx_result
68
+ )
69
+ end
70
+
56
71
  infl = Inflation.adjust(
57
72
  amount: converted,
58
73
  from: from_point.date.to_s,
@@ -75,14 +90,32 @@ module Timeprice
75
90
  )
76
91
  end
77
92
 
93
+ # Same-date branch: no time-elapsed inflation, so the FX leg alone is
94
+ # the answer. Builds a CompareResult with cpi_ratio=1.0.
95
+ def fx_only_result(amount:, from_point:, to_point:, to_country:, fx_result:)
96
+ CompareResult.new(
97
+ amount: fx_result.amount,
98
+ original_amount: amount.to_f,
99
+ from_currency: from_point.currency,
100
+ from_date: from_point.date.to_s,
101
+ to_currency: to_point.currency,
102
+ to_date: to_point.date.to_s,
103
+ country: to_country,
104
+ fx_rate: fx_result.rate,
105
+ cpi_ratio: 1.0,
106
+ converted_amount: fx_result.amount,
107
+ granularity: fx_result.granularity
108
+ )
109
+ end
110
+
78
111
  # Coerce both points and resolve to_country.
79
112
  def resolve_points(from, to)
80
113
  from_point = Point.coerce(from)
81
114
  to_point = Point.coerce(to)
82
- raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
115
+ fail UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
83
116
 
84
117
  to_country = Supported.country_for_currency(to_point.currency)
85
- raise UnsupportedCurrency, to_point.currency unless to_country
118
+ fail UnsupportedCurrency, to_point.currency unless to_country
86
119
 
87
120
  [from_point, to_point, to_country]
88
121
  end
@@ -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.dig("series", "monthly") || {}
18
- @annual = data.dig("series", "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 fail 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
+ fail 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
+ fail 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
+ fail 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,14 +2,13 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "errors"
5
+ require_relative "schema"
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 = 3
12
-
13
12
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
14
13
 
15
14
  class << self
@@ -42,8 +41,8 @@ module Timeprice
42
41
  manifest_cache[data_root] ||= begin
43
42
  path = File.join(data_root, "manifest.json")
44
43
  unless File.exist?(path)
45
- raise DataNotFound, "manifest.json missing (looked in #{path}). " \
46
- "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
44
+ fail DataNotFound, "manifest.json missing (looked in #{path}). " \
45
+ "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
47
46
  end
48
47
 
49
48
  parse_with_schema(path)
@@ -60,12 +59,12 @@ module Timeprice
60
59
  key = country.to_s.downcase
61
60
  code = country.to_s.upcase
62
61
  cpi_cache[[data_root, key]] ||= begin
63
- raise UnsupportedCountry, code unless Supported.country?(code)
62
+ fail UnsupportedCountry, code unless Supported.country?(code)
64
63
 
65
64
  path = File.join(data_root, "cpi", "#{key}.json")
66
65
  unless File.exist?(path)
67
- raise DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
68
- "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
66
+ fail DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
67
+ "Check TIMEPRICE_DATA_ROOT or reinstall the gem."
69
68
  end
70
69
 
71
70
  parse_with_schema(path)
@@ -80,7 +79,7 @@ module Timeprice
80
79
  key = year.to_i
81
80
  fx_cache[[data_root, key]] ||= begin
82
81
  path = File.join(data_root, "fx", "usd", "#{key}.json")
83
- raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
82
+ fail DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
84
83
 
85
84
  parse_with_schema(path)
86
85
  end
@@ -112,11 +111,7 @@ module Timeprice
112
111
  end
113
112
 
114
113
  def parse_with_schema(path)
115
- data = JSON.parse(File.read(path))
116
- version = data["schema_version"]
117
- raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
118
-
119
- data
114
+ Schema.load_cpi(JSON.parse(File.read(path)), path: path)
120
115
  end
121
116
  end
122
117
  end