timeprice 0.6.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +33 -2
  4. data/data/cpi/au.json +419 -0
  5. data/data/cpi/ca.json +1501 -0
  6. data/data/cpi/cn.json +487 -0
  7. data/data/cpi/eu.json +1 -1
  8. data/data/cpi/jp.json +1 -1
  9. data/data/cpi/kr.json +549 -0
  10. data/data/cpi/ru.json +487 -0
  11. data/data/cpi/uk.json +1 -1
  12. data/data/cpi/us.json +1 -1
  13. data/data/cpi/vn.json +26 -26
  14. data/data/fx/usd/1999.json +1042 -262
  15. data/data/fx/usd/2000.json +1274 -258
  16. data/data/fx/usd/2001.json +1277 -257
  17. data/data/fx/usd/2002.json +1282 -258
  18. data/data/fx/usd/2003.json +1282 -258
  19. data/data/fx/usd/2004.json +1302 -262
  20. data/data/fx/usd/2005.json +1292 -260
  21. data/data/fx/usd/2006.json +1282 -258
  22. data/data/fx/usd/2007.json +1282 -258
  23. data/data/fx/usd/2008.json +1287 -259
  24. data/data/fx/usd/2009.json +1287 -259
  25. data/data/fx/usd/2010.json +1297 -261
  26. data/data/fx/usd/2011.json +1292 -260
  27. data/data/fx/usd/2012.json +1287 -259
  28. data/data/fx/usd/2013.json +1282 -258
  29. data/data/fx/usd/2014.json +1282 -258
  30. data/data/fx/usd/2015.json +1287 -259
  31. data/data/fx/usd/2016.json +1292 -260
  32. data/data/fx/usd/2017.json +1282 -258
  33. data/data/fx/usd/2018.json +1282 -258
  34. data/data/fx/usd/2019.json +1282 -258
  35. data/data/fx/usd/2020.json +1292 -260
  36. data/data/fx/usd/2021.json +1297 -261
  37. data/data/fx/usd/2022.json +1292 -260
  38. data/data/fx/usd/2023.json +1282 -258
  39. data/data/fx/usd/2024.json +1287 -259
  40. data/data/fx/usd/2025.json +1282 -258
  41. data/data/fx/usd/2026.json +457 -92
  42. data/data/fx/usd/_annual.json +46 -1
  43. data/data/manifest.json +155 -7
  44. data/lib/timeprice/cli.rb +3 -3
  45. data/lib/timeprice/compare.rb +36 -3
  46. data/lib/timeprice/cpi_lookup.rb +4 -4
  47. data/lib/timeprice/data_loader.rb +8 -17
  48. data/lib/timeprice/date.rb +62 -0
  49. data/lib/timeprice/exchange.rb +49 -23
  50. data/lib/timeprice/inflation.rb +12 -4
  51. data/lib/timeprice/metadata.rb +121 -0
  52. data/lib/timeprice/metadata_snapshot.rb +23 -0
  53. data/lib/timeprice/point.rb +11 -3
  54. data/lib/timeprice/schema.rb +78 -0
  55. data/lib/timeprice/version.rb +1 -1
  56. data/lib/timeprice.rb +14 -1
  57. 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,14 +161,29 @@
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
189
  "schema_version": 4
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",
236
+ "generated_at": "2026-05-12",
89
237
  "schema_version": 4
90
238
  }
data/lib/timeprice/cli.rb CHANGED
@@ -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
@@ -32,7 +32,7 @@ module Timeprice
32
32
  when QUARTER_RE then quarterly_or_fallbacks(key)
33
33
  when /\A\d{4}-\d{2}\z/ then monthly_or_fallbacks(key)
34
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)"
35
+ else fail ArgumentError, "Invalid date format: #{key.inspect} (use YYYY, YYYY-MM, or YYYY-Qn)"
36
36
  end
37
37
  end
38
38
 
@@ -48,7 +48,7 @@ module Timeprice
48
48
  end
49
49
 
50
50
  year_key = month_key[0, 4]
51
- raise DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
51
+ fail DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
52
52
 
