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
data/lib/timeprice/cli.rb CHANGED
@@ -1,6 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thor"
3
+ begin
4
+ require "thor"
5
+ rescue LoadError
6
+ warn <<~MSG
7
+ The `timeprice` CLI requires the `thor` gem, which isn't installed in
8
+ this environment. Install it with:
9
+
10
+ gem install thor
11
+
12
+ Library use (`require "timeprice"`) does not need thor; it's a CLI-only
13
+ dependency as of v0.8.0.
14
+ MSG
15
+ exit 1
16
+ end
17
+
4
18
  require "json"
5
19
  require_relative "../timeprice"
6
20
  require_relative "cli/presenters/inflation"
@@ -106,8 +120,10 @@ module Timeprice
106
120
  end
107
121
 
108
122
  desc "compare AMOUNT", "Combine FX + inflation across two (year, currency) points"
109
- method_option :from, type: :string, required: true, desc: "Source as \"YEAR CURRENCY\" or \"CURRENCY YEAR\""
110
- method_option :to, type: :string, required: true, desc: "Target as \"YEAR CURRENCY\" or \"CURRENCY YEAR\""
123
+ method_option :from, type: :string, required: true, desc: "Source as \"YEAR CURRENCY\" or \"CURRENCY YEAR\""
124
+ method_option :to, type: :string, required: true, desc: "Target as \"YEAR CURRENCY\" or \"CURRENCY YEAR\""
125
+ method_option :forecast, type: :boolean, default: false,
126
+ desc: "Allow target dates past bundled data via trailing-CAGR forecast"
111
127
  def compare(amount)
112
128
  with_error_handling do
113
129
  from_tuple = parse_compare_token(options[:from], label: "--from")
@@ -115,7 +131,8 @@ module Timeprice
115
131
  result = Timeprice.compare(
116
132
  amount: parse_amount(amount),
117
133
  from: from_tuple,
118
- to: to_tuple
134
+ to: to_tuple,
135
+ forecast: options[:forecast]
119
136
  )
120
137
  render Presenters::Compare.new(result)
121
138
  end
@@ -160,12 +177,12 @@ module Timeprice
160
177
  end
161
178
 
162
179
  def parse_compare_token(token, label:)
163
- raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
180
+ fail ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
164
181
 
165
182
  parts = token.strip.split(/\s+/)
166
183
  unless parts.size == 2
167
- raise ArgumentError,
168
- "#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
184
+ fail ArgumentError,
185
+ "#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
169
186
  end
170
187
 
