timeprice 0.1.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 +7 -0
- data/CHANGELOG.md +18 -0
- data/DATA_LICENSES.md +31 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +12 -0
- data/README.md +187 -0
- data/data/cpi/eu.json +401 -0
- data/data/cpi/jp.json +75 -0
- data/data/cpi/uk.json +508 -0
- data/data/cpi/us.json +480 -0
- data/data/cpi/vn.json +40 -0
- data/data/fx/usd/1983.json +12 -0
- data/data/fx/usd/1986.json +12 -0
- data/data/fx/usd/1987.json +12 -0
- data/data/fx/usd/1988.json +12 -0
- data/data/fx/usd/1989.json +12 -0
- data/data/fx/usd/1990.json +12 -0
- data/data/fx/usd/1991.json +12 -0
- data/data/fx/usd/1992.json +12 -0
- data/data/fx/usd/1993.json +12 -0
- data/data/fx/usd/1994.json +12 -0
- data/data/fx/usd/1995.json +12 -0
- data/data/fx/usd/1996.json +12 -0
- data/data/fx/usd/1997.json +12 -0
- data/data/fx/usd/1998.json +12 -0
- data/data/fx/usd/1999.json +1566 -0
- data/data/fx/usd/2000.json +1542 -0
- data/data/fx/usd/2001.json +1533 -0
- data/data/fx/usd/2002.json +1539 -0
- data/data/fx/usd/2003.json +1539 -0
- data/data/fx/usd/2004.json +1563 -0
- data/data/fx/usd/2005.json +1554 -0
- data/data/fx/usd/2006.json +1539 -0
- data/data/fx/usd/2007.json +1539 -0
- data/data/fx/usd/2008.json +1545 -0
- data/data/fx/usd/2009.json +1545 -0
- data/data/fx/usd/2010.json +1560 -0
- data/data/fx/usd/2011.json +1554 -0
- data/data/fx/usd/2012.json +1545 -0
- data/data/fx/usd/2013.json +1539 -0
- data/data/fx/usd/2014.json +1539 -0
- data/data/fx/usd/2015.json +1545 -0
- data/data/fx/usd/2016.json +1554 -0
- data/data/fx/usd/2017.json +1539 -0
- data/data/fx/usd/2018.json +1539 -0
- data/data/fx/usd/2019.json +1539 -0
- data/data/fx/usd/2020.json +1551 -0
- data/data/fx/usd/2021.json +1560 -0
- data/data/fx/usd/2022.json +1554 -0
- data/data/fx/usd/2023.json +1539 -0
- data/data/fx/usd/2024.json +1545 -0
- data/data/fx/usd/2025.json +1284 -0
- data/data/fx/usd/2026.json +449 -0
- data/exe/timeprice +7 -0
- data/lib/timeprice/cli.rb +179 -0
- data/lib/timeprice/compare.rb +99 -0
- data/lib/timeprice/data_loader.rb +59 -0
- data/lib/timeprice/errors.rb +49 -0
- data/lib/timeprice/exchange.rb +98 -0
- data/lib/timeprice/inflation.rb +89 -0
- data/lib/timeprice/sources.rb +128 -0
- data/lib/timeprice/version.rb +5 -0
- data/lib/timeprice.rb +25 -0
- metadata +150 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "inflation"
|
|
5
|
+
require_relative "exchange"
|
|
6
|
+
|
|
7
|
+
module Timeprice
|
|
8
|
+
CompareResult = Data.define(
|
|
9
|
+
:amount, :original_amount,
|
|
10
|
+
:from_currency, :from_date,
|
|
11
|
+
:to_currency, :to_date,
|
|
12
|
+
:country, :fx_rate, :cpi_ratio,
|
|
13
|
+
:converted_amount, :granularity
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Compare combines FX and inflation across two (currency, date) points.
|
|
17
|
+
#
|
|
18
|
+
# CONVENTION (critical): convert at SOURCE date first, then inflate in
|
|
19
|
+
# destination currency. See PLAN.md §2 last bullet and §7.
|
|
20
|
+
#
|
|
21
|
+
# This preserves purchasing-power equivalence in the destination economy.
|
|
22
|
+
# The naive alternative (inflate in source currency first, then convert at
|
|
23
|
+
# destination date) double-counts source-country inflation because nominal
|
|
24
|
+
# FX rates already absorb relative inflation between the two currencies.
|
|
25
|
+
#
|
|
26
|
+
# If a future refactor flips the order, the regression test in
|
|
27
|
+
# spec/timeprice/compare_spec.rb will fail.
|
|
28
|
+
module Compare
|
|
29
|
+
# Map ISO currency → CPI country code.
|
|
30
|
+
CURRENCY_TO_COUNTRY = {
|
|
31
|
+
"USD" => "US",
|
|
32
|
+
"GBP" => "UK",
|
|
33
|
+
"EUR" => "EU",
|
|
34
|
+
"JPY" => "JP",
|
|
35
|
+
"VND" => "VN"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# amount: Numeric
|
|
41
|
+
# from: [currency, date_or_year] e.g. ["USD", "2010"] or ["USD", "2010-06"]
|
|
42
|
+
# to: [currency, date_or_year]
|
|
43
|
+
def run(amount:, from:, to:)
|
|
44
|
+
from_currency, from_date = from
|
|
45
|
+
to_currency, to_date = to
|
|
46
|
+
from_currency = from_currency.to_s.upcase
|
|
47
|
+
to_currency = to_currency.to_s.upcase
|
|
48
|
+
|
|
49
|
+
to_country = CURRENCY_TO_COUNTRY[to_currency] ||
|
|
50
|
+
(raise UnsupportedCurrency, to_currency)
|
|
51
|
+
CURRENCY_TO_COUNTRY[from_currency] || (raise UnsupportedCurrency, from_currency)
|
|
52
|
+
|
|
53
|
+
# Step 1: convert at source date into destination currency.
|
|
54
|
+
fx_date = normalize_fx_date(from_date)
|
|
55
|
+
fx_result = Exchange.convert(
|
|
56
|
+
amount: amount,
|
|
57
|
+
from: from_currency,
|
|
58
|
+
to: to_currency,
|
|
59
|
+
date: fx_date
|
|
60
|
+
)
|
|
61
|
+
converted = fx_result.amount
|
|
62
|
+
|
|
63
|
+
# Step 2: inflate that destination-currency amount from source date to
|
|
64
|
+
# destination date using destination-country CPI.
|
|
65
|
+
infl = Inflation.adjust(
|
|
66
|
+
amount: converted,
|
|
67
|
+
from: from_date.to_s,
|
|
68
|
+
to: to_date.to_s,
|
|
69
|
+
country: to_country
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
CompareResult.new(
|
|
73
|
+
amount: infl.amount,
|
|
74
|
+
original_amount: amount.to_f,
|
|
75
|
+
from_currency: from_currency,
|
|
76
|
+
from_date: from_date.to_s,
|
|
77
|
+
to_currency: to_currency,
|
|
78
|
+
to_date: to_date.to_s,
|
|
79
|
+
country: to_country,
|
|
80
|
+
fx_rate: fx_result.rate,
|
|
81
|
+
cpi_ratio: infl.to_index.to_f / infl.from_index.to_f,
|
|
82
|
+
converted_amount: converted,
|
|
83
|
+
granularity: infl.granularity
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# If the user gave a year like "2010", anchor FX to mid-year (2010-06-30).
|
|
88
|
+
# If they gave "YYYY-MM", anchor to the 15th. Full dates pass through.
|
|
89
|
+
def normalize_fx_date(date)
|
|
90
|
+
s = date.to_s
|
|
91
|
+
case s
|
|
92
|
+
when /\A\d{4}\z/ then "#{s}-06-30"
|
|
93
|
+
when /\A\d{4}-\d{2}\z/ then "#{s}-15"
|
|
94
|
+
when /\A\d{4}-\d{2}-\d{2}\z/ then s
|
|
95
|
+
else raise ArgumentError, "Invalid date for compare: #{date.inspect}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Timeprice
|
|
7
|
+
module DataLoader
|
|
8
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
9
|
+
|
|
10
|
+
DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def data_root
|
|
14
|
+
ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def data_root=(path)
|
|
18
|
+
@data_root = path
|
|
19
|
+
clear_cache!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear_cache!
|
|
23
|
+
@cpi_cache = {}
|
|
24
|
+
@fx_cache = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load_cpi(country)
|
|
28
|
+
@cpi_cache ||= {}
|
|
29
|
+
key = country.to_s.downcase
|
|
30
|
+
@cpi_cache[[data_root, key]] ||= begin
|
|
31
|
+
path = File.join(data_root, "cpi", "#{key}.json")
|
|
32
|
+
raise UnsupportedCountry, country.to_s.upcase unless File.exist?(path)
|
|
33
|
+
parse_with_schema(path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_fx_year(year)
|
|
38
|
+
@fx_cache ||= {}
|
|
39
|
+
key = year.to_i
|
|
40
|
+
@fx_cache[[data_root, key]] ||= begin
|
|
41
|
+
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
42
|
+
raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
43
|
+
parse_with_schema(path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def parse_with_schema(path)
|
|
50
|
+
data = JSON.parse(File.read(path))
|
|
51
|
+
version = data["schema_version"]
|
|
52
|
+
unless version == SUPPORTED_SCHEMA_VERSION
|
|
53
|
+
raise UnsupportedSchemaVersion.new(version, path)
|
|
54
|
+
end
|
|
55
|
+
data
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Timeprice
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class UnsupportedCountry < Error
|
|
7
|
+
attr_reader :country
|
|
8
|
+
|
|
9
|
+
def initialize(country)
|
|
10
|
+
@country = country
|
|
11
|
+
super("Unsupported country: #{country.inspect}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class UnsupportedCurrency < Error
|
|
16
|
+
attr_reader :currency
|
|
17
|
+
|
|
18
|
+
def initialize(currency)
|
|
19
|
+
@currency = currency
|
|
20
|
+
super("Unsupported currency: #{currency.inspect}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class DateOutOfRange < Error
|
|
25
|
+
attr_reader :date, :range
|
|
26
|
+
|
|
27
|
+
def initialize(date, range)
|
|
28
|
+
@date = date
|
|
29
|
+
@range = range
|
|
30
|
+
super("Date #{date.inspect} out of supported range #{range.inspect}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class DataNotFound < Error
|
|
35
|
+
def initialize(message = "Data not found")
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class UnsupportedSchemaVersion < Error
|
|
41
|
+
attr_reader :version, :path
|
|
42
|
+
|
|
43
|
+
def initialize(version, path)
|
|
44
|
+
@version = version
|
|
45
|
+
@path = path
|
|
46
|
+
super("Unsupported schema_version #{version.inspect} in #{path}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "data_loader"
|
|
6
|
+
|
|
7
|
+
module Timeprice
|
|
8
|
+
ExchangeResult = Data.define(
|
|
9
|
+
:amount, :original_amount, :from, :to, :date, :effective_date, :rate
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
module Exchange
|
|
13
|
+
BASE = "USD"
|
|
14
|
+
MAX_FALLBACK_DAYS = 7
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Convert `amount` from currency `from` to currency `to` on `date`.
|
|
19
|
+
# date: "YYYY-MM-DD".
|
|
20
|
+
def convert(amount:, from:, to:, date:)
|
|
21
|
+
from = from.to_s.upcase
|
|
22
|
+
to = to.to_s.upcase
|
|
23
|
+
d = parse_date(date)
|
|
24
|
+
|
|
25
|
+
rate, eff_date = resolve_rate(from, to, d)
|
|
26
|
+
ExchangeResult.new(
|
|
27
|
+
amount: amount.to_f * rate,
|
|
28
|
+
original_amount: amount.to_f,
|
|
29
|
+
from: from,
|
|
30
|
+
to: to,
|
|
31
|
+
date: d.to_s,
|
|
32
|
+
effective_date: eff_date.to_s,
|
|
33
|
+
rate: rate
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns [rate (Float), effective_date (Date)].
|
|
38
|
+
# Handles:
|
|
39
|
+
# - identity (from == to)
|
|
40
|
+
# - direct lookup of USD-base rate
|
|
41
|
+
# - inverse (foreign → USD)
|
|
42
|
+
# - triangulation through USD (both legs must resolve to SAME effective date)
|
|
43
|
+
def resolve_rate(from, to, d)
|
|
44
|
+
return [1.0, d] if from == to
|
|
45
|
+
|
|
46
|
+
if from == BASE
|
|
47
|
+
rate, eff = lookup_usd_base(to, d)
|
|
48
|
+
[rate, eff]
|
|
49
|
+
elsif to == BASE
|
|
50
|
+
rate, eff = lookup_usd_base(from, d)
|
|
51
|
+
[1.0 / rate, eff]
|
|
52
|
+
else
|
|
53
|
+
# Triangulation: from → USD → to, both legs at the same effective date.
|
|
54
|
+
usd_to_from, eff_a = lookup_usd_base(from, d)
|
|
55
|
+
usd_to_to, eff_b = lookup_usd_base(to, d)
|
|
56
|
+
if eff_a != eff_b
|
|
57
|
+
raise DataNotFound,
|
|
58
|
+
"FX triangulation date mismatch for #{from}->#{to} on #{d}: " \
|
|
59
|
+
"USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}"
|
|
60
|
+
end
|
|
61
|
+
[usd_to_to / usd_to_from, eff_a]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Walk back up to MAX_FALLBACK_DAYS to find a rate.
|
|
66
|
+
# Returns [rate, effective_date].
|
|
67
|
+
def lookup_usd_base(currency, d)
|
|
68
|
+
(0..MAX_FALLBACK_DAYS).each do |offset|
|
|
69
|
+
candidate = d - offset
|
|
70
|
+
year_data =
|
|
71
|
+
begin
|
|
72
|
+
DataLoader.load_fx_year(candidate.year)
|
|
73
|
+
rescue DataNotFound
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
rates_for_day = year_data.dig("rates", candidate.to_s)
|
|
77
|
+
next unless rates_for_day
|
|
78
|
+
rate = rates_for_day[currency]
|
|
79
|
+
next unless rate
|
|
80
|
+
return [rate.to_f, candidate]
|
|
81
|
+
end
|
|
82
|
+
raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_date(date)
|
|
86
|
+
case date
|
|
87
|
+
when Date then date
|
|
88
|
+
when String
|
|
89
|
+
unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
90
|
+
raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
|
|
91
|
+
end
|
|
92
|
+
Date.parse(date)
|
|
93
|
+
else
|
|
94
|
+
raise ArgumentError, "Invalid date: #{date.inspect}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "data_loader"
|
|
5
|
+
|
|
6
|
+
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
|
|
14
|
+
InflationResult = Data.define(
|
|
15
|
+
:amount, :original_amount, :from, :to, :country,
|
|
16
|
+
:from_index, :to_index, :granularity
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
module Inflation
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Adjust `amount` from date `from` to date `to` using country CPI.
|
|
23
|
+
#
|
|
24
|
+
# Dates accept "YYYY" or "YYYY-MM".
|
|
25
|
+
def adjust(amount:, from:, to:, country:)
|
|
26
|
+
data = DataLoader.load_cpi(country)
|
|
27
|
+
from_index, from_gran = lookup_index(data, from)
|
|
28
|
+
to_index, to_gran = lookup_index(data, to)
|
|
29
|
+
|
|
30
|
+
ratio = to_index.to_f / from_index.to_f
|
|
31
|
+
InflationResult.new(
|
|
32
|
+
amount: amount.to_f * ratio,
|
|
33
|
+
original_amount: amount.to_f,
|
|
34
|
+
from: from,
|
|
35
|
+
to: to,
|
|
36
|
+
country: country.to_s.upcase,
|
|
37
|
+
from_index: from_index,
|
|
38
|
+
to_index: to_index,
|
|
39
|
+
granularity: merge_granularity(from_gran, to_gran)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Inflation rate as decimal (e.g. 0.42 = 42%).
|
|
44
|
+
def rate(from:, to:, country:)
|
|
45
|
+
result = adjust(amount: 1.0, from: from, to: to, country: country)
|
|
46
|
+
result.amount - 1.0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns [index_value, granularity_symbol]
|
|
50
|
+
def lookup_index(data, key)
|
|
51
|
+
key = key.to_s
|
|
52
|
+
monthly = data["monthly"] || {}
|
|
53
|
+
annual = data["annual"] || {}
|
|
54
|
+
|
|
55
|
+
case key
|
|
56
|
+
when /\A\d{4}-\d{2}\z/
|
|
57
|
+
if monthly.key?(key)
|
|
58
|
+
[monthly[key], :monthly]
|
|
59
|
+
else
|
|
60
|
+
year = key[0, 4]
|
|
61
|
+
if annual.key?(year)
|
|
62
|
+
[annual[year], :annual]
|
|
63
|
+
else
|
|
64
|
+
raise DataNotFound, "No CPI data for #{key.inspect} in #{data["country"]}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
when /\A\d{4}\z/
|
|
68
|
+
if annual.key?(key)
|
|
69
|
+
[annual[key], :annual]
|
|
70
|
+
else
|
|
71
|
+
months = monthly.select { |k, _| k.start_with?("#{key}-") }
|
|
72
|
+
raise DataNotFound, "No CPI data for #{key.inspect} in #{data["country"]}" if months.empty?
|
|
73
|
+
avg = months.values.sum.to_f / months.size
|
|
74
|
+
[avg, :annual_from_monthly_avg]
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# If either end fell back to annual_from_monthly_avg, propagate that label;
|
|
82
|
+
# else if either is annual, propagate :annual; else :monthly.
|
|
83
|
+
def merge_granularity(a, b)
|
|
84
|
+
return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
|
|
85
|
+
return :annual if a == :annual || b == :annual
|
|
86
|
+
:monthly
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "data_loader"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
# Enumerate bundled data sources with license/attribution and the actual
|
|
7
|
+
# coverage range derived from data/ at runtime.
|
|
8
|
+
module Sources
|
|
9
|
+
# Static license & attribution metadata. Coverage is computed dynamically.
|
|
10
|
+
ATTRIBUTIONS = [
|
|
11
|
+
{
|
|
12
|
+
id: "us_cpi",
|
|
13
|
+
kind: "cpi",
|
|
14
|
+
country: "US",
|
|
15
|
+
name: "U.S. Bureau of Labor Statistics — CPI-U (series CUUR0000SA0)",
|
|
16
|
+
license: "U.S. Government work — public domain",
|
|
17
|
+
license_url: "https://www.bls.gov/bls/linksite.htm",
|
|
18
|
+
attribution: "Data: U.S. Bureau of Labor Statistics"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "uk_cpi",
|
|
22
|
+
kind: "cpi",
|
|
23
|
+
country: "UK",
|
|
24
|
+
name: "UK Office for National Statistics — CPI all-items (series D7BT)",
|
|
25
|
+
license: "Open Government Licence v3.0",
|
|
26
|
+
license_url: "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/",
|
|
27
|
+
attribution: "Contains public sector information licensed under the Open Government Licence v3.0"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "eu_hicp",
|
|
31
|
+
kind: "cpi",
|
|
32
|
+
country: "EU",
|
|
33
|
+
name: "Eurostat — HICP prc_hicp_midx (Euro area, all items)",
|
|
34
|
+
license: "Eurostat reuse policy (free reuse with attribution)",
|
|
35
|
+
license_url: "https://ec.europa.eu/eurostat/about-us/policies/copyright",
|
|
36
|
+
attribution: "Source: Eurostat"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "jp_cpi",
|
|
40
|
+
kind: "cpi",
|
|
41
|
+
country: "JP",
|
|
42
|
+
name: "World Bank — FP.CPI.TOTL (annual, JP fallback)",
|
|
43
|
+
license: "CC BY 4.0",
|
|
44
|
+
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
45
|
+
attribution: "Source: World Bank, FP.CPI.TOTL"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "vn_cpi",
|
|
49
|
+
kind: "cpi",
|
|
50
|
+
country: "VN",
|
|
51
|
+
name: "World Bank — FP.CPI.TOTL (annual)",
|
|
52
|
+
license: "CC BY 4.0",
|
|
53
|
+
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
54
|
+
attribution: "Source: World Bank, FP.CPI.TOTL"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "fx_ecb",
|
|
58
|
+
kind: "fx",
|
|
59
|
+
country: nil,
|
|
60
|
+
name: "European Central Bank reference rates (via Frankfurter)",
|
|
61
|
+
license: "ECB reference rates — free reuse",
|
|
62
|
+
license_url: "https://www.ecb.europa.eu/services/disclaimer/html/index.en.html",
|
|
63
|
+
attribution: "FX data: European Central Bank reference rates via Frankfurter"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "fx_vnd",
|
|
67
|
+
kind: "fx",
|
|
68
|
+
country: "VN",
|
|
69
|
+
name: "World Bank — PA.NUS.FCRF (VND annual average, broadcast daily)",
|
|
70
|
+
license: "CC BY 4.0",
|
|
71
|
+
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
72
|
+
attribution: "VND FX: World Bank, PA.NUS.FCRF"
|
|
73
|
+
}
|
|
74
|
+
].freeze
|
|
75
|
+
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# Returns an array of hashes with :id, :kind, :name, :license, :license_url,
|
|
79
|
+
# :attribution, :coverage (string like "1990-01 to 2026-03 (monthly+annual)").
|
|
80
|
+
def list
|
|
81
|
+
ATTRIBUTIONS.map { |s| s.merge(coverage: coverage_for(s)) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def coverage_for(src)
|
|
85
|
+
case src[:kind]
|
|
86
|
+
when "cpi" then cpi_coverage(src[:country])
|
|
87
|
+
when "fx" then fx_coverage(src[:id])
|
|
88
|
+
else "n/a"
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
"(coverage unavailable: #{e.message})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cpi_coverage(country)
|
|
95
|
+
data = DataLoader.load_cpi(country)
|
|
96
|
+
monthly = (data["monthly"] || {}).keys.sort
|
|
97
|
+
annual = (data["annual"] || {}).keys.sort
|
|
98
|
+
parts = []
|
|
99
|
+
parts << "monthly #{monthly.first}..#{monthly.last} (#{monthly.size})" unless monthly.empty?
|
|
100
|
+
parts << "annual #{annual.first}..#{annual.last} (#{annual.size})" unless annual.empty?
|
|
101
|
+
parts.join(", ")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fx_coverage(id)
|
|
105
|
+
root = File.join(DataLoader.data_root, "fx", "usd")
|
|
106
|
+
years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
107
|
+
return "no data" if years.empty?
|
|
108
|
+
case id
|
|
109
|
+
when "fx_vnd"
|
|
110
|
+
# VND broadcast-from-annual covers earlier years too.
|
|
111
|
+
with_vnd = years.select do |y|
|
|
112
|
+
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
113
|
+
d["rates"].any? { |_, v| v.key?("VND") }
|
|
114
|
+
end
|
|
115
|
+
return "no VND data" if with_vnd.empty?
|
|
116
|
+
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
117
|
+
else
|
|
118
|
+
# ECB pairs (EUR/GBP/JPY) start 1999
|
|
119
|
+
ecb_years = years.select do |y|
|
|
120
|
+
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
121
|
+
d["rates"].any? { |_, v| (v.keys & %w[EUR GBP JPY]).any? }
|
|
122
|
+
end
|
|
123
|
+
return "no ECB data" if ecb_years.empty?
|
|
124
|
+
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/timeprice.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "timeprice/version"
|
|
4
|
+
require_relative "timeprice/errors"
|
|
5
|
+
require_relative "timeprice/data_loader"
|
|
6
|
+
require_relative "timeprice/inflation"
|
|
7
|
+
require_relative "timeprice/exchange"
|
|
8
|
+
require_relative "timeprice/compare"
|
|
9
|
+
require_relative "timeprice/sources"
|
|
10
|
+
|
|
11
|
+
module Timeprice
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def inflation(amount:, from:, to:, country:)
|
|
15
|
+
Inflation.adjust(amount: amount, from: from, to: to, country: country)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def exchange(amount:, from:, to:, date:)
|
|
19
|
+
Exchange.convert(amount: amount, from: from, to: to, date: date)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def compare(amount:, from:, to:)
|
|
23
|
+
Compare.run(amount: amount, from: from, to: to)
|
|
24
|
+
end
|
|
25
|
+
end
|