timeprice 0.6.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 +46 -0
- data/README.md +33 -2
- 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 +1 -1
- data/data/cpi/jp.json +1 -1
- data/data/cpi/kr.json +549 -0
- data/data/cpi/ru.json +487 -0
- data/data/cpi/uk.json +1 -1
- data/data/cpi/us.json +1 -1
- data/data/cpi/vn.json +26 -26
- data/data/fx/usd/1999.json +1042 -262
- data/data/fx/usd/2000.json +1274 -258
- data/data/fx/usd/2001.json +1277 -257
- data/data/fx/usd/2002.json +1282 -258
- data/data/fx/usd/2003.json +1282 -258
- data/data/fx/usd/2004.json +1302 -262
- data/data/fx/usd/2005.json +1292 -260
- data/data/fx/usd/2006.json +1282 -258
- data/data/fx/usd/2007.json +1282 -258
- data/data/fx/usd/2008.json +1287 -259
- data/data/fx/usd/2009.json +1287 -259
- data/data/fx/usd/2010.json +1297 -261
- data/data/fx/usd/2011.json +1292 -260
- data/data/fx/usd/2012.json +1287 -259
- data/data/fx/usd/2013.json +1282 -258
- data/data/fx/usd/2014.json +1282 -258
- data/data/fx/usd/2015.json +1287 -259
- data/data/fx/usd/2016.json +1292 -260
- data/data/fx/usd/2017.json +1282 -258
- data/data/fx/usd/2018.json +1282 -258
- data/data/fx/usd/2019.json +1282 -258
- data/data/fx/usd/2020.json +1292 -260
- data/data/fx/usd/2021.json +1297 -261
- data/data/fx/usd/2022.json +1292 -260
- data/data/fx/usd/2023.json +1282 -258
- data/data/fx/usd/2024.json +1287 -259
- data/data/fx/usd/2025.json +1282 -258
- data/data/fx/usd/2026.json +457 -92
- data/data/fx/usd/_annual.json +46 -1
- data/data/manifest.json +155 -7
- data/lib/timeprice/cli.rb +3 -3
- data/lib/timeprice/compare.rb +36 -3
- data/lib/timeprice/cpi_lookup.rb +4 -4
- 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/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/version.rb +1 -1
- data/lib/timeprice.rb +14 -1
- metadata +24 -1
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
|
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
|
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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: timeprice
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '1.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: lefthook
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.1'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.1'
|
|
26
40
|
- !ruby/object:Gem::Dependency
|
|
27
41
|
name: rake
|
|
28
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -107,8 +121,13 @@ files:
|
|
|
107
121
|
- LICENSE.txt
|
|
108
122
|
- NOTICE
|
|
109
123
|
- README.md
|
|
124
|
+
- data/cpi/au.json
|
|
125
|
+
- data/cpi/ca.json
|
|
126
|
+
- data/cpi/cn.json
|
|
110
127
|
- data/cpi/eu.json
|
|
111
128
|
- data/cpi/jp.json
|
|
129
|
+
- data/cpi/kr.json
|
|
130
|
+
- data/cpi/ru.json
|
|
112
131
|
- data/cpi/uk.json
|
|
113
132
|
- data/cpi/us.json
|
|
114
133
|
- data/cpi/vn.json
|
|
@@ -153,11 +172,15 @@ files:
|
|
|
153
172
|
- lib/timeprice/compare.rb
|
|
154
173
|
- lib/timeprice/cpi_lookup.rb
|
|
155
174
|
- lib/timeprice/data_loader.rb
|
|
175
|
+
- lib/timeprice/date.rb
|
|
156
176
|
- lib/timeprice/errors.rb
|
|
157
177
|
- lib/timeprice/exchange.rb
|
|
158
178
|
- lib/timeprice/granularity.rb
|
|
159
179
|
- lib/timeprice/inflation.rb
|
|
180
|
+
- lib/timeprice/metadata.rb
|
|
181
|
+
- lib/timeprice/metadata_snapshot.rb
|
|
160
182
|
- lib/timeprice/point.rb
|
|
183
|
+
- lib/timeprice/schema.rb
|
|
161
184
|
- lib/timeprice/sources.rb
|
|
162
185
|
- lib/timeprice/sources/coverage.rb
|
|
163
186
|
- lib/timeprice/supported.rb
|