171
188
  Point.coerce(parts)
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../inflation"
4
+ require_relative "../exchange"
5
+ require_relative "../forecast/cpi_forecaster"
6
+ require_relative "../forecast/cagr"
7
+ require_relative "../cpi_lookup"
8
+ require_relative "../data_loader"
9
+ require_relative "../point"
10
+ require_relative "../supported"
11
+
12
+ module Timeprice
13
+ module Compare
14
+ # Annual sample points for the result-card chart. Composes the same FX
15
+ # leg as {Compare.run} and a year-by-year measured-or-forecast CPI ratio
16
+ # for the destination country.
17
+ #
18
+ # Each point is `{ date: "YYYY-01", amount:, measured: }`. Forecast
19
+ # points additionally carry `:low` and `:high` for the ±1σ band.
20
+ #
21
+ # @api private
22
+ module Series
23
+ module_function
24
+
25
+ DEFAULT_AMOUNT = 100.0
26
+
27
+ def for(from:, to:, forecast: false, amount: DEFAULT_AMOUNT)
28
+ ctx = build_context(from: from, to: to, amount: amount, forecast: forecast)
29
+ (ctx[:from_year]..ctx[:to_year]).map { |y| point_for(y, ctx) }
30
+ end
31
+
32
+ def build_context(from:, to:, amount:, forecast:)
33
+ from_point, to_point, to_country = coerce_points(from, to)
34
+ data = DataLoader.load_cpi(to_country)
35
+ last_key, last_cpi = last_known(data)
36
+ last_year = Forecast::Cagr.parse(last_key).year
37
+
38
+ {
39
+ source_in_dest: source_amount_in_dest(amount, from_point, to_point),
40
+ source_cpi: CpiLookup.new(data).at(from_point.date.to_s).value.to_f,
41
+ lookup: CpiLookup.new(data),
42
+ last_year: last_year,
43
+ last_cpi: last_cpi,
44
+ from_year: Forecast::Cagr.parse(from_point.date.to_s).year,
45
+ to_year: Forecast::Cagr.parse(to_point.date.to_s).year,
46
+ stats: forecast_stats(data, last_key, forecast, to_point, last_year),
47
+ }
48
+ end
49
+
50
+ def coerce_points(from, to)
51
+ from_point = Point.coerce(from)
52
+ to_point = Point.coerce(to)
53
+ to_country = Supported.country_for_currency(to_point.currency)
54
+ fail UnsupportedCurrency, to_point.currency unless to_country
55
+
56
+ [from_point, to_point, to_country]
57
+ end
58
+
59
+ def source_amount_in_dest(amount, from_point, to_point)
60
+ Exchange.convert(
61
+ amount: amount, from: from_point.currency,
62
+ to: to_point.currency, date: from_point.fx_anchor_date
63
+ ).amount
64
+ end
65
+
66
+ def last_known(data)
67
+ annual_or_monthly = Forecast::CpiForecaster.pick_series(data)
68
+ last_key = annual_or_monthly.keys.max_by { |k| Forecast::Cagr.parse(k) }
69
+ [last_key, annual_or_monthly[last_key].to_f]
70
+ end
71
+
72
+ def forecast_stats(data, last_key, forecast, to_point, last_year)
73
+ return nil unless forecast && Forecast::Cagr.parse(to_point.date.to_s).year > last_year
74
+
75
+ Forecast::Cagr.compute(
76
+ series: Forecast::CpiForecaster.pick_series(data),
77
+ last_date: last_key,
78
+ window_years: Forecast::CpiForecaster::DEFAULT_WINDOW_YEARS
79
+ )
80
+ end
81
+
82
+ def point_for(year, ctx)
83
+ if year <= ctx[:last_year]
84
+ measured_point(y: year, lookup: ctx[:lookup],
85
+ source_in_dest: ctx[:source_in_dest], source_cpi: ctx[:source_cpi])
86
+ else
87
+ forecast_point(y: year, last_year: ctx[:last_year], last_cpi: ctx[:last_cpi],
88
+ source_in_dest: ctx[:source_in_dest], source_cpi: ctx[:source_cpi],
89
+ stats: ctx[:stats])
90
+ end
91
+ end
92
+
93
+ def measured_point(y:, lookup:, source_in_dest:, source_cpi:)
94
+ cpi_y = lookup.at(y.to_s).value.to_f
95
+ { date: "#{y}-01", amount: source_in_dest * (cpi_y / source_cpi), measured: true }
96
+ rescue DataNotFound
97
+ nil
98
+ end
99
+
100
+ def forecast_point(y:, last_year:, last_cpi:, source_in_dest:, source_cpi:, stats:)
101
+ yrs = y - last_year
102
+ mid = last_cpi * ((1.0 + stats[:cagr])**yrs)
103
+ low = last_cpi * ((1.0 + stats[:cagr] - stats[:sigma_yoy])**yrs)
104
+ high = last_cpi * ((1.0 + stats[:cagr] + stats[:sigma_yoy])**yrs)
105
+ {
106
+ date: "#{y}-01",
107
+ amount: source_in_dest * (mid / source_cpi),
108
+ low: source_in_dest * (low / source_cpi),
109
+ high: source_in_dest * (high / source_cpi),
110
+ measured: false,
111
+ }
112
+ end
113
+ end
114
+
115
+ # @see Series.for
116
+ def self.series_for(**)
117
+ Series.for(**).compact
118
+ end
119
+ end
120
+ end
@@ -6,6 +6,8 @@ require_relative "point"
6
6
  require_relative "inflation"
