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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/README.md +73 -2
- data/data/cpi/au.json +419 -0
- data/data/cpi/br.json +529 -0
- data/data/cpi/ca.json +1501 -0
- data/data/cpi/ch.json +549 -0
- data/data/cpi/cn.json +487 -0
- 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 +550 -0
- 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 +487 -0
- 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 +32 -32
- data/data/cpi/za.json +549 -0
- data/data/fx/usd/1999.json +5982 -262
- data/data/fx/usd/2000.json +6999 -258
- data/data/fx/usd/2001.json +7142 -257
- data/data/fx/usd/2002.json +7170 -258
- data/data/fx/usd/2003.json +7170 -258
- data/data/fx/usd/2004.json +7282 -262
- data/data/fx/usd/2005.json +7226 -260
- data/data/fx/usd/2006.json +7170 -258
- data/data/fx/usd/2007.json +7169 -258
- data/data/fx/usd/2008.json +7184 -259
- data/data/fx/usd/2009.json +6941 -259
- data/data/fx/usd/2010.json +6995 -261
- data/data/fx/usd/2011.json +6968 -260
- data/data/fx/usd/2012.json +6941 -259
- data/data/fx/usd/2013.json +6914 -258
- data/data/fx/usd/2014.json +6914 -258
- data/data/fx/usd/2015.json +6941 -259
- data/data/fx/usd/2016.json +6968 -260
- data/data/fx/usd/2017.json +6914 -258
- data/data/fx/usd/2018.json +7148 -258
- data/data/fx/usd/2019.json +7170 -258
- data/data/fx/usd/2020.json +7226 -260
- data/data/fx/usd/2021.json +7254 -261
- data/data/fx/usd/2022.json +7226 -260
- data/data/fx/usd/2023.json +7170 -258
- data/data/fx/usd/2024.json +7198 -259
- data/data/fx/usd/2025.json +7170 -258
- data/data/fx/usd/2026.json +2561 -92
- data/data/fx/usd/_annual.json +46 -1
- data/data/manifest.json +565 -2
- data/lib/timeprice/cli/presenters/compare.rb +44 -8
- data/lib/timeprice/cli.rb +24 -7
- data/lib/timeprice/compare/series.rb +120 -0
- data/lib/timeprice/compare.rb +108 -15
- data/lib/timeprice/cpi_lookup.rb +14 -8
- data/lib/timeprice/data_loader.rb +8 -17
- data/lib/timeprice/date.rb +62 -0
- data/lib/timeprice/exchange.rb +49 -23
- 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/inflation.rb +12 -4
- data/lib/timeprice/metadata.rb +121 -0
- data/lib/timeprice/metadata_snapshot.rb +23 -0
- data/lib/timeprice/point.rb +11 -3
- data/lib/timeprice/schema.rb +78 -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 +48 -3
- metadata +49 -2
|
@@ -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
|
|
@@ -0,0 +1,173 @@
|
|
|
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 currency-pair FX rate forward using the same trailing-CAGR
|
|
11
|
+
# mechanism as CpiForecaster, but with FX-appropriate defaults: 5-year
|
|
12
|
+
# window, 2-year horizon cap.
|
|
13
|
+
#
|
|
14
|
+
# The annual series is reconstructed by reading each bundled year file
|
|
15
|
+
# and pulling the year-end (or annual-average where present) rate. The
|
|
16
|
+
# daily granularity that {Exchange} works with is too noisy to anchor a
|
|
17
|
+
# multi-year extrapolation — annualizing first is the whole point.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
module FxForecaster
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
DEFAULT_WINDOW_YEARS = 5
|
|
24
|
+
HORIZON_CAP_YEARS = 2
|
|
25
|
+
|
|
26
|
+
def project(from:, to:, target:, window_years: DEFAULT_WINDOW_YEARS)
|
|
27
|
+
from = from.to_s.upcase
|
|
28
|
+
to = to.to_s.upcase
|
|
29
|
+
|
|
30
|
+
series = load_annual_series(from, to)
|
|
31
|
+
fail DataNotFound, "no FX series for #{from}->#{to}" if series.empty?
|
|
32
|
+
|
|
33
|
+
last_key = series.keys.max_by { |k| Cagr.parse(k) }
|
|
34
|
+
last_value = series[last_key].to_f
|
|
35
|
+
horizon_months = months_between(last_key, target)
|
|
36
|
+
earliest_year = series.keys.map { |k| Cagr.parse(k).year }.min
|
|
37
|
+
warnings = []
|
|
38
|
+
warnings << "insufficient_window" if Cagr.parse(last_key).year - window_years < earliest_year
|
|
39
|
+
warnings << "horizon_exceeds_cap" if horizon_months > HORIZON_CAP_YEARS * 12
|
|
40
|
+
|
|
41
|
+
stats = Cagr.compute(series: series, last_date: last_key, window_years: window_years)
|
|
42
|
+
|
|
43
|
+
build_result(
|
|
44
|
+
last_key: last_key, last_value: last_value,
|
|
45
|
+
target: target, horizon_months: horizon_months,
|
|
46
|
+
window_years: window_years, stats: stats, warnings: warnings
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Build an annual {year_string => rate} series by reading each FX year
|
|
51
|
+
# file and computing a daily average for the requested currency pair.
|
|
52
|
+
# Falls back to the _annual.json file for years where the daily file
|
|
53
|
+
# does not cover the requested pair. Returns {} if no data is found.
|
|
54
|
+
def load_annual_series(from, to)
|
|
55
|
+
root = File.join(DataLoader.data_root, "fx", "usd")
|
|
56
|
+
return {} unless File.directory?(root)
|
|
57
|
+
|
|
58
|
+
fallback = DataLoader.load_fx_annual_fallback
|
|
59
|
+
years = Dir.children(root).filter_map do |f|
|
|
60
|
+
Regexp.last_match(1).to_i if f =~ /\A(\d{4})\.json\z/
|
|
61
|
+
end.sort
|
|
62
|
+
|
|
63
|
+
years.each_with_object({}) do |year, acc|
|
|
64
|
+
rate = pick_year_rate(DataLoader.load_fx_year(year), from, to)
|
|
65
|
+
rate ||= pick_annual_fallback_rate(fallback, year, from, to) if fallback
|
|
66
|
+
acc[year.to_s] = rate if rate
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compute an annual average rate for +from+->+to+ from a year-file
|
|
71
|
+
# payload. Year-file rates are stored as USD->currency daily rates.
|
|
72
|
+
#
|
|
73
|
+
# from == "USD" => average rates[date][to] across all dates
|
|
74
|
+
# to == "USD" => average rates[date][from], then invert
|
|
75
|
+
# else (cross) => per-date to/from ratio, then average
|
|
76
|
+
def pick_year_rate(payload, from, to)
|
|
77
|
+
daily = payload["rates"]
|
|
78
|
+
return nil unless daily.is_a?(Hash) && !daily.empty?
|
|
79
|
+
|
|
80
|
+
if from == "USD"
|
|
81
|
+
daily_avg(daily.values, to)
|
|
82
|
+
elsif to == "USD"
|
|
83
|
+
invert_avg(daily.values, from)
|
|
84
|
+
else
|
|
85
|
+
cross_avg(daily.values, from, to)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Extract an annual rate for +from+->+to+ from the _annual.json fallback.
|
|
90
|
+
# Fallback stores USD->currency annual averages keyed by year string.
|
|
91
|
+
def pick_annual_fallback_rate(fallback, year, from, to)
|
|
92
|
+
ann = fallback.dig("annual", year.to_s)
|
|
93
|
+
return nil unless ann.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
if from == "USD"
|
|
96
|
+
ann[to]&.to_f
|
|
97
|
+
elsif to == "USD"
|
|
98
|
+
invert_scalar(ann[from]&.to_f)
|
|
99
|
+
else
|
|
100
|
+
cross_scalar(ann[from]&.to_f, ann[to]&.to_f)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def months_between(from_key, to_key)
|
|
105
|
+
f = Cagr.parse(from_key)
|
|
106
|
+
t = Cagr.parse(to_key)
|
|
107
|
+
((t.year - f.year) * 12) + (t.month - f.month)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_result(last_key:, last_value:, target:, horizon_months:, window_years:, stats:, warnings:)
|
|
111
|
+
years_forward = horizon_months / 12.0
|
|
112
|
+
value = last_value * ((1.0 + stats[:cagr])**years_forward)
|
|
113
|
+
low = last_value * ((1.0 + stats[:cagr] - stats[:sigma_yoy])**years_forward)
|
|
114
|
+
high = last_value * ((1.0 + stats[:cagr] + stats[:sigma_yoy])**years_forward)
|
|
115
|
+
|
|
116
|
+
Forecast::Result.new(
|
|
117
|
+
value: value, low: low, high: high,
|
|
118
|
+
projection_method: "cagr_trailing",
|
|
119
|
+
window_years: window_years,
|
|
120
|
+
sigma_pct: stats[:sigma_yoy],
|
|
121
|
+
last_known_date: last_key,
|
|
122
|
+
target_date: target,
|
|
123
|
+
horizon_months: horizon_months,
|
|
124
|
+
basis_kind: :fx,
|
|
125
|
+
warnings: warnings.uniq
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# --- private helpers ---
|
|
130
|
+
|
|
131
|
+
# Average USD->+currency+ values across all day_rates hashes.
|
|
132
|
+
def daily_avg(day_rates_list, currency)
|
|
133
|
+
vals = day_rates_list.filter_map { |dr| dr[currency]&.to_f }
|
|
134
|
+
return nil if vals.empty?
|
|
135
|
+
|
|
136
|
+
vals.sum / vals.size
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Average USD->+currency+ then invert to get currency->USD.
|
|
140
|
+
def invert_avg(day_rates_list, currency)
|
|
141
|
+
mean = daily_avg(day_rates_list, currency)
|
|
142
|
+
invert_scalar(mean)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Average the per-day cross rate (USD->to / USD->from) for dates where
|
|
146
|
+
# both currencies are present.
|
|
147
|
+
def cross_avg(day_rates_list, from, to)
|
|
148
|
+
pairs = day_rates_list.filter_map do |dr|
|
|
149
|
+
usd_from = dr[from]&.to_f
|
|
150
|
+
usd_to = dr[to]&.to_f
|
|
151
|
+
cross_scalar(usd_from, usd_to)
|
|
152
|
+
end
|
|
153
|
+
return nil if pairs.empty?
|
|
154
|
+
|
|
155
|
+
pairs.sum / pairs.size
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Invert a single USD->X scalar to X->USD; nil if zero or nil.
|
|
159
|
+
def invert_scalar(val)
|
|
160
|
+
return nil unless val&.positive?
|
|
161
|
+
|
|
162
|
+
1.0 / val
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Compute cross rate from two USD->X scalars; nil if either is nil/zero.
|
|
166
|
+
def cross_scalar(usd_from, usd_to)
|
|
167
|
+
return nil unless usd_from&.positive? && usd_to
|
|
168
|
+
|
|
169
|
+
usd_to / usd_from
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Timeprice
|
|
4
|
+
# Forward-projection of CPI / FX series past the last bundled data point.
|
|
5
|
+
#
|
|
6
|
+
# Method: trailing-window CAGR for the point estimate, ±1σ of trailing
|
|
7
|
+
# year-over-year changes for the band. Pure-Ruby, deterministic, no
|
|
8
|
+
# network calls or stats dependencies.
|
|
9
|
+
#
|
|
10
|
+
# All results are explicitly tagged so callers never confuse them with
|
|
11
|
+
# measured data. The {Forecast::Result#warnings} array surfaces horizon-cap
|
|
12
|
+
# violations and insufficient-window conditions.
|
|
13
|
+
module Forecast
|
|
14
|
+
Result = Data.define(
|
|
15
|
+
:value, :low, :high,
|
|
16
|
+
:projection_method, :window_years, :sigma_pct,
|
|
17
|
+
:last_known_date, :target_date, :horizon_months,
|
|
18
|
+
:basis_kind, :warnings
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "errors"
|
|
|
4
4
|
require_relative "data_loader"
|
|
5
5
|
require_relative "cpi_lookup"
|
|
6
6
|
require_relative "granularity"
|
|
7
|
+
require_relative "date"
|
|
7
8
|
|
|
8
9
|
module Timeprice
|
|
9
10
|
# Value object returned by Inflation.adjust. See {Granularity} for the set
|
|
@@ -22,6 +23,11 @@ module Timeprice
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
# CPI-based inflation adjustment for the {Supported.countries} list.
|
|
26
|
+
#
|
|
27
|
+
# @api private
|
|
28
|
+
# The supported public entry point is {Timeprice.inflation}. Direct
|
|
29
|
+
# references to this module will move to `Timeprice::Internal::Inflation`
|
|
30
|
+
# in a future release.
|
|
25
31
|
module Inflation
|
|
26
32
|
module_function
|
|
27
33
|
|
|
@@ -37,16 +43,18 @@ module Timeprice
|
|
|
37
43
|
# @raise [UnsupportedCountry] if `country` is not supported
|
|
38
44
|
# @raise [DataNotFound] if no CPI data covers the requested period
|
|
39
45
|
def adjust(amount:, from:, to:, country:)
|
|
46
|
+
from = Timeprice::Date.coerce(from)
|
|
47
|
+
to = Timeprice::Date.coerce(to)
|
|
40
48
|
lookup = CpiLookup.new(DataLoader.load_cpi(country))
|
|
41
|
-
from_point = lookup.at(from)
|
|
42
|
-
to_point = lookup.at(to)
|
|
49
|
+
from_point = lookup.at(from.to_s)
|
|
50
|
+
to_point = lookup.at(to.to_s)
|
|
43
51
|
|
|
44
52
|
ratio = to_point.value.to_f / from_point.value
|
|
45
53
|
InflationResult.new(
|
|
46
54
|
amount: amount.to_f * ratio,
|
|
47
55
|
original_amount: amount.to_f,
|
|
48
|
-
from: from,
|
|
49
|
-
to: to,
|
|
56
|
+
from: from.to_s,
|
|
57
|
+
to: to.to_s,
|
|
50
58
|
country: country.to_s.upcase,
|
|
51
59
|
from_index: from_point.value,
|
|
52
60
|
to_index: to_point.value,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "data_loader"
|
|
4
|
+
require_relative "supported"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "metadata_snapshot"
|
|
7
|
+
|
|
8
|
+
module Timeprice
|
|
9
|
+
# Describes the bundled dataset so external surfaces (the website, other
|
|
10
|
+
# tools) can render dropdowns, date pickers, and version pills without
|
|
11
|
+
# hardcoding country lists, currency lists, or date ranges.
|
|
12
|
+
#
|
|
13
|
+
# See {Timeprice.metadata} for the public entry point.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
# Direct references will move to `Timeprice::Internal::Metadata` in a
|
|
17
|
+
# future release.
|
|
18
|
+
module Metadata
|
|
19
|
+
# ISO 3166-style display names for the countries shipped today.
|
|
20
|
+
COUNTRY_NAMES = {
|
|
21
|
+
"AU" => "Australia",
|
|
22
|
+
"CA" => "Canada",
|
|
23
|
+
"CN" => "China",
|
|
24
|
+
"EU" => "Eurozone",
|
|
25
|
+
"JP" => "Japan",
|
|
26
|
+
"KR" => "South Korea",
|
|
27
|
+
"RU" => "Russia",
|
|
28
|
+
"UK" => "United Kingdom",
|
|
29
|
+
"US" => "United States",
|
|
30
|
+
"VN" => "Vietnam",
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# ISO 4217 display names for the currencies shipped today.
|
|
34
|
+
CURRENCY_NAMES = {
|
|
35
|
+
"AUD" => "Australian dollar",
|
|
36
|
+
"CAD" => "Canadian dollar",
|
|
37
|
+
"CNY" => "Chinese yuan",
|
|
38
|
+
"EUR" => "Euro",
|
|
39
|
+
"GBP" => "British pound",
|
|
40
|
+
"JPY" => "Japanese yen",
|
|
41
|
+
"KRW" => "South Korean won",
|
|
42
|
+
"RUB" => "Russian ruble",
|
|
43
|
+
"USD" => "US dollar",
|
|
44
|
+
"VND" => "Vietnamese dong",
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Build the metadata snapshot.
|
|
50
|
+
# @return [MetadataSnapshot]
|
|
51
|
+
def build
|
|
52
|
+
manifest = DataLoader.load_manifest
|
|
53
|
+
countries = (manifest["countries"] || []).map { |c| country_entry(c) }
|
|
54
|
+
currencies = Supported.currencies.map { |code| { code: code, name: CURRENCY_NAMES[code] || code } }
|
|
55
|
+
MetadataSnapshot.new(
|
|
56
|
+
version: VERSION,
|
|
57
|
+
generated_at: manifest["generated_at"],
|
|
58
|
+
countries: deep_freeze(countries),
|
|
59
|
+
currencies: deep_freeze(currencies),
|
|
60
|
+
fx: deep_freeze(fx_entry(manifest))
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Range info comes from the manifest (`cpi_ranges`), pre-computed at
|
|
65
|
+
# manifest generation time. Falls back to walking the CPI file for any
|
|
66
|
+
# country missing the field — older manifests, or local data roots
|
|
67
|
+
# produced by hand.
|
|
68
|
+
def country_entry(country)
|
|
69
|
+
code = country["code"]
|
|
70
|
+
ranges = country["cpi_ranges"] || derive_cpi_ranges(code)
|
|
71
|
+
per_granularity = ranges.each_with_object({}) do |(gran, range), acc|
|
|
72
|
+
acc[gran.to_sym] = { min: range["min"], max: range["max"] }
|
|
73
|
+
end
|
|
74
|
+
{
|
|
75
|
+
code: code,
|
|
76
|
+
name: COUNTRY_NAMES[code] || code,
|
|
77
|
+
currency: country["currency"],
|
|
78
|
+
granularities: country["granularities"] || per_granularity.keys.map(&:to_s),
|
|
79
|
+
cpi: per_granularity,
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def derive_cpi_ranges(code)
|
|
84
|
+
cpi = DataLoader.load_cpi(code)
|
|
85
|
+
series = cpi["series"] || {}
|
|
86
|
+
series.each_with_object({}) do |(granularity, points), acc|
|
|
87
|
+
next unless points.is_a?(Hash) && !points.empty?
|
|
88
|
+
|
|
89
|
+
keys = points.keys.sort
|
|
90
|
+
acc[granularity] = { "min" => keys.first, "max" => keys.last }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Bounds come from the manifest (`fx.daily_min`/`fx.daily_max`). Older
|
|
95
|
+
# manifests without those keys: peek at the earliest/latest year files.
|
|
96
|
+
def fx_entry(manifest)
|
|
97
|
+
fx = manifest["fx"] || {}
|
|
98
|
+
base = fx["base"]
|
|
99
|
+
years = fx["daily_years"] || []
|
|
100
|
+
return { base: base, daily_min: nil, daily_max: nil } if years.empty?
|
|
101
|
+
|
|
102
|
+
daily_min = fx["daily_min"]
|
|
103
|
+
daily_max = fx["daily_max"]
|
|
104
|
+
if daily_min.nil? || daily_max.nil?
|
|
105
|
+
first = DataLoader.load_fx_year(years.min)
|
|
106
|
+
last = DataLoader.load_fx_year(years.max)
|
|
107
|
+
daily_min ||= (first["rates"] || {}).keys.min
|
|
108
|
+
daily_max ||= (last["rates"] || {}).keys.max
|
|
109
|
+
end
|
|
110
|
+
{ base: base, daily_min: daily_min, daily_max: daily_max }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def deep_freeze(value)
|
|
114
|
+
case value
|
|
115
|
+
when Hash then value.each_value { |v| deep_freeze(v) }.freeze
|
|
116
|
+
when Array then value.each { |v| deep_freeze(v) }.freeze
|
|
117
|
+
else value.frozen? ? value : value.freeze
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
# Frozen value object describing the bundled dataset: version, refresh
|
|
7
|
+
# date, country list with CPI ranges, currency list with display names,
|
|
8
|
+
# and FX coverage. Replaces the previous Hash return shape on
|
|
9
|
+
# {Timeprice.metadata}.
|
|
10
|
+
#
|
|
11
|
+
# `[]`, `to_h`, and `to_json` are kept compatible with the old Hash
|
|
12
|
+
# interface so downstream consumers (the website, this gem's specs)
|
|
13
|
+
# don't need a coordinated rewrite.
|
|
14
|
+
MetadataSnapshot = Data.define(:version, :generated_at, :countries, :currencies, :fx) do
|
|
15
|
+
def [](key)
|
|
16
|
+
to_h[key]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_json(*args)
|
|
20
|
+
to_h.to_json(*args)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/timeprice/point.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "date"
|
|
4
|
+
|
|
3
5
|
module Timeprice
|
|
4
6
|
# A (currency, date) pair used as input to {Timeprice.compare}.
|
|
5
7
|
#
|
|
@@ -13,6 +15,12 @@ module Timeprice
|
|
|
13
15
|
# Timeprice::Point.coerce(["USD", "2010"])
|
|
14
16
|
# Timeprice::Point.coerce(["2010", "USD"])
|
|
15
17
|
Point = Data.define(:currency, :date) do
|
|
18
|
+
# Canonical constructor. Accepts a stdlib-string or Timeprice::Date
|
|
19
|
+
# for the date argument; stores the canonical string form.
|
|
20
|
+
def self.parse(currency, date)
|
|
21
|
+
new(currency: currency.to_s.upcase, date: Timeprice::Date.coerce(date).to_s)
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
# Coerce input into a Point. Accepts:
|
|
17
25
|
# - {Point} (returned as-is)
|
|
18
26
|
# - 2-element Array of [currency, date] in either order
|
|
@@ -28,11 +36,11 @@ module Timeprice
|
|
|
28
36
|
a, b = input.map(&:to_s)
|
|
29
37
|
currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
|
|
30
38
|
date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
|
|
31
|
-
|
|
39
|
+
fail ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
|
|
32
40
|
|
|
33
41
|
new(currency: currency.upcase, date: date)
|
|
34
42
|
else
|
|
35
|
-
|
|
43
|
+
fail ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
|
|
36
44
|
end
|
|
37
45
|
end
|
|
38
46
|
|
|
@@ -55,7 +63,7 @@ module Timeprice
|
|
|
55
63
|
when /\A\d{4}\z/ then "#{date}-06-30"
|
|
56
64
|
when /\A\d{4}-\d{2}\z/ then "#{date}-15"
|
|
57
65
|
when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
|
|
58
|
-
else
|
|
66
|
+
else fail ArgumentError, "Invalid date for Point: #{date.inspect}"
|
|
59
67
|
end
|
|
60
68
|
end
|
|
61
69
|
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
# Single source of truth for the on-disk v4 CPI/manifest format. Both the
|
|
7
|
+
# reader ({DataLoader}) and the writer (today: pipeline `CountryFile`)
|
|
8
|
+
# route through here so the schema lives in exactly one place.
|
|
9
|
+
module Schema
|
|
10
|
+
CURRENT_VERSION = 4
|
|
11
|
+
SUPPORTED_VERSIONS = [3, 4].freeze
|
|
12
|
+
|
|
13
|
+
KEY_SCHEMA_VERSION = "schema_version"
|
|
14
|
+
KEY_COUNTRY = "country"
|
|
15
|
+
KEY_INDEX = "index"
|
|
16
|
+
KEY_SERIES = "series"
|
|
17
|
+
KEY_PROVENANCE = "provenance"
|
|
18
|
+
KEY_PROVIDERS = "providers"
|
|
19
|
+
|
|
20
|
+
GRANULARITIES = %i[monthly quarterly annual].freeze
|
|
21
|
+
|
|
22
|
+
BASE_YEAR_RE = /\A(?<period>.+?)=100(?:\s*\(rebased\s+(?<rebased>\d{4}-\d{2}-\d{2})\))?\z/
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def supported?(version)
|
|
27
|
+
SUPPORTED_VERSIONS.include?(version)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assert_supported!(version, path)
|
|
31
|
+
return if supported?(version)
|
|
32
|
+
|
|
33
|
+
fail UnsupportedSchemaVersion.new(version, path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build a CPI payload ready for JSON.dump. Series keys are emitted in a
|
|
37
|
+
# stable order (annual, monthly[, quarterly]) so file diffs stay tight.
|
|
38
|
+
def dump_cpi(country:, base_year:, monthly:, annual:, providers:, provenance:, quarterly: {})
|
|
39
|
+
series = { "annual" => annual, "monthly" => monthly }
|
|
40
|
+
series["quarterly"] = quarterly unless quarterly.empty?
|
|
41
|
+
{
|
|
42
|
+
KEY_SCHEMA_VERSION => CURRENT_VERSION,
|
|
43
|
+
KEY_COUNTRY => country.to_s.upcase,
|
|
44
|
+
KEY_INDEX => serialise_base_year(base_year),
|
|
45
|
+
KEY_SERIES => series,
|
|
46
|
+
KEY_PROVENANCE => provenance,
|
|
47
|
+
KEY_PROVIDERS => providers,
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Validate a parsed payload (read from disk) against the schema, then
|
|
52
|
+
# return it unchanged. Raises UnsupportedSchemaVersion if the version
|
|
53
|
+
# field is missing or unknown.
|
|
54
|
+
def load_cpi(parsed, path:)
|
|
55
|
+
assert_supported!(parsed[KEY_SCHEMA_VERSION], path)
|
|
56
|
+
parsed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def serialise_base_year(str)
|
|
60
|
+
m = BASE_YEAR_RE.match(str.to_s)
|
|
61
|
+
if m
|
|
62
|
+
{ "base_period" => m[:period], "rebased_at" => m[:rebased] }
|
|
63
|
+
else
|
|
64
|
+
{ "base_period" => str.to_s, "rebased_at" => nil }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def deserialise_base_year(index)
|
|
69
|
+
return nil unless index.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
period = index["base_period"]
|
|
72
|
+
rebased = index["rebased_at"]
|
|
73
|
+
return nil if period.nil?
|
|
74
|
+
|
|
75
|
+
rebased ? "#{period}=100 (rebased #{rebased})" : "#{period}=100"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|