timeprice 0.1.1 → 0.1.2
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 +13 -1
- data/lib/timeprice/cli.rb +4 -2
- data/lib/timeprice/compare.rb +3 -3
- data/lib/timeprice/data_loader.rb +11 -4
- data/lib/timeprice/exchange.rb +9 -1
- data/lib/timeprice/inflation.rb +7 -6
- data/lib/timeprice/sources.rb +12 -9
- data/lib/timeprice/version.rb +1 -1
- metadata +44 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b0ebaf1340f0abefa6b4dcae31f7d2f2d22021155d24ebc258f15b0495f7a480
|
|
4
|
+
data.tar.gz: 6068762094c6008c72f4dd7becb10e1e8cb0d0c17b668234e8e0dff84c494cc2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fa08a556599384d4ae292064b2342bfdc6976eac3fea784afb7426d623fdd0c8ee43001135bd2f7c3a065e735db8dd6d347459144e44ee702f30505ee23c3c1f
|
|
7
|
+
data.tar.gz: 9fd49be55c8790b4a1adfd9350fdd2e6eed82cdcbf8e41c2dd3b1e1514e5b81f9ffa71dc0a9b530738ac2f2d6fa22e969c240bd81dac043e4a4b8e23b21b6924
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,19 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
-
## [0.1.
|
|
8
|
+
## [0.1.2] - 2026-05-11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- RuboCop with `rubocop-rake` + `rubocop-rspec`, wired into Rake (`rake default` runs spec + rubocop) and CI (separate `RuboCop` job alongside `RSpec`).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `DataLoader.load_cpi` now distinguishes between "country isn't supported" (`UnsupportedCountry`) and "data file is missing on disk" (`DataNotFound` with the path the loader looked at). Previously both surfaced as `UnsupportedCountry`, masking install / `TIMEPRICE_DATA_ROOT` misconfigurations.
|
|
15
|
+
- `Timeprice.exchange` now rejects invalid calendar dates (e.g. `2021-02-29`) with `ArgumentError` instead of leaking a `Date::Error`. Honors the public error contract.
|
|
16
|
+
- Trimmed `ZERO_DECIMAL_CURRENCIES` to currencies actually supported by the gem (JPY, VND). Removed aspirational entries (KRW, IDR, HUF, CLP).
|
|
17
|
+
- Inline source comments now reference README sections (`README.md "Compare semantics"`) instead of `PLAN.md` (which is intentionally not shipped in the gem).
|
|
18
|
+
- `CONTRIBUTING.md` updated to match the single-Ruby CI; both `rspec` and `rubocop` must be green.
|
|
19
|
+
|
|
20
|
+
|
|
9
21
|
|
|
10
22
|
### Changed
|
|
11
23
|
- CLI output formatting: currency-aware decimals (no `.0000` on JPY/VND), magnitude-aware FX rate precision (no `91.180000` for a 91.18 rate).
|
data/lib/timeprice/cli.rb
CHANGED
|
@@ -73,7 +73,7 @@ module Timeprice
|
|
|
73
73
|
say JSON.generate(list)
|
|
74
74
|
else
|
|
75
75
|
list.each do |s|
|
|
76
|
-
say
|
|
76
|
+
say s[:name].to_s
|
|
77
77
|
say " id: #{s[:id]}"
|
|
78
78
|
say " license: #{s[:license]}"
|
|
79
79
|
say " license_url: #{s[:license_url]}"
|
|
@@ -95,7 +95,7 @@ module Timeprice
|
|
|
95
95
|
|
|
96
96
|
no_commands do
|
|
97
97
|
# Currencies with no minor unit — render whole numbers, no decimals.
|
|
98
|
-
ZERO_DECIMAL_CURRENCIES = %w[JPY VND
|
|
98
|
+
ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
|
|
99
99
|
|
|
100
100
|
def with_error_handling
|
|
101
101
|
yield
|
|
@@ -109,6 +109,7 @@ module Timeprice
|
|
|
109
109
|
|
|
110
110
|
def parse_compare_token(token, label:)
|
|
111
111
|
raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
|
|
112
|
+
|
|
112
113
|
parts = token.strip.split(/\s+/)
|
|
113
114
|
unless parts.size == 2
|
|
114
115
|
raise ArgumentError,
|
|
@@ -142,6 +143,7 @@ module Timeprice
|
|
|
142
143
|
# answer actually used annual data — that's where users want a heads-up.
|
|
143
144
|
def granularity_suffix(granularity)
|
|
144
145
|
return "" if granularity == :monthly
|
|
146
|
+
|
|
145
147
|
" (granularity: #{granularity})"
|
|
146
148
|
end
|
|
147
149
|
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Timeprice
|
|
|
16
16
|
# Compare combines FX and inflation across two (currency, date) points.
|
|
17
17
|
#
|
|
18
18
|
# CONVENTION (critical): convert at SOURCE date first, then inflate in
|
|
19
|
-
# destination currency. See
|
|
19
|
+
# destination currency. See README.md "Compare semantics" section.
|
|
20
20
|
#
|
|
21
21
|
# This preserves purchasing-power equivalence in the destination economy.
|
|
22
22
|
# The naive alternative (inflate in source currency first, then convert at
|
|
@@ -32,7 +32,7 @@ module Timeprice
|
|
|
32
32
|
"GBP" => "UK",
|
|
33
33
|
"EUR" => "EU",
|
|
34
34
|
"JPY" => "JP",
|
|
35
|
-
"VND" => "VN"
|
|
35
|
+
"VND" => "VN",
|
|
36
36
|
}.freeze
|
|
37
37
|
|
|
38
38
|
module_function
|
|
@@ -78,7 +78,7 @@ module Timeprice
|
|
|
78
78
|
to_date: to_date.to_s,
|
|
79
79
|
country: to_country,
|
|
80
80
|
fx_rate: fx_result.rate,
|
|
81
|
-
cpi_ratio: infl.to_index.to_f / infl.from_index
|
|
81
|
+
cpi_ratio: infl.to_index.to_f / infl.from_index,
|
|
82
82
|
converted_amount: converted,
|
|
83
83
|
granularity: infl.granularity
|
|
84
84
|
)
|
|
@@ -27,9 +27,16 @@ module Timeprice
|
|
|
27
27
|
def load_cpi(country)
|
|
28
28
|
@cpi_cache ||= {}
|
|
29
29
|
key = country.to_s.downcase
|
|
30
|
+
code = country.to_s.upcase
|
|
30
31
|
@cpi_cache[[data_root, key]] ||= begin
|
|
32
|
+
raise UnsupportedCountry, code unless SUPPORTED_COUNTRIES.include?(code)
|
|
33
|
+
|
|
31
34
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
32
|
-
|
|
35
|
+
unless File.exist?(path)
|
|
36
|
+
raise DataNotFound, "CPI data file missing for #{code} (looked in #{path}). " \
|
|
37
|
+
"Check TIMEPRICE_DATA_ROOT or reinstall the gem."
|
|
38
|
+
end
|
|
39
|
+
|
|
33
40
|
parse_with_schema(path)
|
|
34
41
|
end
|
|
35
42
|
end
|
|
@@ -40,6 +47,7 @@ module Timeprice
|
|
|
40
47
|
@fx_cache[[data_root, key]] ||= begin
|
|
41
48
|
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
42
49
|
raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
50
|
+
|
|
43
51
|
parse_with_schema(path)
|
|
44
52
|
end
|
|
45
53
|
end
|
|
@@ -49,9 +57,8 @@ module Timeprice
|
|
|
49
57
|
def parse_with_schema(path)
|
|
50
58
|
data = JSON.parse(File.read(path))
|
|
51
59
|
version = data["schema_version"]
|
|
52
|
-
unless version == SUPPORTED_SCHEMA_VERSION
|
|
53
|
-
|
|
54
|
-
end
|
|
60
|
+
raise UnsupportedSchemaVersion.new(version, path) unless version == SUPPORTED_SCHEMA_VERSION
|
|
61
|
+
|
|
55
62
|
data
|
|
56
63
|
end
|
|
57
64
|
end
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Timeprice
|
|
|
22
22
|
to = to.to_s.upcase
|
|
23
23
|
raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from)
|
|
24
24
|
raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
|
|
25
|
+
|
|
25
26
|
d = parse_date(date)
|
|
26
27
|
|
|
27
28
|
rate, eff_date = resolve_rate(from, to, d)
|
|
@@ -77,8 +78,10 @@ module Timeprice
|
|
|
77
78
|
end
|
|
78
79
|
rates_for_day = year_data.dig("rates", candidate.to_s)
|
|
79
80
|
next unless rates_for_day
|
|
81
|
+
|
|
80
82
|
rate = rates_for_day[currency]
|
|
81
83
|
next unless rate
|
|
84
|
+
|
|
82
85
|
return [rate.to_f, candidate]
|
|
83
86
|
end
|
|
84
87
|
raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}"
|
|
@@ -91,7 +94,12 @@ module Timeprice
|
|
|
91
94
|
unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
92
95
|
raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)"
|
|
93
96
|
end
|
|
94
|
-
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
Date.parse(date)
|
|
100
|
+
rescue Date::Error
|
|
101
|
+
raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date"
|
|
102
|
+
end
|
|
95
103
|
else
|
|
96
104
|
raise ArgumentError, "Invalid date: #{date.inspect}"
|
|
97
105
|
end
|
data/lib/timeprice/inflation.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Timeprice
|
|
|
27
27
|
from_index, from_gran = lookup_index(data, from)
|
|
28
28
|
to_index, to_gran = lookup_index(data, to)
|
|
29
29
|
|
|
30
|
-
ratio = to_index.to_f / from_index
|
|
30
|
+
ratio = to_index.to_f / from_index
|
|
31
31
|
InflationResult.new(
|
|
32
32
|
amount: amount.to_f * ratio,
|
|
33
33
|
original_amount: amount.to_f,
|
|
@@ -58,11 +58,10 @@ module Timeprice
|
|
|
58
58
|
[monthly[key], :monthly]
|
|
59
59
|
else
|
|
60
60
|
year = key[0, 4]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
61
|
+
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) unless annual.key?(year)
|
|
62
|
+
|
|
63
|
+
[annual[year], :annual]
|
|
64
|
+
|
|
66
65
|
end
|
|
67
66
|
when /\A\d{4}\z/
|
|
68
67
|
if annual.key?(key)
|
|
@@ -70,6 +69,7 @@ module Timeprice
|
|
|
70
69
|
else
|
|
71
70
|
months = monthly.select { |k, _| k.start_with?("#{key}-") }
|
|
72
71
|
raise DataNotFound, missing_cpi_message(key, data, monthly, annual) if months.empty?
|
|
72
|
+
|
|
73
73
|
avg = months.values.sum.to_f / months.size
|
|
74
74
|
[avg, :annual_from_monthly_avg]
|
|
75
75
|
end
|
|
@@ -98,6 +98,7 @@ module Timeprice
|
|
|
98
98
|
def merge_granularity(a, b)
|
|
99
99
|
return :annual_from_monthly_avg if a == :annual_from_monthly_avg || b == :annual_from_monthly_avg
|
|
100
100
|
return :annual if a == :annual || b == :annual
|
|
101
|
+
|
|
101
102
|
:monthly
|
|
102
103
|
end
|
|
103
104
|
end
|
data/lib/timeprice/sources.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Timeprice
|
|
|
15
15
|
name: "U.S. Bureau of Labor Statistics — CPI-U (series CUUR0000SA0)",
|
|
16
16
|
license: "U.S. Government work — public domain",
|
|
17
17
|
license_url: "https://www.bls.gov/bls/linksite.htm",
|
|
18
|
-
attribution: "Data: U.S. Bureau of Labor Statistics"
|
|
18
|
+
attribution: "Data: U.S. Bureau of Labor Statistics",
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
id: "uk_cpi",
|
|
@@ -24,7 +24,7 @@ module Timeprice
|
|
|
24
24
|
name: "UK Office for National Statistics — CPI all-items (series D7BT)",
|
|
25
25
|
license: "Open Government Licence v3.0",
|
|
26
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"
|
|
27
|
+
attribution: "Contains public sector information licensed under the Open Government Licence v3.0",
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
id: "eu_hicp",
|
|
@@ -33,7 +33,7 @@ module Timeprice
|
|
|
33
33
|
name: "Eurostat — HICP prc_hicp_midx (Euro area, all items)",
|
|
34
34
|
license: "Eurostat reuse policy (free reuse with attribution)",
|
|
35
35
|
license_url: "https://ec.europa.eu/eurostat/about-us/policies/copyright",
|
|
36
|
-
attribution: "Source: Eurostat"
|
|
36
|
+
attribution: "Source: Eurostat",
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
id: "jp_cpi",
|
|
@@ -42,7 +42,7 @@ module Timeprice
|
|
|
42
42
|
name: "World Bank — FP.CPI.TOTL (annual, JP fallback)",
|
|
43
43
|
license: "CC BY 4.0",
|
|
44
44
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
45
|
-
attribution: "Source: World Bank, FP.CPI.TOTL"
|
|
45
|
+
attribution: "Source: World Bank, FP.CPI.TOTL",
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
id: "vn_cpi",
|
|
@@ -51,7 +51,7 @@ module Timeprice
|
|
|
51
51
|
name: "World Bank — FP.CPI.TOTL (annual)",
|
|
52
52
|
license: "CC BY 4.0",
|
|
53
53
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
54
|
-
attribution: "Source: World Bank, FP.CPI.TOTL"
|
|
54
|
+
attribution: "Source: World Bank, FP.CPI.TOTL",
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
id: "fx_ecb",
|
|
@@ -60,7 +60,7 @@ module Timeprice
|
|
|
60
60
|
name: "European Central Bank reference rates (via Frankfurter)",
|
|
61
61
|
license: "ECB reference rates — free reuse",
|
|
62
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"
|
|
63
|
+
attribution: "FX data: European Central Bank reference rates via Frankfurter",
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
id: "fx_vnd",
|
|
@@ -69,8 +69,8 @@ module Timeprice
|
|
|
69
69
|
name: "World Bank — PA.NUS.FCRF (VND annual average, broadcast daily)",
|
|
70
70
|
license: "CC BY 4.0",
|
|
71
71
|
license_url: "https://datacatalog.worldbank.org/public-licenses#cc-by",
|
|
72
|
-
attribution: "VND FX: World Bank, PA.NUS.FCRF"
|
|
73
|
-
}
|
|
72
|
+
attribution: "VND FX: World Bank, PA.NUS.FCRF",
|
|
73
|
+
},
|
|
74
74
|
].freeze
|
|
75
75
|
|
|
76
76
|
module_function
|
|
@@ -105,6 +105,7 @@ module Timeprice
|
|
|
105
105
|
root = File.join(DataLoader.data_root, "fx", "usd")
|
|
106
106
|
years = Dir[File.join(root, "*.json")].map { |f| File.basename(f, ".json").to_i }.sort
|
|
107
107
|
return "no data" if years.empty?
|
|
108
|
+
|
|
108
109
|
case id
|
|
109
110
|
when "fx_vnd"
|
|
110
111
|
# VND broadcast-from-annual covers earlier years too.
|
|
@@ -113,14 +114,16 @@ module Timeprice
|
|
|
113
114
|
d["rates"].any? { |_, v| v.key?("VND") }
|
|
114
115
|
end
|
|
115
116
|
return "no VND data" if with_vnd.empty?
|
|
117
|
+
|
|
116
118
|
"USD↔VND #{with_vnd.first}..#{with_vnd.last}"
|
|
117
119
|
else
|
|
118
120
|
# ECB pairs (EUR/GBP/JPY) start 1999
|
|
119
121
|
ecb_years = years.select do |y|
|
|
120
122
|
d = JSON.parse(File.read(File.join(root, "#{y}.json")))
|
|
121
|
-
d["rates"].any? { |_, v|
|
|
123
|
+
d["rates"].any? { |_, v| v.keys.intersect?(%w[EUR GBP JPY]) }
|
|
122
124
|
end
|
|
123
125
|
return "no ECB data" if ecb_years.empty?
|
|
126
|
+
|
|
124
127
|
"USD↔EUR/GBP/JPY daily #{ecb_years.first}..#{ecb_years.last}"
|
|
125
128
|
end
|
|
126
129
|
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.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -51,6 +51,48 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '3.13'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.69'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.69'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop-rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.6'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.6'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rubocop-rspec
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.3'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '3.3'
|
|
54
96
|
description: Offline historical inflation & FX for Ruby - bundled data, no API keys,
|
|
55
97
|
monthly auto-refresh.
|
|
56
98
|
email:
|
|
@@ -130,6 +172,7 @@ metadata:
|
|
|
130
172
|
bug_tracker_uri: https://github.com/patrick204nqh/timeprice/issues
|
|
131
173
|
changelog_uri: https://github.com/patrick204nqh/timeprice/blob/main/CHANGELOG.md
|
|
132
174
|
github_repo: patrick204nqh/timeprice
|
|
175
|
+
rubygems_mfa_required: 'true'
|
|
133
176
|
rdoc_options: []
|
|
134
177
|
require_paths:
|
|
135
178
|
- lib
|