7
7
  require_relative "exchange"
8
8
  require_relative "granularity"
9
+ require_relative "cpi_lookup"
10
+ require_relative "compare/series"
9
11
 
10
12
  module Timeprice
11
13
  CompareResult = Data.define(
@@ -13,7 +15,8 @@ module Timeprice
13
15
  :from_currency, :from_date,
14
16
  :to_currency, :to_date,
15
17
  :country, :fx_rate, :cpi_ratio,
16
- :converted_amount, :granularity
18
+ :converted_amount, :granularity,
19
+ :forecast
17
20
  )
18
21
 
19
22
  # Compare combines FX and inflation across two (currency, date) points.
@@ -28,6 +31,11 @@ module Timeprice
28
31
  #
29
32
  # If a future refactor flips the order, the regression test in
30
33
  # spec/timeprice/compare_spec.rb will fail.
34
+ #
35
+ # @api private
36
+ # The supported public entry point is {Timeprice.compare}. Direct
37
+ # references will move to `Timeprice::Internal::Compare` in a future
38
+ # release.
31
39
  module Compare
32
40
  module_function
33
41
 
@@ -39,22 +47,36 @@ module Timeprice
39
47
  # @param to [Timeprice::Point, Array(String, String)] destination point
40
48
  # @return [CompareResult]
41
49
  # @raise [UnsupportedCurrency] if either currency is not in {Supported.currencies}
42
- def run(amount:, from:, to:)
50
+ def run(amount:, from:, to:, forecast: false)
43
51
  from_point, to_point, to_country = resolve_points(from, to)
44
52
 
45
- # Step 1: convert at source date into destination currency.
53
+ if forecast && future_target?(to_point, to_country)
54
+ return run_with_forecast(
55
+ amount: amount, from_point: from_point, to_point: to_point, to_country: to_country
56
+ )
57
+ end
58
+
46
59
  fx_result = Exchange.convert(
47
- amount: amount,
48
- from: from_point.currency,
49
- to: to_point.currency,
50
- date: from_point.fx_anchor_date
60
+ amount: amount, from: from_point.currency,
61
+ to: to_point.currency, date: from_point.fx_anchor_date
51
62
  )
52
- converted = fx_result.amount
53
63
 
54
- # Step 2: inflate that destination-currency amount from source date to
55
- # destination date using destination-country CPI.
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
+
71
+ measured_result(
72
+ amount: amount, from_point: from_point, to_point: to_point,
73
+ to_country: to_country, fx_result: fx_result
74
+ )
75
+ end
76
+
77
+ def measured_result(amount:, from_point:, to_point:, to_country:, fx_result:)
56
78
  infl = Inflation.adjust(
57
- amount: converted,
79
+ amount: fx_result.amount,
58
80
  from: from_point.date.to_s,
59
81
  to: to_point.date.to_s,
60
82
  country: to_country
@@ -70,8 +92,28 @@ module Timeprice
70
92
  country: to_country,
71
93
  fx_rate: fx_result.rate,
72
94
  cpi_ratio: infl.to_index.to_f / infl.from_index,
73
- converted_amount: converted,
74
- granularity: Granularity.merge(fx_result.granularity, infl.granularity)
95
+ converted_amount: fx_result.amount,
96
+ granularity: Granularity.merge(fx_result.granularity, infl.granularity),
97
+ forecast: nil
98
+ )
99
+ end
100
+
101
+ # Same-date branch: no time-elapsed inflation, so the FX leg alone is
102
+ # the answer. Builds a CompareResult with cpi_ratio=1.0.
103
+ def fx_only_result(amount:, from_point:, to_point:, to_country:, fx_result:)
104
+ CompareResult.new(
105
+ amount: fx_result.amount,
106
+ original_amount: amount.to_f,
107
+ from_currency: from_point.currency,
108
+ from_date: from_point.date.to_s,
109
+ to_currency: to_point.currency,
110
+ to_date: to_point.date.to_s,
111
+ country: to_country,
112
+ fx_rate: fx_result.rate,
113
+ cpi_ratio: 1.0,
114
+ converted_amount: fx_result.amount,
115
+ granularity: fx_result.granularity,
116
+ forecast: nil
75
117
  )
