timeprice 0.7.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +41 -1
  4. data/data/cpi/au.json +2 -2
  5. data/data/cpi/br.json +529 -0
  6. data/data/cpi/ca.json +3 -3
  7. data/data/cpi/ch.json +549 -0
  8. data/data/cpi/cn.json +10 -10
  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 +36 -35
  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 +2 -2
  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 +10 -10
  32. data/data/cpi/za.json +549 -0
  33. data/data/fx/usd/1999.json +5201 -261
  34. data/data/fx/usd/2000.json +5982 -257
  35. data/data/fx/usd/2001.json +6121 -256
  36. data/data/fx/usd/2002.json +6145 -257
  37. data/data/fx/usd/2003.json +6145 -257
  38. data/data/fx/usd/2004.json +6241 -261
  39. data/data/fx/usd/2005.json +6193 -259
  40. data/data/fx/usd/2006.json +6145 -257
  41. data/data/fx/usd/2007.json +6144 -257
  42. data/data/fx/usd/2008.json +6155 -258
  43. data/data/fx/usd/2009.json +5912 -258
  44. data/data/fx/usd/2010.json +5958 -260
  45. data/data/fx/usd/2011.json +5935 -259
  46. data/data/fx/usd/2012.json +5912 -258
  47. data/data/fx/usd/2013.json +5889 -257
  48. data/data/fx/usd/2014.json +5889 -257
  49. data/data/fx/usd/2015.json +5912 -258
  50. data/data/fx/usd/2016.json +5935 -259
  51. data/data/fx/usd/2017.json +5889 -257
  52. data/data/fx/usd/2018.json +6123 -257
  53. data/data/fx/usd/2019.json +6145 -257
  54. data/data/fx/usd/2020.json +6193 -259
  55. data/data/fx/usd/2021.json +6217 -260
  56. data/data/fx/usd/2022.json +6193 -259
  57. data/data/fx/usd/2023.json +6145 -257
  58. data/data/fx/usd/2024.json +6169 -258
  59. data/data/fx/usd/2025.json +6145 -257
  60. data/data/fx/usd/2026.json +2196 -92
  61. data/data/fx/usd/_annual.json +2 -2
  62. data/data/manifest.json +488 -73
  63. data/lib/timeprice/cli/presenters/compare.rb +44 -8
  64. data/lib/timeprice/cli.rb +21 -4
  65. data/lib/timeprice/compare/series.rb +120 -0
  66. data/lib/timeprice/compare.rb +77 -17
  67. data/lib/timeprice/cpi_lookup.rb +11 -5
  68. data/lib/timeprice/forecast/cagr.rb +96 -0
  69. data/lib/timeprice/forecast/cpi_forecaster.rb +90 -0
  70. data/lib/timeprice/forecast/fx_forecaster.rb +173 -0
  71. data/lib/timeprice/forecast.rb +21 -0
  72. data/lib/timeprice/sources.rb +1 -1
  73. data/lib/timeprice/supported.rb +16 -0
  74. data/lib/timeprice/version.rb +1 -1
  75. data/lib/timeprice.rb +34 -2
  76. metadata +26 -2
@@ -15,30 +15,66 @@ module Timeprice
15
15
  end
16
16
 
17
17
  def json_hash
