timeprice 0.4.0 → 0.6.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 +52 -0
- data/DATA_LICENSES.md +16 -1
- data/README.md +29 -5
- data/data/cpi/eu.json +406 -403
- data/data/cpi/jp.json +78 -75
- data/data/cpi/uk.json +513 -510
- data/data/cpi/us.json +488 -485
- data/data/cpi/vn.json +342 -339
- data/data/fx/usd/1999.json +23 -7
- data/data/fx/usd/2000.json +23 -7
- data/data/fx/usd/2001.json +23 -7
- data/data/fx/usd/2002.json +23 -7
- data/data/fx/usd/2003.json +23 -7
- data/data/fx/usd/2004.json +23 -7
- data/data/fx/usd/2005.json +23 -7
- data/data/fx/usd/2006.json +23 -7
- data/data/fx/usd/2007.json +23 -7
- data/data/fx/usd/2008.json +23 -7
- data/data/fx/usd/2009.json +23 -7
- data/data/fx/usd/2010.json +23 -7
- data/data/fx/usd/2011.json +23 -7
- data/data/fx/usd/2012.json +23 -7
- data/data/fx/usd/2013.json +23 -7
- data/data/fx/usd/2014.json +23 -7
- data/data/fx/usd/2015.json +23 -7
- data/data/fx/usd/2016.json +23 -7
- data/data/fx/usd/2017.json +23 -7
- data/data/fx/usd/2018.json +23 -7
- data/data/fx/usd/2019.json +23 -7
- data/data/fx/usd/2020.json +23 -7
- data/data/fx/usd/2021.json +23 -7
- data/data/fx/usd/2022.json +23 -7
- data/data/fx/usd/2023.json +23 -7
- data/data/fx/usd/2024.json +23 -7
- data/data/fx/usd/2025.json +24 -5
- data/data/fx/usd/2026.json +24 -5
- data/data/fx/usd/_annual.json +145 -0
- data/data/manifest.json +90 -0
- data/lib/timeprice/cli.rb +3 -3
- data/lib/timeprice/compare.rb +1 -1
- data/lib/timeprice/cpi_lookup.rb +64 -18
- data/lib/timeprice/data_loader.rb +47 -8
- data/lib/timeprice/errors.rb +4 -4
- data/lib/timeprice/exchange.rb +8 -8
- data/lib/timeprice/granularity.rb +41 -10
- data/lib/timeprice/inflation.rb +5 -5
- data/lib/timeprice/sources/coverage.rb +27 -32
- data/lib/timeprice/supported.rb +39 -22
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +2 -2
- metadata +3 -15
- data/data/fx/usd/1983.json +0 -11
- data/data/fx/usd/1986.json +0 -11
- data/data/fx/usd/1987.json +0 -11
- data/data/fx/usd/1988.json +0 -11
- data/data/fx/usd/1989.json +0 -11
- data/data/fx/usd/1990.json +0 -11
- data/data/fx/usd/1991.json +0 -11
- data/data/fx/usd/1992.json +0 -11
- data/data/fx/usd/1993.json +0 -11
- data/data/fx/usd/1994.json +0 -11
- data/data/fx/usd/1995.json +0 -11
- data/data/fx/usd/1996.json +0 -11
- data/data/fx/usd/1997.json +0 -11
- data/data/fx/usd/1998.json +0 -11
data/data/manifest.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"countries": [
|
|
3
|
+
{
|
|
4
|
+
"code": "EU",
|
|
5
|
+
"cpi_file": "cpi/eu.json",
|
|
6
|
+
"currency": "EUR",
|
|
7
|
+
"granularities": [
|
|
8
|
+
"monthly",
|
|
9
|
+
"annual"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"code": "JP",
|
|
14
|
+
"cpi_file": "cpi/jp.json",
|
|
15
|
+
"currency": "JPY",
|
|
16
|
+
"granularities": [
|
|
17
|
+
"annual"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"code": "UK",
|
|
22
|
+
"cpi_file": "cpi/uk.json",
|
|
23
|
+
"currency": "GBP",
|
|
24
|
+
"granularities": [
|
|
25
|
+
"monthly",
|
|
26
|
+
"annual"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"code": "US",
|
|
31
|
+
"cpi_file": "cpi/us.json",
|
|
32
|
+
"currency": "USD",
|
|
33
|
+
"granularities": [
|
|
34
|
+
"monthly",
|
|
35
|
+
"annual"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"code": "VN",
|
|
40
|
+
"cpi_file": "cpi/vn.json",
|
|
41
|
+
"currency": "VND",
|
|
42
|
+
"granularities": [
|
|
43
|
+
"monthly",
|
|
44
|
+
"annual"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"fx": {
|
|
49
|
+
"annual_file": "fx/usd/_annual.json",
|
|
50
|
+
"base": "USD",
|
|
51
|
+
"currencies": [
|
|
52
|
+
"EUR",
|
|
53
|
+
"GBP",
|
|
54
|
+
"JPY",
|
|
55
|
+
"VND"
|
|
56
|
+
],
|
|
57
|
+
"daily_years": [
|
|
58
|
+
1999,
|
|
59
|
+
2000,
|
|
60
|
+
2001,
|
|
61
|
+
2002,
|
|
62
|
+
2003,
|
|
63
|
+
2004,
|
|
64
|
+
2005,
|
|
65
|
+
2006,
|
|
66
|
+
2007,
|
|
67
|
+
2008,
|
|
68
|
+
2009,
|
|
69
|
+
2010,
|
|
70
|
+
2011,
|
|
71
|
+
2012,
|
|
72
|
+
2013,
|
|
73
|
+
2014,
|
|
74
|
+
2015,
|
|
75
|
+
2016,
|
|
76
|
+
2017,
|
|
77
|
+
2018,
|
|
78
|
+
2019,
|
|
79
|
+
2020,
|
|
80
|
+
2021,
|
|
81
|
+
2022,
|
|
82
|
+
2023,
|
|
83
|
+
2024,
|
|
84
|
+
2025,
|
|
85
|
+
2026
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
"generated_at": "2026-05-11",
|
|
89
|
+
"schema_version": 4
|
|
90
|
+
}
|
data/lib/timeprice/cli.rb
CHANGED
|
@@ -76,9 +76,9 @@ module Timeprice
|
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
|
|
79
|
-
method_option :from, type: :string, required: true, desc: "Source date (YYYY or YYYY-
|
|
80
|
-
method_option :to, type: :string, required: true, desc: "Target date (YYYY or YYYY-
|
|
81
|
-
method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN)"
|
|
79
|
+
method_option :from, type: :string, required: true, desc: "Source date (YYYY, YYYY-MM, or YYYY-Qn)"
|
|
80
|
+
method_option :to, type: :string, required: true, desc: "Target date (YYYY, YYYY-MM, or YYYY-Qn)"
|
|
81
|
+
method_option :country, type: :string, required: true, desc: "Country code (US, UK, EU, JP, VN, AU, CA, KR, CN, RU)"
|
|
82
82
|
def inflation(amount)
|
|
83
83
|
with_error_handling do
|
|
84
84
|
result = Timeprice.inflation(
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Timeprice
|
|
|
38
38
|
# accepts a {Point} or a 2-tuple like `["USD", "2010"]` or `["USD", "2010-06"]`
|
|
39
39
|
# @param to [Timeprice::Point, Array(String, String)] destination point
|
|
40
40
|
# @return [CompareResult]
|
|
41
|
-
# @raise [UnsupportedCurrency] if either currency is not in {Supported
|
|
41
|
+
# @raise [UnsupportedCurrency] if either currency is not in {Supported.currencies}
|
|
42
42
|
def run(amount:, from:, to:)
|
|
43
43
|
from_point, to_point, to_country = resolve_points(from, to)
|
|
44
44
|
|
data/lib/timeprice/cpi_lookup.rb
CHANGED
|
@@ -8,54 +8,100 @@ module Timeprice
|
|
|
8
8
|
# resolved. See {Granularity} for the full set of possible tags.
|
|
9
9
|
CpiPoint = Data.define(:value, :granularity)
|
|
10
10
|
|
|
11
|
-
# Resolves CPI keys ("YYYY" or "YYYY-
|
|
12
|
-
# country's parsed CPI data hash. Knowing the JSON shape ("monthly"
|
|
13
|
-
# "annual" string keys) is isolated here — Inflation just
|
|
11
|
+
# Resolves CPI keys ("YYYY", "YYYY-MM", or "YYYY-Qn") to a CpiPoint against
|
|
12
|
+
# a single country's parsed CPI data hash. Knowing the JSON shape ("monthly"
|
|
13
|
+
# / "quarterly" / "annual" string keys) is isolated here — Inflation just
|
|
14
|
+
# asks for points.
|
|
14
15
|
class CpiLookup
|
|
16
|
+
QUARTER_RE = /\A(\d{4})-Q([1-4])\z/
|
|
17
|
+
|
|
15
18
|
def initialize(data)
|
|
16
19
|
@data = data
|
|
17
|
-
@monthly
|
|
18
|
-
@
|
|
20
|
+
@monthly = data.dig("series", "monthly") || {}
|
|
21
|
+
@quarterly = data.dig("series", "quarterly") || {}
|
|
22
|
+
@annual = data.dig("series", "annual") || {}
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
# @param key [String] "YYYY" or "YYYY-
|
|
25
|
+
# @param key [String] "YYYY", "YYYY-MM", or "YYYY-Qn"
|
|
22
26
|
# @return [CpiPoint]
|
|
23
27
|
# @raise [DataNotFound] if no CPI value covers `key`
|
|
24
28
|
# @raise [ArgumentError] on malformed `key`
|
|
25
29
|
def at(key)
|
|
26
30
|
key = key.to_s
|
|
27
31
|
case key
|
|
28
|
-
when
|
|
29
|
-
when /\A\d{4}\z/
|
|
30
|
-
|
|
32
|
+
when QUARTER_RE then quarterly_or_fallbacks(key)
|
|
33
|
+
when /\A\d{4}-\d{2}\z/ then monthly_or_fallbacks(key)
|
|
34
|
+
when /\A\d{4}\z/ then annual_or_derived(key)
|
|
35
|
+
else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY, YYYY-MM, or YYYY-Qn)"
|
|
31
36
|
end
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
private
|
|
35
40
|
|
|
36
|
-
def
|
|
41
|
+
def monthly_or_fallbacks(month_key)
|
|
37
42
|
return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
|
|
38
43
|
|
|
39
|
-
year = month_key
|
|
40
|
-
|
|
44
|
+
year, month = month_key.split("-").map(&:to_i)
|
|
45
|
+
qkey = format("%04d-Q%d", year, ((month - 1) / 3) + 1)
|
|
46
|
+
if @quarterly.key?(qkey)
|
|
47
|
+
return CpiPoint.new(value: @quarterly[qkey], granularity: Granularity::MONTHLY_FROM_QUARTERLY_FALLBACK)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
year_key = month_key[0, 4]
|
|
51
|
+
raise DataNotFound, missing_message(month_key) unless @annual.key?(year_key)
|
|
41
52
|
|
|
42
|
-
CpiPoint.new(value: @annual[
|
|
53
|
+
CpiPoint.new(value: @annual[year_key], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
|
|
43
54
|
end
|
|
44
55
|
|
|
45
|
-
def
|
|
56
|
+
def quarterly_or_fallbacks(quarter_key)
|
|
57
|
+
if @quarterly.key?(quarter_key)
|
|
58
|
+
return CpiPoint.new(value: @quarterly[quarter_key],
|
|
59
|
+
granularity: Granularity::QUARTERLY)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
year_int, q = quarter_key.match(QUARTER_RE).captures.map(&:to_i)
|
|
63
|
+
first_month = ((q - 1) * 3) + 1
|
|
64
|
+
last_month = q * 3
|
|
65
|
+
months = (first_month..last_month).map { |m| format("%04d-%02d", year_int, m) }
|
|
66
|
+
.map { |k| @monthly[k] }
|
|
67
|
+
.compact
|
|
68
|
+
if months.size == 3
|
|
69
|
+
return CpiPoint.new(value: months.sum.to_f / 3,
|
|
70
|
+
granularity: Granularity::QUARTERLY_FROM_MONTHLY_AVG)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
year = quarter_key[0, 4]
|
|
74
|
+
raise DataNotFound, missing_message(quarter_key) unless @annual.key?(year)
|
|
75
|
+
|
|
76
|
+
CpiPoint.new(value: @annual[year], granularity: Granularity::QUARTERLY_FROM_ANNUAL_FALLBACK)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def annual_or_derived(year)
|
|
46
80
|
return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
|
|
47
81
|
|
|
48
|
-
months
|
|
49
|
-
|
|
82
|
+
months = @monthly.select { |k, _| k.start_with?("#{year}-") }
|
|
83
|
+
quarters = @quarterly.select { |k, _| k.start_with?("#{year}-Q") }
|
|
84
|
+
|
|
85
|
+
# Prefer complete-period averages over partials, and within each, prefer
|
|
86
|
+
# monthly resolution. Partial tags distinguish biased estimates (e.g.
|
|
87
|
+
# only Jan-Feb populated) from a true full-year mean.
|
|
88
|
+
return average(months, 12, Granularity::ANNUAL_FROM_MONTHLY_AVG) if months.size == 12
|
|
89
|
+
return average(quarters, 4, Granularity::ANNUAL_FROM_QUARTERLY_AVG) if quarters.size == 4
|
|
90
|
+
return average(months, months.size, Granularity::ANNUAL_FROM_PARTIAL_MONTHS) if months.any?
|
|
91
|
+
return average(quarters, quarters.size, Granularity::ANNUAL_FROM_PARTIAL_QUARTERS) if quarters.any?
|
|
92
|
+
|
|
93
|
+
raise DataNotFound, missing_message(year)
|
|
94
|
+
end
|
|
50
95
|
|
|
51
|
-
|
|
52
|
-
CpiPoint.new(value:
|
|
96
|
+
def average(series, divisor, granularity)
|
|
97
|
+
CpiPoint.new(value: series.values.sum.to_f / divisor, granularity: granularity)
|
|
53
98
|
end
|
|
54
99
|
|
|
55
100
|
def missing_message(key)
|
|
56
101
|
country = @data["country"]
|
|
57
102
|
ranges = []
|
|
58
103
|
ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
|
|
104
|
+
ranges << "quarterly #{@quarterly.keys.min}..#{@quarterly.keys.max}" if @quarterly.any?
|
|
59
105
|
ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
|
|
60
106
|
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
61
107
|
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
@@ -2,19 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "errors"
|
|
5
|
-
require_relative "supported"
|
|
6
5
|
|
|
7
6
|
module Timeprice
|
|
8
7
|
# Loads and caches the bundled JSON data files. Override the search root
|
|
9
8
|
# by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
|
|
10
9
|
# {DataLoader.data_root=}.
|
|
11
10
|
module DataLoader
|
|
12
|
-
SUPPORTED_SCHEMA_VERSION =
|
|
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
|
|
13
16
|
|
|
14
17
|
DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
|
|
15
18
|
|
|
16
19
|
class << self
|
|
17
|
-
# @return [String] absolute path to the directory containing `cpi
|
|
20
|
+
# @return [String] absolute path to the directory containing `cpi/`, `fx/`, `manifest.json`.
|
|
18
21
|
def data_root
|
|
19
22
|
ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
|
|
20
23
|
end
|
|
@@ -32,19 +35,36 @@ module Timeprice
|
|
|
32
35
|
def clear_cache!
|
|
33
36
|
@cpi_cache = {}
|
|
34
37
|
@fx_cache = {}
|
|
38
|
+
@manifest_cache = {}
|
|
39
|
+
@annual_fallback_cache = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load the top-level manifest describing the bundled dataset.
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
# @raise [DataNotFound] if `manifest.json` is missing
|
|
45
|
+
def load_manifest
|
|
46
|
+
manifest_cache[data_root] ||= begin
|
|
47
|
+
path = File.join(data_root, "manifest.json")
|
|
48
|
+
unless File.exist?(path)
|
|
49
|
+
raise DataNotFound, "manifest.json missing (looked in #{path}). " \
|
|
50
|
+
"Check TIMEPRICE_DATA_ROOT or reinstall the gem."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
parse_with_schema(path)
|
|
54
|
+
end
|
|
35
55
|
end
|
|
36
56
|
|
|
37
57
|
# Load the CPI series for a supported country.
|
|
38
58
|
# @param country [String]
|
|
39
|
-
# @return [Hash] parsed JSON with "
|
|
40
|
-
# @raise [UnsupportedCountry] if `country` is not in {Supported
|
|
59
|
+
# @return [Hash] parsed JSON with "series" / "index" / "provenance" / "providers"
|
|
60
|
+
# @raise [UnsupportedCountry] if `country` is not in {Supported.countries}
|
|
41
61
|
# @raise [DataNotFound] if the file is missing
|
|
42
62
|
# @raise [UnsupportedSchemaVersion] if the file uses a future schema
|
|
43
63
|
def load_cpi(country)
|
|
44
64
|
key = country.to_s.downcase
|
|
45
65
|
code = country.to_s.upcase
|
|
46
66
|
cpi_cache[[data_root, key]] ||= begin
|
|
47
|
-
raise UnsupportedCountry, code unless Supported
|
|
67
|
+
raise UnsupportedCountry, code unless Supported.country?(code)
|
|
48
68
|
|
|
49
69
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
50
70
|
unless File.exist?(path)
|
|
@@ -58,7 +78,7 @@ module Timeprice
|
|
|
58
78
|
|
|
59
79
|
# Load the FX rates for a year.
|
|
60
80
|
# @param year [Integer, String]
|
|
61
|
-
# @return [Hash] parsed JSON with
|
|
81
|
+
# @return [Hash] parsed JSON with `rates` (and optional `annual`) blocks
|
|
62
82
|
# @raise [DataNotFound] if the per-year file is missing
|
|
63
83
|
def load_fx_year(year)
|
|
64
84
|
key = year.to_i
|
|
@@ -70,6 +90,17 @@ module Timeprice
|
|
|
70
90
|
end
|
|
71
91
|
end
|
|
72
92
|
|
|
93
|
+
# Load the sparse historical FX annual-only fallback file, if present.
|
|
94
|
+
# Returns nil when no fallback file ships with this data root.
|
|
95
|
+
# @return [Hash, nil]
|
|
96
|
+
def load_fx_annual_fallback
|
|
97
|
+
return @annual_fallback_cache[data_root] if @annual_fallback_cache&.key?(data_root)
|
|
98
|
+
|
|
99
|
+
@annual_fallback_cache ||= {}
|
|
100
|
+
path = File.join(data_root, "fx", "usd", "_annual.json")
|
|
101
|
+
@annual_fallback_cache[data_root] = File.exist?(path) ? parse_with_schema(path) : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
73
104
|
private
|
|
74
105
|
|
|
75
106
|
def cpi_cache
|
|
@@ -80,13 +111,21 @@ module Timeprice
|
|
|
80
111
|
@fx_cache ||= {}
|
|
81
112
|
end
|
|
82
113
|
|
|
114
|
+
def manifest_cache
|
|
115
|
+
@manifest_cache ||= {}
|
|
116
|
+
end
|
|
117
|
+
|
|
83
118
|
def parse_with_schema(path)
|
|
84
119
|
data = JSON.parse(File.read(path))
|
|
85
120
|
version = data["schema_version"]
|
|
86
|
-
raise UnsupportedSchemaVersion.new(version, path) unless version
|
|
121
|
+
raise UnsupportedSchemaVersion.new(version, path) unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
|
|
87
122
|
|
|
88
123
|
data
|
|
89
124
|
end
|
|
90
125
|
end
|
|
91
126
|
end
|
|
92
127
|
end
|
|
128
|
+
|
|
129
|
+
# Supported is loaded by the top-level entry point. Referenced lazily inside
|
|
130
|
+
# load_cpi to avoid a require cycle (Supported reads the manifest via DataLoader).
|
|
131
|
+
require_relative "supported" unless defined?(Timeprice::Supported)
|
data/lib/timeprice/errors.rb
CHANGED
|
@@ -7,23 +7,23 @@ module Timeprice
|
|
|
7
7
|
# to handle anything the gem can throw at you.
|
|
8
8
|
class Error < StandardError; end
|
|
9
9
|
|
|
10
|
-
# Raised when a country code is not in {Supported
|
|
10
|
+
# Raised when a country code is not in {Supported.countries}.
|
|
11
11
|
class UnsupportedCountry < Error
|
|
12
12
|
attr_reader :country
|
|
13
13
|
|
|
14
14
|
def initialize(country)
|
|
15
15
|
@country = country
|
|
16
|
-
super("Unsupported country: #{country.inspect} (supported: #{Supported
|
|
16
|
+
super("Unsupported country: #{country.inspect} (supported: #{Supported.countries.join(", ")})")
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
# Raised when a currency code is not in {Supported
|
|
20
|
+
# Raised when a currency code is not in {Supported.currencies}.
|
|
21
21
|
class UnsupportedCurrency < Error
|
|
22
22
|
attr_reader :currency
|
|
23
23
|
|
|
24
24
|
def initialize(currency)
|
|
25
25
|
@currency = currency
|
|
26
|
-
super("Unsupported currency: #{currency.inspect} (supported: #{Supported
|
|
26
|
+
super("Unsupported currency: #{currency.inspect} (supported: #{Supported.currencies.join(", ")})")
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -33,8 +33,8 @@ module Timeprice
|
|
|
33
33
|
def convert(amount:, from:, to:, date:)
|
|
34
34
|
from = from.to_s.upcase
|
|
35
35
|
to = to.to_s.upcase
|
|
36
|
-
raise UnsupportedCurrency, from unless Supported
|
|
37
|
-
raise UnsupportedCurrency, to unless Supported
|
|
36
|
+
raise UnsupportedCurrency, from unless Supported.currency?(from)
|
|
37
|
+
raise UnsupportedCurrency, to unless Supported.currency?(to)
|
|
38
38
|
|
|
39
39
|
d = parse_date(date)
|
|
40
40
|
|
|
@@ -82,7 +82,7 @@ module Timeprice
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
# Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall
|
|
85
|
-
# back to the
|
|
85
|
+
# back to data/fx/usd/_annual.json (the single source of annual FX truth).
|
|
86
86
|
# Returns [rate, effective_date, granularity].
|
|
87
87
|
def lookup_usd_base(currency, d)
|
|
88
88
|
(0..MAX_FALLBACK_DAYS).each do |offset|
|
|
@@ -108,12 +108,12 @@ module Timeprice
|
|
|
108
108
|
raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
# Consult
|
|
111
|
+
# Consult data/fx/usd/_annual.json. Returns Float or nil.
|
|
112
112
|
def annual_fallback(currency, year)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
fallback = DataLoader.load_fx_annual_fallback
|
|
114
|
+
return nil unless fallback
|
|
115
|
+
|
|
116
|
+
fallback.dig("annual", year.to_s, currency)&.to_f
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
def parse_date(date)
|
|
@@ -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
|
@@ -21,18 +21,18 @@ module Timeprice
|
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
# CPI-based inflation adjustment for the {Supported
|
|
24
|
+
# CPI-based inflation adjustment for the {Supported.countries} list.
|
|
25
25
|
module Inflation
|
|
26
26
|
module_function
|
|
27
27
|
|
|
28
28
|
# Adjust `amount` from date `from` to date `to` using country CPI.
|
|
29
29
|
#
|
|
30
|
-
# Dates accept "YYYY" or "YYYY-
|
|
30
|
+
# Dates accept "YYYY", "YYYY-MM", or "YYYY-Qn" (Q1..Q4).
|
|
31
31
|
#
|
|
32
32
|
# @param amount [Numeric]
|
|
33
|
-
# @param from [String] source date ("YYYY" or "YYYY-
|
|
34
|
-
# @param to [String] target date ("YYYY" or "YYYY-
|
|
35
|
-
# @param country [String] country code (see {Supported
|
|
33
|
+
# @param from [String] source date ("YYYY", "YYYY-MM", or "YYYY-Qn")
|
|
34
|
+
# @param to [String] target date ("YYYY", "YYYY-MM", or "YYYY-Qn")
|
|
35
|
+
# @param country [String] country code (see {Supported.countries})
|
|
36
36
|
# @return [InflationResult]
|
|
37
37
|
# @raise [UnsupportedCountry] if `country` is not supported
|
|
38
38
|
# @raise [DataNotFound] if no CPI data covers the requested period
|
|
@@ -5,9 +5,10 @@ require_relative "../data_loader"
|
|
|
5
5
|
|
|
6
6
|
module Timeprice
|
|
7
7
|
module Sources
|
|
8
|
-
# Computes coverage strings for bundled data sources at runtime
|
|
9
|
-
#
|
|
10
|
-
# a pure data table
|
|
8
|
+
# Computes coverage strings for bundled data sources at runtime by reading
|
|
9
|
+
# the structured `provenance` blocks in v3 data files. The Sources
|
|
10
|
+
# attribution registry stays a pure data table; Coverage is the only
|
|
11
|
+
# place that touches the filesystem.
|
|
11
12
|
module Coverage
|
|
12
13
|
module_function
|
|
13
14
|
|
|
@@ -25,46 +26,40 @@ module Timeprice
|
|
|
25
26
|
|
|
26
27
|
def cpi(country)
|
|
27
28
|
data = DataLoader.load_cpi(country)
|
|
28
|
-
monthly = (
|
|
29
|
-
annual = (
|
|
29
|
+
monthly = data.dig("series", "monthly") || {}
|
|
30
|
+
annual = data.dig("series", "annual") || {}
|
|
30
31
|
parts = []
|
|
31
|
-
parts << "monthly #{monthly.
|
|
32
|
-
parts << "annual #{annual.
|
|
32
|
+
parts << "monthly #{monthly.keys.min}..#{monthly.keys.max} (#{monthly.size})" if monthly.any?
|
|
33
|
+
parts << "annual #{annual.keys.min}..#{annual.keys.max} (#{annual.size})" if annual.any?
|
|
33
34
|
parts.join(", ")
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def fx(id)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def fx_years
|
|
44
|
-
Dir[File.join(fx_root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def vnd_summary(years)
|
|
48
|
-
with_vnd = years.select { |y| year_has_currency?(y, %w[VND]) }
|
|
49
|
-
return "no VND data" if with_vnd.empty?
|
|
50
|
-
|
|
51
|
-
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
38
|
+
case id
|
|
39
|
+
when "fx_ecb" then ecb_summary
|
|
40
|
+
when "fx_vnd" then vnd_summary
|
|
41
|
+
else "n/a"
|
|
42
|
+
end
|
|
52
43
|
end
|
|
53
44
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
# Frankfurter (ECB) → daily EUR/GBP/JPY in per-year files. Range derived
|
|
46
|
+
# from the manifest's `daily_years` list.
|
|
47
|
+
def ecb_summary
|
|
48
|
+
years = DataLoader.load_manifest.dig("fx", "daily_years") || []
|
|
49
|
+
return "no ECB data" if years.empty?
|
|
57
50
|
|
|
58
|
-
"USD↔EUR/GBP/JPY daily #{
|
|
51
|
+
"USD↔EUR/GBP/JPY daily #{years.first}..#{years.last}"
|
|
59
52
|
end
|
|
60
53
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
# All annual FX (today only VND) lives in data/fx/usd/_annual.json.
|
|
55
|
+
def vnd_summary
|
|
56
|
+
fallback = DataLoader.load_fx_annual_fallback
|
|
57
|
+
years = (fallback&.dig("annual") || {})
|
|
58
|
+
.select { |_y, ccy_hash| ccy_hash.key?("VND") }
|
|
59
|
+
.keys.map(&:to_i).sort
|
|
60
|
+
return "no VND data" if years.empty?
|
|
65
61
|
|
|
66
|
-
|
|
67
|
-
File.join(DataLoader.data_root, "fx", "usd")
|
|
62
|
+
"USD↔VND #{years.first}..#{years.last}"
|
|
68
63
|
end
|
|
69
64
|
end
|
|
70
65
|
end
|