76
118
  end
77
119
 
@@ -79,12 +121,63 @@ module Timeprice
79
121
  def resolve_points(from, to)
80
122
  from_point = Point.coerce(from)
81
123
  to_point = Point.coerce(to)
82
- raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
124
+ fail UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
83
125
 
84
126
  to_country = Supported.country_for_currency(to_point.currency)
85
- raise UnsupportedCurrency, to_point.currency unless to_country
127
+ fail UnsupportedCurrency, to_point.currency unless to_country
86
128
 
87
129
  [from_point, to_point, to_country]
88
130
  end
131
+
132
+ # Returns true when to_point.date is past the destination country's last
133
+ # bundled CPI date.
134
+ def future_target?(to_point, to_country)
135
+ data = DataLoader.load_cpi(to_country)
136
+ series = Forecast::CpiForecaster.pick_series(data)
137
+ last = series.keys.max_by { |k| Forecast::Cagr.parse(k) }
138
+ Forecast::Cagr.parse(to_point.date.to_s) > Forecast::Cagr.parse(last)
139
+ end
140
+
141
+ def run_with_forecast(amount:, from_point:, to_point:, to_country:)
142
+ fx_result = Exchange.convert(
143
+ amount: amount, from: from_point.currency,
144
+ to: to_point.currency, date: from_point.fx_anchor_date
145
+ )
146
+ cpi_fwd = Forecast::CpiForecaster.project(country: to_country, target: to_point.date.to_s)
147
+ source_cpi_value = source_index(to_country, from_point.date.to_s)
148
+ inflation_ratio = cpi_fwd.value / source_cpi_value
149
+
150
+ CompareResult.new(
151
+ amount: fx_result.amount * inflation_ratio,
152
+ original_amount: amount.to_f,
153
+ from_currency: from_point.currency, from_date: from_point.date.to_s,
154
+ to_currency: to_point.currency, to_date: to_point.date.to_s,
155
+ country: to_country,
156
+ fx_rate: fx_result.rate,
157
+ cpi_ratio: inflation_ratio,
158
+ converted_amount: fx_result.amount,
159
+ granularity: :forecast,
160
+ forecast: forecast_hash(cpi_fwd: cpi_fwd, converted: fx_result.amount, source_cpi: source_cpi_value)
161
+ )
162
+ end
163
+
164
+ def forecast_hash(cpi_fwd:, converted:, source_cpi:)
165
+ {
166
+ basis_kind: cpi_fwd.basis_kind,
167
+ projection_method: cpi_fwd.projection_method,
168
+ window_years: cpi_fwd.window_years,
169
+ sigma_pct: cpi_fwd.sigma_pct,
170
+ last_known_date: cpi_fwd.last_known_date,
171
+ horizon_months: cpi_fwd.horizon_months,
172
+ low: converted * (cpi_fwd.low / source_cpi),
173
+ high: converted * (cpi_fwd.high / source_cpi),
174
+ warnings: cpi_fwd.warnings,
175
+ }
176
+ end
177
+
178
+ # Resolve a measured CPI index for the source date (which must be in range).
179
+ def source_index(country, date)
180
+ CpiLookup.new(DataLoader.load_cpi(country)).at(date).value.to_f
181
+ end
89
182
  end
90
183
  end
@@ -22,17 +22,23 @@ module Timeprice
22
22
  @annual = data.dig("series", "annual") || {}
23
23
  end
