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
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
|
|
@@ -160,12 +177,12 @@ module Timeprice
|
|
|
160
177
|
end
|
|
161
178
|
|
|
162
179
|
def parse_compare_token(token, label:)
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
|
|
@@ -48,7 +54,7 @@ module Timeprice
|
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
year_key = month_key[0, 4]
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|