timeprice 0.2.0 → 0.4.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 +82 -0
- data/DATA_LICENSES.md +2 -1
- data/README.md +15 -6
- data/data/cpi/eu.json +23 -1
- data/data/cpi/jp.json +18 -2
- data/data/cpi/uk.json +23 -1
- data/data/cpi/us.json +29 -1
- data/data/cpi/vn.json +362 -34
- data/data/fx/usd/1983.json +7 -8
- data/data/fx/usd/1986.json +7 -8
- data/data/fx/usd/1987.json +7 -8
- data/data/fx/usd/1988.json +7 -8
- data/data/fx/usd/1989.json +7 -8
- data/data/fx/usd/1990.json +7 -8
- data/data/fx/usd/1991.json +7 -8
- data/data/fx/usd/1992.json +7 -8
- data/data/fx/usd/1993.json +7 -8
- data/data/fx/usd/1994.json +7 -8
- data/data/fx/usd/1995.json +7 -8
- data/data/fx/usd/1996.json +7 -8
- data/data/fx/usd/1997.json +7 -8
- data/data/fx/usd/1998.json +7 -8
- data/data/fx/usd/1999.json +266 -525
- data/data/fx/usd/2000.json +262 -517
- data/data/fx/usd/2001.json +261 -512
- data/data/fx/usd/2002.json +262 -514
- data/data/fx/usd/2003.json +262 -514
- data/data/fx/usd/2004.json +266 -522
- data/data/fx/usd/2005.json +264 -521
- data/data/fx/usd/2006.json +262 -514
- data/data/fx/usd/2007.json +262 -514
- data/data/fx/usd/2008.json +263 -516
- data/data/fx/usd/2009.json +263 -516
- data/data/fx/usd/2010.json +265 -523
- data/data/fx/usd/2011.json +264 -521
- data/data/fx/usd/2012.json +263 -516
- data/data/fx/usd/2013.json +262 -514
- data/data/fx/usd/2014.json +262 -514
- data/data/fx/usd/2015.json +263 -516
- data/data/fx/usd/2016.json +264 -521
- data/data/fx/usd/2017.json +262 -514
- data/data/fx/usd/2018.json +262 -514
- data/data/fx/usd/2019.json +262 -514
- data/data/fx/usd/2020.json +264 -518
- data/data/fx/usd/2021.json +265 -523
- data/data/fx/usd/2022.json +264 -521
- data/data/fx/usd/2023.json +262 -514
- data/data/fx/usd/2024.json +263 -516
- data/data/fx/usd/2025.json +5 -5
- data/data/fx/usd/2026.json +5 -5
- data/lib/timeprice/cli/formatting.rb +34 -0
- data/lib/timeprice/cli/presenters/compare.rb +46 -0
- data/lib/timeprice/cli/presenters/exchange.rb +45 -0
- data/lib/timeprice/cli/presenters/inflation.rb +37 -0
- data/lib/timeprice/cli/presenters/sources.rb +65 -0
- data/lib/timeprice/cli.rb +83 -114
- data/lib/timeprice/compare.rb +17 -34
- data/lib/timeprice/cpi_lookup.rb +64 -0
- data/lib/timeprice/data_loader.rb +13 -6
- data/lib/timeprice/exchange.rb +35 -17
- data/lib/timeprice/granularity.rb +46 -0
- data/lib/timeprice/inflation.rb +20 -71
- data/lib/timeprice/point.rb +30 -11
- data/lib/timeprice/sources/coverage.rb +71 -0
- data/lib/timeprice/sources.rb +7 -54
- data/lib/timeprice/supported.rb +12 -5
- data/lib/timeprice/version.rb +1 -1
- metadata +9 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "granularity"
|
|
5
|
+
|
|
6
|
+
module Timeprice
|
|
7
|
+
# CpiPoint pairs a CPI index value with the granularity of how it was
|
|
8
|
+
# resolved. See {Granularity} for the full set of possible tags.
|
|
9
|
+
CpiPoint = Data.define(:value, :granularity)
|
|
10
|
+
|
|
11
|
+
# Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
|
|
12
|
+
# country's parsed CPI data hash. Knowing the JSON shape ("monthly" /
|
|
13
|
+
# "annual" string keys) is isolated here — Inflation just asks for points.
|
|
14
|
+
class CpiLookup
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@data = data
|
|
17
|
+
@monthly = data["monthly"] || {}
|
|
18
|
+
@annual = data["annual"] || {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param key [String] "YYYY" or "YYYY-MM"
|
|
22
|
+
# @return [CpiPoint]
|
|
23
|
+
# @raise [DataNotFound] if no CPI value covers `key`
|
|
24
|
+
# @raise [ArgumentError] on malformed `key`
|
|
25
|
+
def at(key)
|
|
26
|
+
key = key.to_s
|
|
27
|
+
case key
|
|
28
|
+
when /\A\d{4}-\d{2}\z/ then monthly_or_annual_fallback(key)
|
|
29
|
+
when /\A\d{4}\z/ then annual_or_monthly_average(key)
|
|
30
|
+
else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def monthly_or_annual_fallback(month_key)
|
|
37
|
+
return CpiPoint.new(value: @monthly[month_key], granularity: Granularity::MONTHLY) if @monthly.key?(month_key)
|
|
38
|
+
|
|
39
|
+
year = month_key[0, 4]
|
|
40
|
+
raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
|
|
41
|
+
|
|
42
|
+
CpiPoint.new(value: @annual[year], granularity: Granularity::MONTHLY_FROM_ANNUAL_FALLBACK)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def annual_or_monthly_average(year)
|
|
46
|
+
return CpiPoint.new(value: @annual[year], granularity: Granularity::ANNUAL) if @annual.key?(year)
|
|
47
|
+
|
|
48
|
+
months = @monthly.select { |k, _| k.start_with?("#{year}-") }
|
|
49
|
+
raise DataNotFound, missing_message(year) if months.empty?
|
|
50
|
+
|
|
51
|
+
avg = months.values.sum.to_f / months.size
|
|
52
|
+
CpiPoint.new(value: avg, granularity: Granularity::ANNUAL_FROM_MONTHLY_AVG)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def missing_message(key)
|
|
56
|
+
country = @data["country"]
|
|
57
|
+
ranges = []
|
|
58
|
+
ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
|
|
59
|
+
ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
|
|
60
|
+
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
61
|
+
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "supported"
|
|
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 =
|
|
12
|
+
SUPPORTED_SCHEMA_VERSION = 2
|
|
12
13
|
|
|
13
14
|
DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
|
|
14
15
|
|
|
@@ -40,11 +41,10 @@ module Timeprice
|
|
|
40
41
|
# @raise [DataNotFound] if the file is missing
|
|
41
42
|
# @raise [UnsupportedSchemaVersion] if the file uses a future schema
|
|
42
43
|
def load_cpi(country)
|
|
43
|
-
@cpi_cache ||= {}
|
|
44
44
|
key = country.to_s.downcase
|
|
45
45
|
code = country.to_s.upcase
|
|
46
|
-
|
|
47
|
-
raise UnsupportedCountry, code unless
|
|
46
|
+
cpi_cache[[data_root, key]] ||= begin
|
|
47
|
+
raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
|
|
48
48
|
|
|
49
49
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
50
50
|
unless File.exist?(path)
|
|
@@ -61,9 +61,8 @@ module Timeprice
|
|
|
61
61
|
# @return [Hash] parsed JSON with a "rates" map of date → currency → Float
|
|
62
62
|
# @raise [DataNotFound] if the per-year file is missing
|
|
63
63
|
def load_fx_year(year)
|
|
64
|
-
@fx_cache ||= {}
|
|
65
64
|
key = year.to_i
|
|
66
|
-
|
|
65
|
+
fx_cache[[data_root, key]] ||= begin
|
|
67
66
|
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
68
67
|
raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
69
68
|
|
|
@@ -73,6 +72,14 @@ module Timeprice
|
|
|
73
72
|
|
|
74
73
|
private
|
|
75
74
|
|
|
75
|
+
def cpi_cache
|
|
76
|
+
@cpi_cache ||= {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fx_cache
|
|
80
|
+
@fx_cache ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
76
83
|
def parse_with_schema(path)
|
|
77
84
|
data = JSON.parse(File.read(path))
|
|
78
85
|
version = data["schema_version"]
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
require "date"
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
require_relative "data_loader"
|
|
6
|
+
require_relative "supported"
|
|
7
|
+
require_relative "granularity"
|
|
6
8
|
|
|
7
9
|
module Timeprice
|
|
8
10
|
ExchangeResult = Data.define(
|
|
9
|
-
:amount, :original_amount, :from, :to, :date, :effective_date, :rate
|
|
11
|
+
:amount, :original_amount, :from, :to, :date, :effective_date, :rate, :granularity
|
|
10
12
|
)
|
|
11
13
|
|
|
12
14
|
# Historical FX conversion using bundled per-year USD-base rate files.
|
|
@@ -31,12 +33,12 @@ module Timeprice
|
|
|
31
33
|
def convert(amount:, from:, to:, date:)
|
|
32
34
|
from = from.to_s.upcase
|
|
33
35
|
to = to.to_s.upcase
|
|
34
|
-
raise UnsupportedCurrency, from unless
|
|
35
|
-
raise UnsupportedCurrency, to unless
|
|
36
|
+
raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
|
|
37
|
+
raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
|
|
36
38
|
|
|
37
39
|
d = parse_date(date)
|
|
38
40
|
|
|
39
|
-
rate, eff_date = resolve_rate(from, to, d)
|
|
41
|
+
rate, eff_date, granularity = resolve_rate(from, to, d)
|
|
40
42
|
ExchangeResult.new(
|
|
41
43
|
amount: amount.to_f * rate,
|
|
42
44
|
original_amount: amount.to_f,
|
|
@@ -44,40 +46,44 @@ module Timeprice
|
|
|
44
46
|
to: to,
|
|
45
47
|
date: d.to_s,
|
|
46
48
|
effective_date: eff_date.to_s,
|
|
47
|
-
rate: rate
|
|
49
|
+
rate: rate,
|
|
50
|
+
granularity: granularity
|
|
48
51
|
)
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
# Returns [rate (Float), effective_date (Date)].
|
|
54
|
+
# Returns [rate (Float), effective_date (Date), granularity (Symbol)].
|
|
55
|
+
# Granularity is :daily when the rate came from a per-date entry, :annual
|
|
56
|
+
# when it came from the per-year `annual` fallback block. Triangulation
|
|
57
|
+
# merges both legs via Granularity.merge (worst-precision-wins).
|
|
52
58
|
# Handles:
|
|
53
59
|
# - identity (from == to)
|
|
54
60
|
# - direct lookup of USD-base rate
|
|
55
61
|
# - inverse (foreign → USD)
|
|
56
62
|
# - triangulation through USD (both legs must resolve to SAME effective date)
|
|
57
63
|
def resolve_rate(from, to, d)
|
|
58
|
-
return [1.0, d] if from == to
|
|
64
|
+
return [1.0, d, Granularity::DAILY] if from == to
|
|
59
65
|
|
|
60
66
|
if from == BASE
|
|
61
|
-
|
|
62
|
-
[rate, eff]
|
|
67
|
+
lookup_usd_base(to, d)
|
|
63
68
|
elsif to == BASE
|
|
64
|
-
rate, eff = lookup_usd_base(from, d)
|
|
65
|
-
[1.0 / rate, eff]
|
|
69
|
+
rate, eff, gran = lookup_usd_base(from, d)
|
|
70
|
+
[1.0 / rate, eff, gran]
|
|
66
71
|
else
|
|
67
72
|
# Triangulation: from → USD → to, both legs at the same effective date.
|
|
68
|
-
usd_to_from, eff_a = lookup_usd_base(from, d)
|
|
69
|
-
usd_to_to, eff_b = lookup_usd_base(to, d)
|
|
73
|
+
usd_to_from, eff_a, gran_a = lookup_usd_base(from, d)
|
|
74
|
+
usd_to_to, eff_b, gran_b = lookup_usd_base(to, d)
|
|
70
75
|
if eff_a != eff_b
|
|
71
76
|
raise DataNotFound,
|
|
72
77
|
"FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
|
|
73
78
|
"USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
|
|
74
79
|
end
|
|
75
|
-
[usd_to_to / usd_to_from, eff_a]
|
|
80
|
+
[usd_to_to / usd_to_from, eff_a, Granularity.merge(gran_a, gran_b)]
|
|
76
81
|
end
|
|
77
82
|
end
|
|
78
83
|
|
|
79
|
-
# Walk back up to MAX_FALLBACK_DAYS to find a rate
|
|
80
|
-
#
|
|
84
|
+
# Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall
|
|
85
|
+
# back to the year file's top-level `annual` block.
|
|
86
|
+
# Returns [rate, effective_date, granularity].
|
|
81
87
|
def lookup_usd_base(currency, d)
|
|
82
88
|
(0..MAX_FALLBACK_DAYS).each do |offset|
|
|
83
89
|
candidate = d - offset
|
|
@@ -93,11 +99,23 @@ module Timeprice
|
|
|
93
99
|
rate = rates_for_day[currency]
|
|
94
100
|
next unless rate
|
|
95
101
|
|
|
96
|
-
return [rate.to_f, candidate]
|
|
102
|
+
return [rate.to_f, candidate, Granularity::DAILY]
|
|
97
103
|
end
|
|
104
|
+
|
|
105
|
+
annual_rate = annual_fallback(currency, d.year)
|
|
106
|
+
return [annual_rate, d, Granularity::ANNUAL] if annual_rate
|
|
107
|
+
|
|
98
108
|
raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
|
|
99
109
|
end
|
|
100
110
|
|
|
111
|
+
# Consult the year file's top-level `annual` block. Returns Float or nil.
|
|
112
|
+
def annual_fallback(currency, year)
|
|
113
|
+
year_data = DataLoader.load_fx_year(year)
|
|
114
|
+
year_data.dig("annual", currency)&.to_f
|
|
115
|
+
rescue DataNotFound
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
101
119
|
def parse_date(date)
|
|
102
120
|
case date
|
|
103
121
|
when Date then date
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Timeprice
|
|
4
|
+
# Closed set of CPI-resolution granularities and the rules for combining /
|
|
5
|
+
# rendering them. Owns the lattice so callers don't hand-maintain it.
|
|
6
|
+
module Granularity
|
|
7
|
+
DAILY = :daily
|
|
8
|
+
MONTHLY = :monthly
|
|
9
|
+
ANNUAL = :annual
|
|
10
|
+
ANNUAL_FROM_MONTHLY_AVG = :annual_from_monthly_avg
|
|
11
|
+
MONTHLY_FROM_ANNUAL_FALLBACK = :monthly_from_annual_fallback
|
|
12
|
+
|
|
13
|
+
# Most-degraded first — `merge` returns the first match.
|
|
14
|
+
# DAILY is the highest-precision FX tag; MONTHLY is the highest-precision
|
|
15
|
+
# CPI tag. Compare uses merge() across both legs, so the most-degraded
|
|
16
|
+
# tag in either leg wins.
|
|
17
|
+
PRECEDENCE = [
|
|
18
|
+
MONTHLY_FROM_ANNUAL_FALLBACK,
|
|
19
|
+
ANNUAL_FROM_MONTHLY_AVG,
|
|
20
|
+
ANNUAL,
|
|
21
|
+
MONTHLY,
|
|
22
|
+
DAILY,
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
HUMAN_LABELS = {
|
|
26
|
+
DAILY => "daily",
|
|
27
|
+
MONTHLY => "monthly",
|
|
28
|
+
ANNUAL => "annual",
|
|
29
|
+
ANNUAL_FROM_MONTHLY_AVG => "annual (avg of months)",
|
|
30
|
+
MONTHLY_FROM_ANNUAL_FALLBACK => "annual (month unavailable)",
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Worst-precision-wins merge across two or more endpoint granularities.
|
|
36
|
+
def merge(*tags)
|
|
37
|
+
PRECEDENCE.find { |t| tags.include?(t) } || MONTHLY
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Human-readable label for CLI output. Falls through to the symbol's
|
|
41
|
+
# string form so an unknown tag still renders something.
|
|
42
|
+
def humanize(tag)
|
|
43
|
+
HUMAN_LABELS.fetch(tag, tag.to_s)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -2,19 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
4
|
require_relative "data_loader"
|
|
5
|
+
require_relative "cpi_lookup"
|
|
6
|
+
require_relative "granularity"
|
|
5
7
|
|
|
6
8
|
module Timeprice
|
|
7
|
-
# Value object returned by Inflation.adjust.
|
|
8
|
-
#
|
|
9
|
-
# granularity is one of:
|
|
10
|
-
# :monthly — both ends resolved on monthly data
|
|
11
|
-
# :annual — at least one end resolved on annual data
|
|
12
|
-
# :annual_from_monthly_avg — at least one end was an annual request resolved
|
|
13
|
-
# by averaging 12 months of monthly data
|
|
9
|
+
# Value object returned by Inflation.adjust. See {Granularity} for the set
|
|
10
|
+
# of possible `granularity` values and the worst-precision-wins merge rule.
|
|
14
11
|
InflationResult = Data.define(
|
|
15
12
|
:amount, :original_amount, :from, :to, :country,
|
|
16
13
|
:from_index, :to_index, :granularity
|
|
17
|
-
)
|
|
14
|
+
) do
|
|
15
|
+
# The country's primary currency (e.g. "USD" for "US"). Falls back to the
|
|
16
|
+
# uppercased country code if the country isn't in the supported map —
|
|
17
|
+
# callers can still render *some* unit rather than crashing.
|
|
18
|
+
def country_currency_label
|
|
19
|
+
require_relative "supported"
|
|
20
|
+
Supported.currency_for_country(country) || country.to_s.upcase
|
|
21
|
+
end
|
|
22
|
+
end
|
|
18
23
|
|
|
19
24
|
# CPI-based inflation adjustment for the {Supported::COUNTRIES} list.
|
|
20
25
|
module Inflation
|
|
@@ -32,20 +37,20 @@ module Timeprice
|
|
|
32
37
|
# @raise [UnsupportedCountry] if `country` is not supported
|
|
33
38
|
# @raise [DataNotFound] if no CPI data covers the requested period
|
|
34
39
|
def adjust(amount:, from:, to:, country:)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
lookup = CpiLookup.new(DataLoader.load_cpi(country))
|
|
41
|
+
from_point = lookup.at(from)
|
|
42
|
+
to_point = lookup.at(to)
|
|
38
43
|
|
|
39
|
-
ratio =
|
|
44
|
+
ratio = to_point.value.to_f / from_point.value
|
|
40
45
|
InflationResult.new(
|
|
41
46
|
amount: amount.to_f * ratio,
|
|
42
47
|
original_amount: amount.to_f,
|
|
43
48
|
from: from,
|
|
44
49
|
to: to,
|
|
45
50
|
country: country.to_s.upcase,
|
|
46
|
-
from_index:
|
|
47
|
-
to_index:
|
|
48
|
-
granularity:
|
|
51
|
+
from_index: from_point.value,
|
|
52
|
+
to_index: to_point.value,
|
|
53
|
+
granularity: Granularity.merge(from_point.granularity, to_point.granularity)
|
|
49
54
|
)
|
|
50
55
|
end
|
|
51
56
|
|
|
@@ -59,61 +64,5 @@ module Timeprice
|
|
|
59
64
|
result = adjust(amount: 1.0, from: from, to: to, country: country)
|
|
60
65
|
result.amount - 1.0
|
|
61
66
|
end
|
|
62
|
-
|
|
63
|
-
# Returns [index_value, granularity_symbol]
|
|
64
|
-
def lookup_index(data, key)
|
|
65
|
-
key = key.to_s
|
|
66
|
-
monthly = data["monthly"] || {}
|
|
67
|
-
annual = data["annual"] || {}
|
|
68
|
-
|
|
69
|
-
case key
|
|
70
|
-
when /\A\d{4}-\d{2}\z/
|
|
71
|
-
if monthly.key?(key)
|
|
72
|
-
[monthly[key], :monthly]
|
|
73
|
-
else
|
|
74
|
-
year = key[0, 4]
|
|
75
|
-
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
|
|
76
|
-
|
|
77
|
-
[annual[year], :annual]
|
|
78
|
-
|
|
79
|
-
end
|
|
80
|
-
when /\A\d{4}\z/
|
|
81
|
-
if annual.key?(key)
|
|
82
|
-
[annual[key], :annual]
|
|
83
|
-
else
|
|
84
|
-
months = monthly.select { |k, _| k.start_with?("#{key}-") }
|
|
85
|
-
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
|
|
86
|
-
|
|
87
|
-
avg = months.values.sum.to_f / months.size
|
|
88
|
-
[avg, :annual_from_monthly_avg]
|
|
89
|
-
end
|
|
90
|
-
else
|
|
91
|
-
raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def missing_cpi_message(key, data, monthly, annual)
|
|
96
|
-
country = data["country"]
|
|
97
|
-
ranges = []
|
|
98
|
-
if monthly.any?
|
|
99
|
-
ks = monthly.keys.sort
|
|
100
|
-
ranges << "monthly #{ks.first}..#{ks.last}"
|
|
101
|
-
end
|
|
102
|
-
if annual.any?
|
|
103
|
-
ks = annual.keys.sort
|
|
104
|
-
ranges << "annual #{ks.first}..#{ks.last}"
|
|
105
|
-
end
|
|
106
|
-
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
107
|
-
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# If either end fell back to annual_from_monthly_avg, propagate that label;
|
|
111
|
-
# else if either is annual, propagate :annual; else :monthly.
|
|
112
|
-
def merge_granularity(a, b)
|
|
113
|
-
return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
|
|
114
|
-
return :annual if a == :annual || b == :annual
|
|
115
|
-
|
|
116
|
-
:monthly
|
|
117
|
-
end
|
|
118
67
|
end
|
|
119
68
|
end
|
data/lib/timeprice/point.rb
CHANGED
|
@@ -21,23 +21,42 @@ module Timeprice
|
|
|
21
21
|
# @return [Point]
|
|
22
22
|
# @raise [ArgumentError] if shape can't be recognised
|
|
23
23
|
def self.coerce(input)
|
|
24
|
-
|
|
24
|
+
case input
|
|
25
|
+
in Point
|
|
26
|
+
input
|
|
27
|
+
in [_, _]
|
|
28
|
+
a, b = input.map(&:to_s)
|
|
29
|
+
currency = [a, b].find { |s| s.match?(/\A[A-Za-z]{3}\z/) }
|
|
30
|
+
date = [a, b].find { |s| s.match?(/\A\d{4}(-\d{2}(-\d{2})?)?\z/) }
|
|
31
|
+
raise ArgumentError, malformed_pair_message(input) if currency.nil? || date.nil?
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
new(currency: currency.upcase, date: date)
|
|
34
|
+
else
|
|
27
35
|
raise ArgumentError, "Expected Timeprice::Point or [currency, date] tuple, got #{input.inspect}"
|
|
28
36
|
end
|
|
37
|
+
end
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
currency
|
|
32
|
-
|
|
39
|
+
def self.malformed_pair_message(input)
|
|
40
|
+
"Could not detect currency + date in #{input.inspect} " \
|
|
41
|
+
"(expected a 3-letter currency and a YYYY[-MM[-DD]] date)"
|
|
42
|
+
end
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
# Resolve `date` to a full YYYY-MM-DD for FX lookup.
|
|
45
|
+
#
|
|
46
|
+
# Coarser grains anchor to a representative day:
|
|
47
|
+
# - "YYYY" → mid-year (YYYY-06-30)
|
|
48
|
+
# - "YYYY-MM" → mid-month (YYYY-MM-15)
|
|
49
|
+
# - "YYYY-MM-DD" → passes through
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
# @raise [ArgumentError] if `date` doesn't match any supported shape
|
|
53
|
+
def fx_anchor_date
|
|
54
|
+
case date.to_s
|
|
55
|
+
when /\A\d{4}\z/ then "#{date}-06-30"
|
|
56
|
+
when /\A\d{4}-\d{2}\z/ then "#{date}-15"
|
|
57
|
+
when /\A\d{4}-\d{2}-\d{2}\z/ then date.to_s
|
|
58
|
+
else raise ArgumentError, "Invalid date for Point: #{date.inspect}"
|
|
38
59
|
end
|
|
39
|
-
|
|
40
|
-
new(currency: currency.upcase, date: date)
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
62
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../data_loader"
|
|
5
|
+
|
|
6
|
+
module Timeprice
|
|
7
|
+
module Sources
|
|
8
|
+
# Computes coverage strings for bundled data sources at runtime. All
|
|
9
|
+
# filesystem reads happen here so the Sources attribution registry stays
|
|
10
|
+
# a pure data table.
|
|
11
|
+
module Coverage
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param src [Hash] one entry from Sources::ATTRIBUTIONS
|
|
15
|
+
# @return [String]
|
|
16
|
+
def for(src)
|
|
17
|
+
case src[:kind]
|
|
18
|
+
when "cpi" then cpi(src[:country])
|
|
19
|
+
when "fx" then fx(src[:id])
|
|
20
|
+
else "n/a"
|
|
21
|
+
end
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
"(coverage unavailable: #{e.message})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cpi(country)
|
|
27
|
+
data = DataLoader.load_cpi(country)
|
|
28
|
+
monthly = (data["monthly"] || {}).keys.sort
|
|
29
|
+
annual = (data["annual"] || {}).keys.sort
|
|
30
|
+
parts = []
|
|
31
|
+
parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
|
|
32
|
+
parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
|
|
33
|
+
parts.join(", ")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fx(id)
|
|
37
|
+
years = fx_years
|
|
38
|
+
return "no data" if years.empty?
|
|
39
|
+
|
|
40
|
+
id == "fx_vnd" ? vnd_summary(years) : ecb_summary(years)
|
|
41
|
+
end
|
|
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}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ecb_summary(years)
|
|
55
|
+
ecb_years = years.select { |y| year_has_currency?(y, %w[EUR GBP JPY]) }
|
|
56
|
+
return "no ECB data" if ecb_years.empty?
|
|
57
|
+
|
|
58
|
+
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def year_has_currency?(year, codes)
|
|
62
|
+
rates = JSON.parse(File.read(File.join(fx_root, "#{year}.json")))["rates"]
|
|
63
|
+
rates.any? { |_, v| v.keys.intersect?(codes) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fx_root
|
|
67
|
+
File.join(DataLoader.data_root, "fx", "usd")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/timeprice/sources.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "sources/coverage"
|
|
4
4
|
|
|
5
5
|
module Timeprice
|
|
6
6
|
# Enumerate bundled data sources with license/attribution and the actual
|
|
@@ -48,10 +48,10 @@ module Timeprice
|
|
|
48
48
|
id: "vn_cpi",
|
|
49
49
|
kind: "cpi",
|
|
50
50
|
country: "VN",
|
|
51
|
-
name: "World Bank
|
|
52
|
-
license: "CC BY 4.0",
|
|
53
|
-
license_url: "https://
|
|
54
|
-
attribution: "
|
|
51
|
+
name: "IMF Data Portal CPI dataflow (monthly primary) + World Bank FP.CPI.TOTL (annual fallback)",
|
|
52
|
+
license: "IMF: free reuse with attribution; World Bank: CC BY 4.0",
|
|
53
|
+
license_url: "https://www.imf.org/external/terms.htm",
|
|
54
|
+
attribution: "Sources: IMF Data Portal CPI dataflow; World Bank FP.CPI.TOTL",
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
id: "fx_ecb",
|
|
@@ -66,7 +66,7 @@ module Timeprice
|
|
|
66
66
|
id: "fx_vnd",
|
|
67
67
|
kind: "fx",
|
|
68
68
|
country: "VN",
|
|
69
|
-
name: "World Bank — PA.NUS.FCRF (VND annual average,
|
|
69
|
+
name: "World Bank — PA.NUS.FCRF (VND annual average, annual-granularity fallback)",
|
|
70
70
|
license: "CC BY 4.0",
|
|
71
71
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
72
72
|
attribution: "VND FX: World Bank, PA.NUS.FCRF",
|
|
@@ -79,54 +79,7 @@ module Timeprice
|
|
|
79
79
|
# :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
|
|
80
80
|
# @return [Array<Hash>]
|
|
81
81
|
def list
|
|
82
|
-
ATTRIBUTIONS.map { |s| s.merge(coverage:
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def coverage_for(src)
|
|
86
|
-
case src[:kind]
|
|
87
|
-
when "cpi" then cpi_coverage(src[:country])
|
|
88
|
-
when "fx" then fx_coverage(src[:id])
|
|
89
|
-
else "n/a"
|
|
90
|
-
end
|
|
91
|
-
rescue StandardError => e
|
|
92
|
-
"(coverage unavailable: #{e.message})"
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def cpi_coverage(country)
|
|
96
|
-
data = DataLoader.load_cpi(country)
|
|
97
|
-
monthly = (data["monthly"] || {}).keys.sort
|
|
98
|
-
annual = (data["annual"] || {}).keys.sort
|
|
99
|
-
parts = []
|
|
100
|
-
parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
|
|
101
|
-
parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
|
|
102
|
-
parts.join(", ")
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def fx_coverage(id)
|
|
106
|
-
root = File.join(DataLoader.data_root, "fx", "usd")
|
|
107
|
-
years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
108
|
-
return "no data" if years.empty?
|
|
109
|
-
|
|
110
|
-
case id
|
|
111
|
-
when "fx_vnd"
|
|
112
|
-
# VND broadcast-from-annual covers earlier years too.
|
|
113
|
-
with_vnd = years.select do |y|
|
|
114
|
-
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
115
|
-
d["rates"].any? { |_, v| v.key?("VND") }
|
|
116
|
-
end
|
|
117
|
-
return "no VND data" if with_vnd.empty?
|
|
118
|
-
|
|
119
|
-
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
120
|
-
else
|
|
121
|
-
# ECB pairs (EUR/GBP/JPY) start 1999
|
|
122
|
-
ecb_years = years.select do |y|
|
|
123
|
-
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
124
|
-
d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
|
|
125
|
-
end
|
|
126
|
-
return "no ECB data" if ecb_years.empty?
|
|
127
|
-
|
|
128
|
-
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
129
|
-
end
|
|
82
|
+
ATTRIBUTIONS.map { |s| s.merge(coverage: Coverage.for(s)) }
|
|
130
83
|
end
|
|
131
84
|
end
|
|
132
85
|
end
|
data/lib/timeprice/supported.rb
CHANGED
|
@@ -21,8 +21,20 @@ module Timeprice
|
|
|
21
21
|
|
|
22
22
|
CURRENCY_TO_COUNTRY = COUNTRY_TO_CURRENCY.invert.freeze
|
|
23
23
|
|
|
24
|
+
# Currencies with no minor unit — formatted as whole numbers.
|
|
25
|
+
ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
|
|
26
|
+
|
|
24
27
|
module_function
|
|
25
28
|
|
|
29
|
+
# ISO 4217 minor-unit count for a currency. Falls back to 2 for unknown
|
|
30
|
+
# codes so callers can still render *some* value rather than crashing.
|
|
31
|
+
#
|
|
32
|
+
# @param currency [String]
|
|
33
|
+
# @return [Integer]
|
|
34
|
+
def decimals_for(currency)
|
|
35
|
+
ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
# @param country [String]
|
|
27
39
|
# @return [Boolean]
|
|
28
40
|
def country?(country)
|
|
@@ -47,9 +59,4 @@ module Timeprice
|
|
|
47
59
|
COUNTRY_TO_CURRENCY[country.to_s.upcase]
|
|
48
60
|
end
|
|
49
61
|
end
|
|
50
|
-
|
|
51
|
-
# Back-compat aliases — keep the old top-level constants pointing at the
|
|
52
|
-
# canonical lists so existing requires of "errors" keep working.
|
|
53
|
-
SUPPORTED_COUNTRIES = Supported::COUNTRIES
|
|
54
|
-
SUPPORTED_CURRENCIES = Supported::CURRENCIES
|
|
55
62
|
end
|
data/lib/timeprice/version.rb
CHANGED
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -157,13 +157,21 @@ files:
|
|
|
157
157
|
- exe/timeprice
|
|
158
158
|
- lib/timeprice.rb
|
|
159
159
|
- lib/timeprice/cli.rb
|
|
160
|
+
- lib/timeprice/cli/formatting.rb
|
|
161
|
+
- lib/timeprice/cli/presenters/compare.rb
|
|
162
|
+
- lib/timeprice/cli/presenters/exchange.rb
|
|
163
|
+
- lib/timeprice/cli/presenters/inflation.rb
|
|
164
|
+
- lib/timeprice/cli/presenters/sources.rb
|
|
160
165
|
- lib/timeprice/compare.rb
|
|
166
|
+
- lib/timeprice/cpi_lookup.rb
|
|
161
167
|
- lib/timeprice/data_loader.rb
|
|
162
168
|
- lib/timeprice/errors.rb
|
|
163
169
|
- lib/timeprice/exchange.rb
|
|
170
|
+
- lib/timeprice/granularity.rb
|
|
164
171
|
- lib/timeprice/inflation.rb
|
|
165
172
|
- lib/timeprice/point.rb
|
|
166
173
|
- lib/timeprice/sources.rb
|
|
174
|
+
- lib/timeprice/sources/coverage.rb
|
|
167
175
|
- lib/timeprice/supported.rb
|
|
168
176
|
- lib/timeprice/version.rb
|
|
169
177
|
homepage: https://github.com/patrick204nqh/timeprice
|