24
24
 
25
- # @param key [String] "YYYY", "YYYY-MM", or "YYYY-Qn"
25
+ # @param key [String] "YYYY", "YYYY-MM", "YYYY-Qn", or "YYYY-MM-DD"
26
26
  # @return [CpiPoint]
27
27
  # @raise [DataNotFound] if no CPI value covers `key`
28
28
  # @raise [ArgumentError] on malformed `key`
29
+ #
30
+ # Daily keys are accepted and silently resolved at month grain — CPI is
31
+ # published monthly at best, so the day is dropped before lookup. The
32
+ # returned granularity reflects what the monthly cascade actually found
33
+ # (monthly / quarterly fallback / annual fallback), not "daily".
29
34
  def at(key)
30
35
  key = key.to_s
31
36
  case key
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)"
37
+ when QUARTER_RE then quarterly_or_fallbacks(key)
38
+ when /\A\d{4}-\d{2}-\d{2}\z/ then monthly_or_fallbacks(key[0, 7])
39
+ when /\A\d{4}-\d{2}\z/ then monthly_or_fallbacks(key)
40
+ when /\A\d{4}\z/ then annual_or_derived(key)
41
+ else fail ArgumentError, "Invalid date format: #{key.inspect} (use YYYY, YYYY-MM, YYYY-Qn, or YYYY-MM-DD)"
36
42
  end
37
43
  end
38
44
 
@@ -48,7 +54,7 @@ module Timeprice
48
54
  end
49
55
 
50
56
  year_key = month_key[0, 4]
51
- raise DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
57
+ fail DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
52
58
 