18
- @result.to_h.merge(
18
+ base = @result.to_h.merge(
19
19
  amount: round_money(@result.amount, @result.to_currency),
20
20
  original_amount: round_money(@result.original_amount, @result.from_currency),
21
21
  converted_amount: round_money(@result.converted_amount, @result.to_currency),
22
22
  fx_rate: @result.fx_rate.to_f.round(6),
23
23
  cpi_ratio: @result.cpi_ratio.to_f.round(6)
24
24
  )
25
+ if @result.forecast
26
+ fc = @result.forecast
27
+ base[:forecast] = fc.merge(
28
+ low: round_money(fc[:low], @result.to_currency),
29
+ high: round_money(fc[:high], @result.to_currency)
30
+ )
31
+ else
32
+ base.delete(:forecast)
33
+ end
34
+ base
25
35
  end
26
36
 
27
37
  # Headline + left-to-right chain so the FX + CPI composition reads naturally.
28
38
  def text_lines
29
- final = "#{fmt_money(@result.amount, @result.to_currency)} #{@result.to_currency}"
30
- original = "#{fmt_money(@result.original_amount, @result.from_currency)} #{@result.from_currency}"
39
+ final = "#{fmt_money(@result.amount, @result.to_currency)} #{@result.to_currency}"
40
+ original = "#{fmt_money(@result.original_amount, @result.from_currency)} #{@result.from_currency}"
31
41
  converted = "#{fmt_money(@result.converted_amount, @result.to_currency)} #{@result.to_currency}"
32
- step1 = "fx @ #{fmt_rate(@result.fx_rate)}"
33
- step2 = "inflate x#{format("%.4f", @result.cpi_ratio)} #{@result.country}"
34
- width = [step1.length, step2.length].max
35
- [
36
- "#{final} in #{@result.to_date}",
42
+ step1 = "fx @ #{fmt_rate(@result.fx_rate)}"
43
+ step2 = "inflate x#{format("%.4f", @result.cpi_ratio)} #{@result.country}"
44
+ width = [step1.length, step2.length].max
45
+ headline = if @result.forecast
46
+ "#{final} in #{@result.to_date} (forecast)"
47
+ else
48
+ "#{final} in #{@result.to_date}"
49
+ end
50
+ lines = [
51
+ headline,
37
52
  " #{original} (#{@result.from_date})",
38
53
  format(" -> %-#{width}s -> %s (%s)", step1, converted, @result.from_date),
39
54
  format(" -> %-#{width}s -> %s (%s, %s)", step2, final, @result.to_date,
40
55
  Granularity.humanize(@result.granularity)),
41
56
  ]
57
+ @result.forecast ? lines + forecast_lines(final) : lines
58
+ end
59
+
60
+ private
61
+
62
+ def forecast_lines(mid_str)
63
+ fc = @result.forecast
64
+ low_str = "#{fmt_money(fc[:low], @result.to_currency)} #{@result.to_currency}"
65
+ high_str = "#{fmt_money(fc[:high], @result.to_currency)} #{@result.to_currency}"
66
+ extra = [
67
+ "",
68
+ " range #{low_str} — #{mid_str} — #{high_str}",
69
+ " (low -1σ) (most likely) (high +1σ)",
70
+ "",
71
+ " basis trailing #{fc[:window_years]}y CAGR · last data #{fc[:last_known_date]}",
72
+ " sigma ±#{format("%.1f", fc[:sigma_pct] * 100)}%/yr · horizon +#{fc[:horizon_months]}mo",
73
+ ]
74
+ if fc[:warnings].include?("horizon_exceeds_cap")
75
+ extra << " caveat long-horizon forecast: results are illustrative, not predictive"
76
+ end
77
+ extra
42
78
  end
43
79
  end
44
80
  end
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
@@ -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.
@@ -44,23 +47,20 @@ module Timeprice
44
47
  # @param to [Timeprice::Point, Array(String, String)] destination point
45
48
  # @return [CompareResult]
46
49
  # @raise [UnsupportedCurrency] if either currency is not in {Supported.currencies}
47
- def run(amount:, from:, to:)
50
+ def run(amount:, from:, to:, forecast: false)
48
51
  from_point, to_point, to_country = resolve_points(from, to)
49
52
 
50
- # 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
+
51
59
  fx_result = Exchange.convert(
52
- amount: amount,
53
- from: from_point.currency,
54
- to: to_point.currency,
55
- date: from_point.fx_anchor_date
60
+ amount: amount, from: from_point.currency,
61
+ to: to_point.currency, date: from_point.fx_anchor_date
56
62
  )
57
- converted = fx_result.amount
58
63
 
59
- # Step 2: inflate that destination-currency amount from source date to
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
64
  if from_point.date == to_point.date
65
65
  return fx_only_result(
66
66
  amount: amount, from_point: from_point, to_point: to_point,
@@ -68,8 +68,15 @@ module Timeprice
68
68
  )
69
69
  end
70
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:)
71
78
  infl = Inflation.adjust(
72
- amount: converted,
79
+ amount: fx_result.amount,
73
80
  from: from_point.date.to_s,
74
81
  to: to_point.date.to_s,
75
82
  country: to_country
@@ -85,8 +92,9 @@ module Timeprice
85
92
  country: to_country,
86
93
  fx_rate: fx_result.rate,
87
94
  cpi_ratio: infl.to_index.to_f / infl.from_index,
88
- converted_amount: converted,
89
- 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
90
98
  )
91
99
  end
92
100
 
@@ -104,7 +112,8 @@ module Timeprice
104
112
  fx_rate: fx_result.rate,
105
113
  cpi_ratio: 1.0,
106
114
  converted_amount: fx_result.amount,
107
- granularity: fx_result.granularity
115
+ granularity: fx_result.granularity,
116
+ forecast: nil
108
117
  )
109
118
  end
110
119
 
@@ -119,5 +128,56 @@ module Timeprice
119
128
 
120
129
  [from_point, to_point, to_country]
121
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
122
182
  end
123
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 fail 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
 
@@ -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