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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +41 -1
- data/data/cpi/au.json +2 -2
- data/data/cpi/br.json +529 -0
- data/data/cpi/ca.json +3 -3
- data/data/cpi/ch.json +549 -0
- data/data/cpi/cn.json +10 -10
- data/data/cpi/cz.json +500 -0
- data/data/cpi/eu.json +1 -1
- data/data/cpi/hk.json +351 -0
- data/data/cpi/hu.json +537 -0
- data/data/cpi/id.json +550 -0
- data/data/cpi/il.json +549 -0
- data/data/cpi/in.json +549 -0
- data/data/cpi/jp.json +2 -2
- data/data/cpi/kr.json +36 -35
- data/data/cpi/mx.json +550 -0
- data/data/cpi/my.json +429 -0
- data/data/cpi/no.json +549 -0
- data/data/cpi/nz.json +94 -0
- data/data/cpi/ph.json +309 -0
- data/data/cpi/pl.json +539 -0
- data/data/cpi/ru.json +2 -2
- data/data/cpi/se.json +549 -0
- data/data/cpi/sg.json +369 -0
- data/data/cpi/th.json +309 -0
- data/data/cpi/tr.json +549 -0
- data/data/cpi/uk.json +1 -1
- data/data/cpi/us.json +1007 -5
- data/data/cpi/vn.json +10 -10
- data/data/cpi/za.json +549 -0
- data/data/fx/usd/1999.json +5201 -261
- data/data/fx/usd/2000.json +5982 -257
- data/data/fx/usd/2001.json +6121 -256
- data/data/fx/usd/2002.json +6145 -257
- data/data/fx/usd/2003.json +6145 -257
- data/data/fx/usd/2004.json +6241 -261
- data/data/fx/usd/2005.json +6193 -259
- data/data/fx/usd/2006.json +6145 -257
- data/data/fx/usd/2007.json +6144 -257
- data/data/fx/usd/2008.json +6155 -258
- data/data/fx/usd/2009.json +5912 -258
- data/data/fx/usd/2010.json +5958 -260
- data/data/fx/usd/2011.json +5935 -259
- data/data/fx/usd/2012.json +5912 -258
- data/data/fx/usd/2013.json +5889 -257
- data/data/fx/usd/2014.json +5889 -257
- data/data/fx/usd/2015.json +5912 -258
- data/data/fx/usd/2016.json +5935 -259
- data/data/fx/usd/2017.json +5889 -257
- data/data/fx/usd/2018.json +6123 -257
- data/data/fx/usd/2019.json +6145 -257
- data/data/fx/usd/2020.json +6193 -259
- data/data/fx/usd/2021.json +6217 -260
- data/data/fx/usd/2022.json +6193 -259
- data/data/fx/usd/2023.json +6145 -257
- data/data/fx/usd/2024.json +6169 -258
- data/data/fx/usd/2025.json +6145 -257
- data/data/fx/usd/2026.json +2196 -92
- data/data/fx/usd/_annual.json +2 -2
- data/data/manifest.json +488 -73
- data/lib/timeprice/cli/presenters/compare.rb +44 -8
- data/lib/timeprice/cli.rb +21 -4
- data/lib/timeprice/compare/series.rb +120 -0
- data/lib/timeprice/compare.rb +77 -17
- data/lib/timeprice/cpi_lookup.rb +11 -5
- data/lib/timeprice/forecast/cagr.rb +96 -0
- data/lib/timeprice/forecast/cpi_forecaster.rb +90 -0
- data/lib/timeprice/forecast/fx_forecaster.rb +173 -0
- data/lib/timeprice/forecast.rb +21 -0
- data/lib/timeprice/sources.rb +1 -1
- data/lib/timeprice/supported.rb +16 -0
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +34 -2
- 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
|
|
30
|
-
original
|
|
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
|
|
33
|
-
step2
|
|
34
|
-
width
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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,
|
|
110
|
-
method_option :to,
|
|
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
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
data/lib/timeprice/cpi_lookup.rb
CHANGED
|
@@ -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-
|
|
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
|
|
33
|
-
when /\A\d{4}-\d{2}\z/ then monthly_or_fallbacks(key)
|
|
34
|
-
when /\A\d{4}\z/ then
|
|
35
|
-
|
|
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
|