53
59
  CpiPoint.new(value: @annual[year_key], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
54
60
  end
@@ -71,7 +77,7 @@ module Timeprice
71
77
  end
72
78
 
73
79
  year = quarter_key[0, 4]
74
- raise DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
80
+ fail DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
75
81
 
76
82
  CpiPoint.new(value: @annual[year], granularity: Granularity::QUARTERLY_FROM_ANNUAL_FALLBACK)
77
83
  end
@@ -90,7 +96,7 @@ module Timeprice
90
96
  return average(months, months.size, Granularity::ANNUAL_FROM_PARTIAL_MONTHS) if months.any?
91
97
  return average(quarters, quarters.size, Granularity::ANNUAL_FROM_PARTIAL_QUARTERS) if quarters.any?
92
98
 
93
- raise DataNotFound, missing_message(year)
99
+ fail DataNotFound, missing_message(year)
94
100
  end
95
101
 
96
102
  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
@@ -5,6 +5,7 @@ require_relative "errors"
5
5
  require_relative "data_loader"
6
6
  require_relative "supported"
7
7
  require_relative "granularity"
8
+ require_relative "date"
8
9
 
9
10
  module Timeprice
10
11
  ExchangeResult = Data.define(
@@ -15,6 +16,11 @@ module Timeprice
15
16
  # Handles identity (USD→USD), direct lookup, inverse, and triangulation
16
17
  # through USD. Weekend/holiday dates fall back up to {MAX_FALLBACK_DAYS}
17
18
  # days to the nearest prior trading day.
19
+ #
20
+ # @api private
21
+ # The supported public entry point is {Timeprice.exchange}. Direct
22
+ # references will move to `Timeprice::Internal::Exchange` in a future
23
+ # release.
18
24
  module Exchange
19
25
  BASE = "USD"
20
26
  MAX_FALLBACK_DAYS = 7
@@ -33,8 +39,8 @@ module Timeprice
33
39
  def convert(amount:, from:, to:, date:)
34
40
  from = from.to_s.upcase
35
41
  to = to.to_s.upcase
36
- raise UnsupportedCurrency, from unless Supported.currency?(from)
37
- raise UnsupportedCurrency, to unless Supported.currency?(to)
42
+ fail UnsupportedCurrency, from unless Supported.currency?(from)
43
+ fail UnsupportedCurrency, to unless Supported.currency?(to)
38
44
 
39
45
  d = parse_date(date)
40
46
 
@@ -69,18 +75,32 @@ module Timeprice
69
75
  rate, eff, gran = lookup_usd_base(from, d)
70
76
  [1.0 / rate, eff, gran]
71
77
  else
72
- # Triangulation: from → USD → to, both legs at the same effective date.
73
- usd_to_from, eff_a, gran_a = lookup_usd_base(from, d)
74
- usd_to_to, eff_b, gran_b = lookup_usd_base(to, d)
75
- if eff_a != eff_b
76
- raise DataNotFound,
77
- "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
78
- "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
79
- end
80
- [usd_to_to / usd_to_from, eff_a, Granularity.merge(gran_a, gran_b)]
78
+ # Triangulation: from → USD → to. Daily legs must agree on the
79
+ # effective date; an annual leg is valid for any date in its year, so
80
+ # we adopt the daily leg's date and let Granularity.merge demote.
81
+ rate_a, *leg_a = lookup_usd_base(from, d)
82
+ rate_b, *leg_b = lookup_usd_base(to, d)
83
+ eff = reconcile_triangulation_dates(from, to, d, leg_a, leg_b)
84
+ [rate_b / rate_a, eff, Granularity.merge(leg_a[1], leg_b[1])]
81
85
  end
82
86
  end
83
87
 
88
+ # Pick a single effective date for a triangulated rate. Daily legs must
89
+ # agree; an annual leg is year-wide so it adopts the daily leg's date.
90
+ # When both legs are annual we fall back to the requested date.
91
+ def reconcile_triangulation_dates(from, to, d, leg_a, leg_b)
92
+ eff_a, gran_a = leg_a
93
+ eff_b, gran_b = leg_b
94
+ return eff_a if eff_a == eff_b
95
+ return d if gran_a == Granularity::ANNUAL && gran_b == Granularity::ANNUAL
96
+ return eff_b if gran_a == Granularity::ANNUAL
97
+ return eff_a if gran_b == Granularity::ANNUAL
98
+
99
+ fail DataNotFound,
100
+ "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
101
+ "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
102
+ end
103
+
84
104
  # Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall
85
105
  # back to data/fx/usd/_annual.json (the single source of annual FX truth).
86
106
  # Returns [rate, effective_date, granularity].
@@ -105,7 +125,7 @@ module Timeprice
105
125
  annual_rate = annual_fallback(currency, d.year)
106
126
  return [annual_rate, d, Granularity::ANNUAL] if annual_rate
107
127
 
108
- raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
128
+ fail DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
109
129
  end
110
130
 
111
131
  # Consult data/fx/usd/_annual.json. Returns Float or nil.
@@ -118,20 +138,26 @@ module Timeprice
118
138
 
119
139
  def parse_date(date)
120
140
  case date
121
- when Date then date
141
+ when ::Date
142
+ date
143
+ when Timeprice::Date
144
+ require_daily!(date)
145
+ ::Date.new(date.year, date.month, date.day)
122
146
  when String
123
- unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
124
- raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
125
- end
126
-
127
- begin
128
- Date.parse(date)
129
- rescue Date::Error
130
- raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
131
- end
147
+ parsed = Timeprice::Date.coerce(date)
148
+ require_daily!(parsed)
149
+ ::Date.new(parsed.year, parsed.month, parsed.day)
132
150
  else
133
- raise ArgumentError, "Invalid date: #{date.inspect}"
151
+ fail ArgumentError, "Invalid date: #{date.inspect}"
134
152
  end
153
+ rescue ::Date::Error
154
+ raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
155
+ end
156
+
157
+ def require_daily!(date)
158
+ return if date.granularity == :daily
159
+
160
+ fail ArgumentError, "Invalid date: Exchange needs YYYY-MM-DD, got #{date}"
135
161
  end
136
162
  end
137
163
  end