53
53
  CpiPoint.new(value: @annual[year_key], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
54
54
  end
@@ -71,7 +71,7 @@ module Timeprice
71
71
  end
72
72
 
73
73
  year = quarter_key[0, 4]
74
- raise DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
74
+ fail DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
75
75
 
76
76
  CpiPoint.new(value: @annual[year], granularity: Granularity::QUARTERLY_FROM_ANNUAL_FALLBACK)
77
77
  end
@@ -90,7 +90,7 @@ module Timeprice
90
90
  return average(months, months.size, Granularity::ANNUAL_FROM_PARTIAL_MONTHS) if months.any?
91
91
  return average(quarters, quarters.size, Granularity::ANNUAL_FROM_PARTIAL_QUARTERS) if quarters.any?
92
92
 
93
- raise DataNotFound, missing_message(year)
93
+ fail DataNotFound, missing_message(year)
94
94
  end
95
95
 
96
96
  def average(series, divisor, granularity)
@@ -2,18 +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 = 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
16
-
17
12
  DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
18
13
 
19
14
  class << self
@@ -46,8 +41,8 @@ module Timeprice
46
41
  manifest_cache[data_root] ||= begin
47
42
  path = File.join(data_root, "manifest.json")
48
43
  unless File.exist?(path)
49
- raise DataNotFound, "manifest.json missing (looked in #{path}). " \
50
- "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."
51
46
  end
52
47
 
53
48
  parse_with_schema(path)
@@ -64,12 +59,12 @@ module Timeprice
64
59
  key = country.to_s.downcase
65
60
  code = country.to_s.upcase
66
61
  cpi_cache[[data_root, key]] ||= begin
67
- raise UnsupportedCountry, code unless Supported.country?(code)
62
+ fail UnsupportedCountry, code unless Supported.country?(code)
68
63
 
69
64
  path = File.join(data_root, "cpi", "#{key}.json")
70
65
  unless File.exist?(path)
71
- raise DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
72
- "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."
73
68
  end
74
69
 
75
70
  parse_with_schema(path)
@@ -84,7 +79,7 @@ module Timeprice
84
79
  key = year.to_i
85
80
  fx_cache[[data_root, key]] ||= begin
86
81
  path = File.join(data_root, "fx", "usd", "#{key}.json")
87
- 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)
88
83
 
89
84
  parse_with_schema(path)
90
85
  end
@@ -116,11 +111,7 @@ module Timeprice
116
111
  end
117
112
 
118
113
  def parse_with_schema(path)
119
- data = JSON.parse(File.read(path))
120
- version = data["schema_version"]
121
- raise UnsupportedSchemaVersion.new(version, path) unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
122
-
123
- data
114
+ Schema.load_cpi(JSON.parse(File.read(path)), path: path)
124
115
  end
125
116
  end
126
117
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Timeprice
6
+ # Raised when a user-supplied date string can't be parsed into a
7
+ # {Timeprice::Date} value.
8
+ class InvalidDate < Error; end
9
+
10
+ # Immutable value object representing "a date at some granularity": a
11
+ # year, a year+month, a year+quarter, or a full calendar day. Used as
12
+ # the canonical input shape for the public API (`Timeprice.inflation`,
13
+ # `Timeprice.exchange`, `Timeprice.compare`) — strings are accepted for
14
+ # convenience and coerced via {.coerce} at the boundary.
15
+ # rubocop:disable Lint/ConstantDefinitionInBlock
16
+ Date = Data.define(:year, :month, :quarter, :day) do
17
+ ANNUAL_RE = /\A(\d{4})\z/
18
+ MONTHLY_RE = /\A(\d{4})-(\d{2})\z/
19
+ QUARTERLY_RE = /\A(\d{4})-Q([1-4])\z/i
20
+ DAILY_RE = /\A(\d{4})-(\d{2})-(\d{2})\z/
21
+
22
+ def self.parse(str)
23
+ case str.to_s
24
+ when DAILY_RE
25
+ new(year: ::Regexp.last_match(1).to_i, month: ::Regexp.last_match(2).to_i,
26
+ quarter: nil, day: ::Regexp.last_match(3).to_i)
27
+ when QUARTERLY_RE
28
+ new(year: ::Regexp.last_match(1).to_i, month: nil,
29
+ quarter: ::Regexp.last_match(2).to_i, day: nil)
30
+ when MONTHLY_RE
31
+ new(year: ::Regexp.last_match(1).to_i, month: ::Regexp.last_match(2).to_i,
32
+ quarter: nil, day: nil)
33
+ when ANNUAL_RE
34
+ new(year: ::Regexp.last_match(1).to_i, month: nil, quarter: nil, day: nil)
35
+ else
36
+ fail InvalidDate, "Cannot parse #{str.inspect} as a Timeprice::Date"
37
+ end
38
+ end
39
+
40
+ def self.coerce(input)
41
+ input.is_a?(self) ? input : parse(input)
42
+ end
43
+
44
+ def granularity
45
+ return :daily if day
46
+ return :monthly if month
47
+ return :quarterly if quarter
48
+
49
+ :annual
50
+ end
51
+
52
+ def to_s
53
+ case granularity
54
+ when :daily then format("%04d-%02d-%02d", year, month, day)
55
+ when :monthly then format("%04d-%02d", year, month)
56
+ when :quarterly then format("%04d-Q%d", year, quarter)
57
+ else format("%04d", year)
58
+ end
59
+ end
60
+ end
61
+ # rubocop:enable Lint/ConstantDefinitionInBlock
62
+ end