timeprice 0.5.0 → 0.7.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 +76 -0
- data/DATA_LICENSES.md +16 -1
- data/README.md +46 -7
- data/data/cpi/au.json +419 -0
- data/data/cpi/ca.json +1501 -0
- data/data/cpi/cn.json +487 -0
- data/data/cpi/eu.json +2 -2
- data/data/cpi/jp.json +2 -2
- data/data/cpi/kr.json +549 -0
- data/data/cpi/ru.json +487 -0
- data/data/cpi/uk.json +2 -2
- data/data/cpi/us.json +2 -2
- data/data/cpi/vn.json +27 -27
- data/data/fx/usd/1999.json +1043 -263
- data/data/fx/usd/2000.json +1275 -259
- data/data/fx/usd/2001.json +1278 -258
- data/data/fx/usd/2002.json +1283 -259
- data/data/fx/usd/2003.json +1283 -259
- data/data/fx/usd/2004.json +1303 -263
- data/data/fx/usd/2005.json +1293 -261
- data/data/fx/usd/2006.json +1283 -259
- data/data/fx/usd/2007.json +1283 -259
- data/data/fx/usd/2008.json +1288 -260
- data/data/fx/usd/2009.json +1288 -260
- data/data/fx/usd/2010.json +1298 -262
- data/data/fx/usd/2011.json +1293 -261
- data/data/fx/usd/2012.json +1288 -260
- data/data/fx/usd/2013.json +1283 -259
- data/data/fx/usd/2014.json +1283 -259
- data/data/fx/usd/2015.json +1288 -260
- data/data/fx/usd/2016.json +1293 -261
- data/data/fx/usd/2017.json +1283 -259
- data/data/fx/usd/2018.json +1283 -259
- data/data/fx/usd/2019.json +1283 -259
- data/data/fx/usd/2020.json +1293 -261
- data/data/fx/usd/2021.json +1298 -262
- data/data/fx/usd/2022.json +1293 -261
- data/data/fx/usd/2023.json +1283 -259
- data/data/fx/usd/2024.json +1288 -260
- data/data/fx/usd/2025.json +1283 -259
- data/data/fx/usd/2026.json +458 -93
- data/data/fx/usd/_annual.json +47 -2
- data/data/manifest.json +156 -8
- data/lib/timeprice/cli.rb +6 -6
- data/lib/timeprice/compare.rb +36 -3
- data/lib/timeprice/cpi_lookup.rb +64 -18
- data/lib/timeprice/data_loader.rb +8 -13
- data/lib/timeprice/date.rb +62 -0
- data/lib/timeprice/exchange.rb +49 -23
- data/lib/timeprice/granularity.rb +41 -10
- data/lib/timeprice/inflation.rb +15 -7
- 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/supported.rb +1 -1
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +14 -1
- metadata +24 -1
|
@@ -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
|
|
@@ -4,20 +4,44 @@ module Timeprice
|
|
|
4
4
|
# Closed set of CPI-resolution granularities and the rules for combining /
|
|
5
5
|
# rendering them. Owns the lattice so callers don't hand-maintain it.
|
|
6
6
|
module Granularity
|
|
7
|
-
DAILY
|
|
8
|
-
MONTHLY
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
DAILY = :daily
|
|
8
|
+
MONTHLY = :monthly
|
|
9
|
+
QUARTERLY = :quarterly
|
|
10
|
+
ANNUAL = :annual
|
|
11
|
+
ANNUAL_FROM_MONTHLY_AVG = :annual_from_monthly_avg
|
|
12
|
+
ANNUAL_FROM_QUARTERLY_AVG = :annual_from_quarterly_avg
|
|
13
|
+
ANNUAL_FROM_PARTIAL_MONTHS = :annual_from_partial_months
|
|
14
|
+
ANNUAL_FROM_PARTIAL_QUARTERS = :annual_from_partial_quarters
|
|
15
|
+
QUARTERLY_FROM_ANNUAL_FALLBACK = :quarterly_from_annual_fallback
|
|
16
|
+
QUARTERLY_FROM_MONTHLY_AVG = :quarterly_from_monthly_avg
|
|
17
|
+
MONTHLY_FROM_QUARTERLY_FALLBACK = :monthly_from_quarterly_fallback
|
|
18
|
+
MONTHLY_FROM_ANNUAL_FALLBACK = :monthly_from_annual_fallback
|
|
12
19
|
|
|
13
|
-
# Most-degraded first — `merge` returns the first match.
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
20
|
+
# Most-degraded first — `merge` returns the first match. DAILY is the
|
|
21
|
+
# highest-precision FX tag; MONTHLY is the highest-precision CPI tag.
|
|
22
|
+
# Compare uses merge() across both legs, so the most-degraded tag in
|
|
23
|
+
# either leg wins.
|
|
24
|
+
#
|
|
25
|
+
# Ordering rationale (worst → best):
|
|
26
|
+
# 1. Cross-grain fallbacks where the asked resolution is finer than
|
|
27
|
+
# what's available (annual stretched to month/quarter).
|
|
28
|
+
# 2. Partial-period averages — asked annual but only some months/
|
|
29
|
+
# quarters in the year are populated. Highly biased by seasonality.
|
|
30
|
+
# 3. Same-or-coarser fallback (quarter stretched to month).
|
|
31
|
+
# 4. Full-period derived averages (complete 4-quarter or 12-month mean
|
|
32
|
+
# standing in for the asked coarser resolution).
|
|
33
|
+
# 5. Native series at the asked resolution.
|
|
17
34
|
PRECEDENCE = [
|
|
18
35
|
MONTHLY_FROM_ANNUAL_FALLBACK,
|
|
36
|
+
ANNUAL_FROM_PARTIAL_QUARTERS,
|
|
37
|
+
ANNUAL_FROM_PARTIAL_MONTHS,
|
|
38
|
+
QUARTERLY_FROM_ANNUAL_FALLBACK,
|
|
39
|
+
MONTHLY_FROM_QUARTERLY_FALLBACK,
|
|
40
|
+
ANNUAL_FROM_QUARTERLY_AVG,
|
|
41
|
+
QUARTERLY_FROM_MONTHLY_AVG,
|
|
19
42
|
ANNUAL_FROM_MONTHLY_AVG,
|
|
20
43
|
ANNUAL,
|
|
44
|
+
QUARTERLY,
|
|
21
45
|
MONTHLY,
|
|
22
46
|
DAILY,
|
|
23
47
|
].freeze
|
|
@@ -25,9 +49,16 @@ module Timeprice
|
|
|
25
49
|
HUMAN_LABELS = {
|
|
26
50
|
DAILY => "daily",
|
|
27
51
|
MONTHLY => "monthly",
|
|
52
|
+
QUARTERLY => "quarterly",
|
|
28
53
|
ANNUAL => "annual",
|
|
29
54
|
ANNUAL_FROM_MONTHLY_AVG => "annual (avg of months)",
|
|
30
|
-
|
|
55
|
+
ANNUAL_FROM_QUARTERLY_AVG => "annual (avg of quarters)",
|
|
56
|
+
ANNUAL_FROM_PARTIAL_MONTHS => "annual (partial-year, avg of available months)",
|
|
57
|
+
ANNUAL_FROM_PARTIAL_QUARTERS => "annual (partial-year, avg of available quarters)",
|
|
58
|
+
QUARTERLY_FROM_ANNUAL_FALLBACK => "quarter (annual fallback)",
|
|
59
|
+
QUARTERLY_FROM_MONTHLY_AVG => "quarter (avg of months)",
|
|
60
|
+
MONTHLY_FROM_QUARTERLY_FALLBACK => "month (quarter unavailable)",
|
|
61
|
+
MONTHLY_FROM_ANNUAL_FALLBACK => "month (annual fallback)",
|
|
31
62
|
}.freeze
|
|
32
63
|
|
|
33
64
|
module_function
|
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,31 +23,38 @@ 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
|
|
|
28
34
|
# Adjust `amount` from date `from` to date `to` using country CPI.
|
|
29
35
|
#
|
|
30
|
-
# Dates accept "YYYY" or "YYYY-
|
|
36
|
+
# Dates accept "YYYY", "YYYY-MM", or "YYYY-Qn" (Q1..Q4).
|
|
31
37
|
#
|
|
32
38
|
# @param amount [Numeric]
|
|
33
|
-
# @param from [String] source date ("YYYY" or "YYYY-
|
|
34
|
-
# @param to [String] target date ("YYYY" or "YYYY-
|
|
39
|
+
# @param from [String] source date ("YYYY", "YYYY-MM", or "YYYY-Qn")
|
|
40
|
+
# @param to [String] target date ("YYYY", "YYYY-MM", or "YYYY-Qn")
|
|
35
41
|
# @param country [String] country code (see {Supported.countries})
|
|
36
42
|
# @return [InflationResult]
|
|
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
|
data/lib/timeprice/supported.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Timeprice
|
|
|
10
10
|
module Supported
|
|
11
11
|
# Currencies with no minor unit — formatted as whole numbers. This is
|
|
12
12
|
# ISO 4217 metadata, not bundled data, so it stays hardcoded.
|
|
13
|
-
ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
|
|
13
|
+
ZERO_DECIMAL_CURRENCIES = %w[JPY KRW VND].freeze
|
|
14
14
|
|
|
15
15
|
module_function
|
|
16
16
|
|
data/lib/timeprice/version.rb
CHANGED
data/lib/timeprice.rb
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "timeprice/version"
|
|
4
|
+
require_relative "timeprice/errors"
|
|
5
|
+
require_relative "timeprice/schema"
|
|
6
|
+
require_relative "timeprice/date"
|
|
4
7
|
require_relative "timeprice/data_loader"
|
|
5
8
|
require_relative "timeprice/supported"
|
|
6
|
-
require_relative "timeprice/errors"
|
|
7
9
|
require_relative "timeprice/point"
|
|
8
10
|
require_relative "timeprice/inflation"
|
|
9
11
|
require_relative "timeprice/exchange"
|
|
10
12
|
require_relative "timeprice/compare"
|
|
11
13
|
require_relative "timeprice/sources"
|
|
14
|
+
require_relative "timeprice/metadata"
|
|
12
15
|
|
|
13
16
|
# Offline historical inflation & FX for Ruby.
|
|
14
17
|
#
|
|
@@ -62,4 +65,14 @@ module Timeprice
|
|
|
62
65
|
def compare(amount:, from:, to:)
|
|
63
66
|
Compare.run(amount: amount, from: from, to: to)
|
|
64
67
|
end
|
|
68
|
+
|
|
69
|
+
# Snapshot describing the bundled dataset: version, refresh date, country
|
|
70
|
+
# list with CPI ranges, currency list with display names, and FX coverage.
|
|
71
|
+
# Intended as the single source of truth for downstream UIs (the website
|
|
72
|
+
# in particular) so dropdowns and date pickers never drift from the data.
|
|
73
|
+
#
|
|
74
|
+
# @return [Hash] frozen, JSON-serialisable
|
|
75
|
+
def metadata
|
|
76
|
+
Metadata.build
|
|
77
|
+
end
|
|
65
78
|
end
|