timeprice 0.1.2 → 0.3.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 +63 -0
- data/README.md +98 -4
- data/lib/timeprice/cli/formatting.rb +34 -0
- data/lib/timeprice/cli/presenters/compare.rb +44 -0
- data/lib/timeprice/cli/presenters/exchange.rb +45 -0
- data/lib/timeprice/cli/presenters/inflation.rb +36 -0
- data/lib/timeprice/cli/presenters/sources.rb +65 -0
- data/lib/timeprice/cli.rb +83 -118
- data/lib/timeprice/compare.rb +30 -40
- data/lib/timeprice/cpi_lookup.rb +62 -0
- data/lib/timeprice/data_loader.rb +31 -5
- data/lib/timeprice/errors.rb +12 -5
- data/lib/timeprice/exchange.rb +15 -3
- data/lib/timeprice/inflation.rb +31 -55
- data/lib/timeprice/point.rb +62 -0
- data/lib/timeprice/sources/coverage.rb +71 -0
- data/lib/timeprice/sources.rb +3 -49
- data/lib/timeprice/supported.rb +62 -0
- data/lib/timeprice/version.rb +1 -1
- data/lib/timeprice.rb +40 -0
- metadata +10 -1
data/lib/timeprice/cli.rb
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "../timeprice"
|
|
6
|
+
require_relative "cli/presenters/inflation"
|
|
7
|
+
require_relative "cli/presenters/exchange"
|
|
8
|
+
require_relative "cli/presenters/compare"
|
|
9
|
+
require_relative "cli/presenters/sources"
|
|
6
10
|
|
|
7
11
|
module Timeprice
|
|
8
12
|
class CLI < Thor
|
|
@@ -16,8 +20,59 @@ module Timeprice
|
|
|
16
20
|
|
|
17
21
|
class_option :json, type: :boolean, default: false, desc: "Output result as JSON"
|
|
18
22
|
|
|
23
|
+
# Return false so Thor::Error propagates to our wrapper in `start`, where
|
|
24
|
+
# we prettify the message and add a `See: timeprice help COMMAND` hint.
|
|
19
25
|
def self.exit_on_failure?
|
|
20
|
-
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
KNOWN_COMMANDS = %w[inflation fx compare sources version].freeze
|
|
30
|
+
|
|
31
|
+
# Top-level help lists command names + descriptions only — matching git,
|
|
32
|
+
# gh, cargo. Arg signatures live in per-command help (`timeprice help fx`).
|
|
33
|
+
HELP_ROWS = [
|
|
34
|
+
["inflation", "Inflation-adjust an amount between two dates"],
|
|
35
|
+
["fx", "Convert an amount between currencies on a date"],
|
|
36
|
+
["compare", "Combine FX + inflation across (year, currency) points"],
|
|
37
|
+
["sources", "List bundled data sources and coverage"],
|
|
38
|
+
["version", "Print the installed timeprice version"],
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Pass debug: true so Thor re-raises Thor::Error (including option-parse
|
|
42
|
+
# failures) instead of printing its own message and silently continuing
|
|
43
|
+
# when exit_on_failure? is false. We catch and format ourselves.
|
|
44
|
+
def self.start(given_args = ARGV, config = {})
|
|
45
|
+
super(given_args, config.merge(debug: true))
|
|
46
|
+
rescue Thor::Error => e
|
|
47
|
+
warn "Error: #{prettify_thor_message(e.message)}"
|
|
48
|
+
cmd = given_args.first
|
|
49
|
+
warn " See: timeprice help #{cmd}" if KNOWN_COMMANDS.include?(cmd)
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.prettify_thor_message(msg)
|
|
54
|
+
msg
|
|
55
|
+
.sub(/\ANo value provided for required options /, "missing required options: ")
|
|
56
|
+
.gsub("'", "")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Thor's API dictates the positional boolean signature — keyword arg
|
|
60
|
+
# would break the override.
|
|
61
|
+
def self.help(shell, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
62
|
+
return super if subcommand
|
|
63
|
+
|
|
64
|
+
shell.say "timeprice — offline historical inflation & FX for Ruby"
|
|
65
|
+
shell.say ""
|
|
66
|
+
shell.say "Commands:"
|
|
67
|
+
width = HELP_ROWS.map { |usage, _| usage.length }.max
|
|
68
|
+
HELP_ROWS.each do |usage, desc|
|
|
69
|
+
shell.say format(" %-#{width}s %s", usage, desc)
|
|
70
|
+
end
|
|
71
|
+
shell.say ""
|
|
72
|
+
shell.say "Global options:"
|
|
73
|
+
shell.say " --json Output result as JSON"
|
|
74
|
+
shell.say ""
|
|
75
|
+
shell.say "Run `timeprice help COMMAND` for usage and options."
|
|
21
76
|
end
|
|
22
77
|
|
|
23
78
|
desc "inflation AMOUNT", "Inflation-adjust an amount between two dates"
|
|
@@ -27,12 +82,12 @@ module Timeprice
|
|
|
27
82
|
def inflation(amount)
|
|
28
83
|
with_error_handling do
|
|
29
84
|
result = Timeprice.inflation(
|
|
30
|
-
amount:
|
|
85
|
+
amount: parse_amount(amount),
|
|
31
86
|
from: options[:from],
|
|
32
87
|
to: options[:to],
|
|
33
88
|
country: options[:country]
|
|
34
89
|
)
|
|
35
|
-
|
|
90
|
+
render Presenters::Inflation.new(result)
|
|
36
91
|
end
|
|
37
92
|
end
|
|
38
93
|
|
|
@@ -41,12 +96,12 @@ module Timeprice
|
|
|
41
96
|
def fx(amount, from_currency, to_currency)
|
|
42
97
|
with_error_handling do
|
|
43
98
|
result = Timeprice.exchange(
|
|
44
|
-
amount:
|
|
99
|
+
amount: parse_amount(amount),
|
|
45
100
|
from: from_currency,
|
|
46
101
|
to: to_currency,
|
|
47
102
|
date: options[:date]
|
|
48
103
|
)
|
|
49
|
-
|
|
104
|
+
render Presenters::Exchange.new(result)
|
|
50
105
|
end
|
|
51
106
|
end
|
|
52
107
|
|
|
@@ -58,30 +113,19 @@ module Timeprice
|
|
|
58
113
|
from_tuple = parse_compare_token(options[:from], label: "--from")
|
|
59
114
|
to_tuple = parse_compare_token(options[:to], label: "--to")
|
|
60
115
|
result = Timeprice.compare(
|
|
61
|
-
amount:
|
|
116
|
+
amount: parse_amount(amount),
|
|
62
117
|
from: from_tuple,
|
|
63
118
|
to: to_tuple
|
|
64
119
|
)
|
|
65
|
-
|
|
120
|
+
render Presenters::Compare.new(result)
|
|
66
121
|
end
|
|
67
122
|
end
|
|
68
123
|
|
|
69
124
|
desc "sources", "List bundled data sources and coverage"
|
|
125
|
+
method_option :verbose, type: :boolean, default: false, aliases: "-v",
|
|
126
|
+
desc: "Include license URLs and full attribution"
|
|
70
127
|
def sources
|
|
71
|
-
|
|
72
|
-
if options[:json]
|
|
73
|
-
say JSON.generate(list)
|
|
74
|
-
else
|
|
75
|
-
list.each do |s|
|
|
76
|
-
say s[:name].to_s
|
|
77
|
-
say " id: #{s[:id]}"
|
|
78
|
-
say " license: #{s[:license]}"
|
|
79
|
-
say " license_url: #{s[:license_url]}"
|
|
80
|
-
say " attribution: #{s[:attribution]}"
|
|
81
|
-
say " coverage: #{s[:coverage]}"
|
|
82
|
-
say ""
|
|
83
|
-
end
|
|
84
|
-
end
|
|
128
|
+
render Presenters::Sources.new(Timeprice::Sources.list, verbose: options[:verbose])
|
|
85
129
|
end
|
|
86
130
|
|
|
87
131
|
desc "version", "Print the installed timeprice version"
|
|
@@ -94,19 +138,27 @@ module Timeprice
|
|
|
94
138
|
end
|
|
95
139
|
|
|
96
140
|
no_commands do
|
|
97
|
-
|
|
98
|
-
|
|
141
|
+
def render(presenter)
|
|
142
|
+
if options[:json]
|
|
143
|
+
say JSON.generate(presenter.json_hash)
|
|
144
|
+
else
|
|
145
|
+
presenter.text_lines.each { |line| say line }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
99
148
|
|
|
100
149
|
def with_error_handling
|
|
101
150
|
yield
|
|
102
|
-
rescue Timeprice::Error => e
|
|
103
|
-
warn "Error: #{e.message}"
|
|
104
|
-
exit 1
|
|
105
|
-
rescue ArgumentError => e
|
|
151
|
+
rescue Timeprice::Error, ArgumentError => e
|
|
106
152
|
warn "Error: #{e.message}"
|
|
107
153
|
exit 1
|
|
108
154
|
end
|
|
109
155
|
|
|
156
|
+
def parse_amount(raw)
|
|
157
|
+
Float(raw)
|
|
158
|
+
rescue ArgumentError, TypeError
|
|
159
|
+
raise ArgumentError, "AMOUNT must be a number, got #{raw.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
110
162
|
def parse_compare_token(token, label:)
|
|
111
163
|
raise ArgumentError, "#{label} is required" if token.nil? || token.strip.empty?
|
|
112
164
|
|
|
@@ -115,100 +167,13 @@ module Timeprice
|
|
|
115
167
|
raise ArgumentError,
|
|
116
168
|
"#{label} must be \"YEAR CURRENCY\" or \"CURRENCY YEAR\", got #{token.inspect}"
|
|
117
169
|
end
|
|
118
|
-
year = parts.find { |p| p.match?(/\A\d{4}\z/) }
|
|
119
|
-
currency = parts.find { |p| p.match?(/\A[A-Za-z]{3}\z/) }
|
|
120
|
-
if year.nil? || currency.nil?
|
|
121
|
-
raise ArgumentError,
|
|
122
|
-
"#{label} must contain a 4-digit year and a 3-letter currency code, got #{token.inspect}"
|
|
123
|
-
end
|
|
124
|
-
[currency.upcase, year]
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def fmt_money(amount, currency)
|
|
128
|
-
decimals = ZERO_DECIMAL_CURRENCIES.include?(currency.to_s.upcase) ? 0 : 2
|
|
129
|
-
format("%.#{decimals}f", amount)
|
|
130
|
-
end
|
|
131
170
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
elsif abs >= 100 then 2
|
|
136
|
-
elsif abs >= 10 then 3
|
|
137
|
-
else 4
|
|
138
|
-
end
|
|
139
|
-
format("%.#{decimals}f", rate)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Granularity is loud noise on the happy path. Only surface it when the
|
|
143
|
-
# answer actually used annual data — that's where users want a heads-up.
|
|
144
|
-
def granularity_suffix(granularity)
|
|
145
|
-
return "" if granularity == :monthly
|
|
146
|
-
|
|
147
|
-
" (granularity: #{granularity})"
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def emit_inflation(result)
|
|
151
|
-
if options[:json]
|
|
152
|
-
say JSON.generate(result.to_h)
|
|
153
|
-
else
|
|
154
|
-
ccy = result.country_currency_label
|
|
155
|
-
say format(
|
|
156
|
-
"%s %s in %s is %s %s in %s [%s]%s",
|
|
157
|
-
fmt_money(result.original_amount, ccy), ccy, result.from,
|
|
158
|
-
fmt_money(result.amount, ccy), ccy, result.to,
|
|
159
|
-
result.country, granularity_suffix(result.granularity)
|
|
160
|
-
)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def emit_exchange(result)
|
|
165
|
-
if options[:json]
|
|
166
|
-
say JSON.generate(result.to_h)
|
|
167
|
-
else
|
|
168
|
-
line = format(
|
|
169
|
-
"%s %s on %s = %s %s (rate: %s)",
|
|
170
|
-
fmt_money(result.original_amount, result.from), result.from, result.date,
|
|
171
|
-
fmt_money(result.amount, result.to), result.to, fmt_rate(result.rate)
|
|
172
|
-
)
|
|
173
|
-
if result.effective_date && result.effective_date != result.date
|
|
174
|
-
line += " [effective: #{result.effective_date}, fallback]"
|
|
175
|
-
end
|
|
176
|
-
say line
|
|
177
|
-
end
|
|
178
|
-
end
|
|
171
|
+
Point.coerce(parts)
|
|
172
|
+
rescue ArgumentError => e
|
|
173
|
+
raise if e.message.start_with?(label)
|
|
179
174
|
|
|
180
|
-
|
|
181
|
-
if options[:json]
|
|
182
|
-
say JSON.generate(result.to_h)
|
|
183
|
-
else
|
|
184
|
-
say format(
|
|
185
|
-
"%s %s in %s -> %s %s in %s",
|
|
186
|
-
fmt_money(result.original_amount, result.from_currency), result.from_currency, result.from_date,
|
|
187
|
-
fmt_money(result.amount, result.to_currency), result.to_currency, result.to_date
|
|
188
|
-
)
|
|
189
|
-
say format(
|
|
190
|
-
" steps: %s %s -> %s %s (fx %s on %s), then inflate in %s x%.4f%s",
|
|
191
|
-
fmt_money(result.original_amount, result.from_currency), result.from_currency,
|
|
192
|
-
fmt_money(result.converted_amount, result.to_currency), result.to_currency,
|
|
193
|
-
fmt_rate(result.fx_rate), result.from_date,
|
|
194
|
-
result.country, result.cpi_ratio, granularity_suffix(result.granularity)
|
|
195
|
-
)
|
|
196
|
-
end
|
|
175
|
+
raise ArgumentError, "#{label}: #{e.message}"
|
|
197
176
|
end
|
|
198
177
|
end
|
|
199
178
|
end
|
|
200
179
|
end
|
|
201
|
-
|
|
202
|
-
# Tiny shim so we can include currency context in the inflation line without
|
|
203
|
-
# bloating the value object — the result doesn't carry currency, only country.
|
|
204
|
-
module Timeprice
|
|
205
|
-
class InflationResult
|
|
206
|
-
COUNTRY_TO_CURRENCY = {
|
|
207
|
-
"US" => "USD", "UK" => "GBP", "EU" => "EUR", "JP" => "JPY", "VN" => "VND"
|
|
208
|
-
}.freeze
|
|
209
|
-
|
|
210
|
-
def country_currency_label
|
|
211
|
-
COUNTRY_TO_CURRENCY[country.to_s.upcase] || country.to_s.upcase
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
end
|
data/lib/timeprice/compare.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "supported"
|
|
5
|
+
require_relative "point"
|
|
4
6
|
require_relative "inflation"
|
|
5
7
|
require_relative "exchange"
|
|
6
8
|
|
|
@@ -26,37 +28,25 @@ module Timeprice
|
|
|
26
28
|
# If a future refactor flips the order, the regression test in
|
|
27
29
|
# spec/timeprice/compare_spec.rb will fail.
|
|
28
30
|
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
31
|
module_function
|
|
39
32
|
|
|
40
|
-
# amount
|
|
41
|
-
#
|
|
42
|
-
#
|
|
33
|
+
# Compare an amount across two (currency, date) points.
|
|
34
|
+
#
|
|
35
|
+
# @param amount [Numeric]
|
|
36
|
+
# @param from [Timeprice::Point, Array(String, String)] source point;
|
|
37
|
+
# accepts a {Point} or a 2-tuple like `["USD", "2010"]` or `["USD", "2010-06"]`
|
|
38
|
+
# @param to [Timeprice::Point, Array(String, String)] destination point
|
|
39
|
+
# @return [CompareResult]
|
|
40
|
+
# @raise [UnsupportedCurrency] if either currency is not in {Supported::CURRENCIES}
|
|
43
41
|
def run(amount:, from:, to:)
|
|
44
|
-
|
|
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)
|
|
42
|
+
from_point, to_point, to_country = resolve_points(from, to)
|
|
52
43
|
|
|
53
44
|
# Step 1: convert at source date into destination currency.
|
|
54
|
-
fx_date = normalize_fx_date(from_date)
|
|
55
45
|
fx_result = Exchange.convert(
|
|
56
46
|
amount: amount,
|
|
57
|
-
from:
|
|
58
|
-
to:
|
|
59
|
-
date:
|
|
47
|
+
from: from_point.currency,
|
|
48
|
+
to: to_point.currency,
|
|
49
|
+
date: from_point.fx_anchor_date
|
|
60
50
|
)
|
|
61
51
|
converted = fx_result.amount
|
|
62
52
|
|
|
@@ -64,18 +54,18 @@ module Timeprice
|
|
|
64
54
|
# destination date using destination-country CPI.
|
|
65
55
|
infl = Inflation.adjust(
|
|
66
56
|
amount: converted,
|
|
67
|
-
from:
|
|
68
|
-
to:
|
|
57
|
+
from: from_point.date.to_s,
|
|
58
|
+
to: to_point.date.to_s,
|
|
69
59
|
country: to_country
|
|
70
60
|
)
|
|
71
61
|
|
|
72
62
|
CompareResult.new(
|
|
73
63
|
amount: infl.amount,
|
|
74
64
|
original_amount: amount.to_f,
|
|
75
|
-
from_currency:
|
|
76
|
-
from_date:
|
|
77
|
-
to_currency:
|
|
78
|
-
to_date:
|
|
65
|
+
from_currency: from_point.currency,
|
|
66
|
+
from_date: from_point.date.to_s,
|
|
67
|
+
to_currency: to_point.currency,
|
|
68
|
+
to_date: to_point.date.to_s,
|
|
79
69
|
country: to_country,
|
|
80
70
|
fx_rate: fx_result.rate,
|
|
81
71
|
cpi_ratio: infl.to_index.to_f / infl.from_index,
|
|
@@ -84,16 +74,16 @@ module Timeprice
|
|
|
84
74
|
)
|
|
85
75
|
end
|
|
86
76
|
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
77
|
+
# Coerce both points and resolve to_country.
|
|
78
|
+
def resolve_points(from, to)
|
|
79
|
+
from_point = Point.coerce(from)
|
|
80
|
+
to_point = Point.coerce(to)
|
|
81
|
+
raise UnsupportedCurrency, from_point.currency unless Supported.country_for_currency(from_point.currency)
|
|
82
|
+
|
|
83
|
+
to_country = Supported.country_for_currency(to_point.currency)
|
|
84
|
+
raise UnsupportedCurrency, to_point.currency unless to_country
|
|
85
|
+
|
|
86
|
+
[from_point, to_point, to_country]
|
|
97
87
|
end
|
|
98
88
|
end
|
|
99
89
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Timeprice
|
|
6
|
+
# CpiPoint pairs a CPI index value with the granularity of how it was
|
|
7
|
+
# resolved (monthly, annual, or annual derived by averaging 12 months).
|
|
8
|
+
CpiPoint = Data.define(:value, :granularity)
|
|
9
|
+
|
|
10
|
+
# Resolves CPI keys ("YYYY" or "YYYY-MM") to a CpiPoint against a single
|
|
11
|
+
# country's parsed CPI data hash. Knowing the JSON shape ("monthly" /
|
|
12
|
+
# "annual" string keys) is isolated here — Inflation just asks for points.
|
|
13
|
+
class CpiLookup
|
|
14
|
+
def initialize(data)
|
|
15
|
+
@data = data
|
|
16
|
+
@monthly = data["monthly"] || {}
|
|
17
|
+
@annual = data["annual"] || {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param key [String] "YYYY" or "YYYY-MM"
|
|
21
|
+
# @return [CpiPoint]
|
|
22
|
+
# @raise [DataNotFound] if no CPI value covers `key`
|
|
23
|
+
# @raise [ArgumentError] on malformed `key`
|
|
24
|
+
def at(key)
|
|
25
|
+
key = key.to_s
|
|
26
|
+
case key
|
|
27
|
+
when /\A\d{4}-\d{2}\z/ then monthly_or_annual_fallback(key)
|
|
28
|
+
when /\A\d{4}\z/ then annual_or_monthly_average(key)
|
|
29
|
+
else raise ArgumentError, "Invalid date format: #{key.inspect} (use YYYY or YYYY-MM)"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def monthly_or_annual_fallback(month_key)
|
|
36
|
+
return CpiPoint.new(value: @monthly[month_key], granularity: :monthly) if @monthly.key?(month_key)
|
|
37
|
+
|
|
38
|
+
year = month_key[0, 4]
|
|
39
|
+
raise DataNotFound, missing_message(month_key) unless @annual.key?(year)
|
|
40
|
+
|
|
41
|
+
CpiPoint.new(value: @annual[year], granularity: :annual)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def annual_or_monthly_average(year)
|
|
45
|
+
return CpiPoint.new(value: @annual[year], granularity: :annual) if @annual.key?(year)
|
|
46
|
+
|
|
47
|
+
months = @monthly.select { |k, _| k.start_with?("#{year}-") }
|
|
48
|
+
raise DataNotFound, missing_message(year) if months.empty?
|
|
49
|
+
|
|
50
|
+
CpiPoint.new(value: months.values.sum.to_f / months.size, granularity: :annual_from_monthly_avg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def missing_message(key)
|
|
54
|
+
country = @data["country"]
|
|
55
|
+
ranges = []
|
|
56
|
+
ranges << "monthly #{@monthly.keys.min}..#{@monthly.keys.max}" if @monthly.any?
|
|
57
|
+
ranges << "annual #{@annual.keys.min}..#{@annual.keys.max}" if @annual.any?
|
|
58
|
+
hint = ranges.empty? ? "" : " (supported: #{ranges.join(", ")})"
|
|
59
|
+
"No CPI data for #{key.inspect} in #{country}#{hint}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -2,34 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "errors"
|
|
5
|
+
require_relative "supported"
|
|
5
6
|
|
|
6
7
|
module Timeprice
|
|
8
|
+
# Loads and caches the bundled JSON data files. Override the search root
|
|
9
|
+
# by setting `TIMEPRICE_DATA_ROOT` in the environment or assigning
|
|
10
|
+
# {DataLoader.data_root=}.
|
|
7
11
|
module DataLoader
|
|
8
12
|
SUPPORTED_SCHEMA_VERSION = 1
|
|
9
13
|
|
|
10
14
|
DEFAULT_DATA_ROOT = File.expand_path("../../data", __dir__)
|
|
11
15
|
|
|
12
16
|
class << self
|
|
17
|
+
# @return [String] absolute path to the directory containing `cpi/` and `fx/`
|
|
13
18
|
def data_root
|
|
14
19
|
ENV["TIMEPRICE_DATA_ROOT"] || @data_root || DEFAULT_DATA_ROOT
|
|
15
20
|
end
|
|
16
21
|
|
|
22
|
+
# Override the data root and clear caches. Mostly useful in tests.
|
|
23
|
+
# @param path [String]
|
|
24
|
+
# @return [void]
|
|
17
25
|
def data_root=(path)
|
|
18
26
|
@data_root = path
|
|
19
27
|
clear_cache!
|
|
20
28
|
end
|
|
21
29
|
|
|
30
|
+
# Drop in-memory caches of parsed data files.
|
|
31
|
+
# @return [void]
|
|
22
32
|
def clear_cache!
|
|
23
33
|
@cpi_cache = {}
|
|
24
34
|
@fx_cache = {}
|
|
25
35
|
end
|
|
26
36
|
|
|
37
|
+
# Load the CPI series for a supported country.
|
|
38
|
+
# @param country [String]
|
|
39
|
+
# @return [Hash] parsed JSON with "monthly" / "annual" / metadata keys
|
|
40
|
+
# @raise [UnsupportedCountry] if `country` is not in {Supported::COUNTRIES}
|
|
41
|
+
# @raise [DataNotFound] if the file is missing
|
|
42
|
+
# @raise [UnsupportedSchemaVersion] if the file uses a future schema
|
|
27
43
|
def load_cpi(country)
|
|
28
|
-
@cpi_cache ||= {}
|
|
29
44
|
key = country.to_s.downcase
|
|
30
45
|
code = country.to_s.upcase
|
|
31
|
-
|
|
32
|
-
raise UnsupportedCountry, code unless
|
|
46
|
+
cpi_cache[[data_root, key]] ||= begin
|
|
47
|
+
raise UnsupportedCountry, code unless Supported::COUNTRIES.include?(code)
|
|
33
48
|
|
|
34
49
|
path = File.join(data_root, "cpi", "#{key}.json")
|
|
35
50
|
unless File.exist?(path)
|
|
@@ -41,10 +56,13 @@ module Timeprice
|
|
|
41
56
|
end
|
|
42
57
|
end
|
|
43
58
|
|
|
59
|
+
# Load the FX rates for a year.
|
|
60
|
+
# @param year [Integer, String]
|
|
61
|
+
# @return [Hash] parsed JSON with a "rates" map of date → currency → Float
|
|
62
|
+
# @raise [DataNotFound] if the per-year file is missing
|
|
44
63
|
def load_fx_year(year)
|
|
45
|
-
@fx_cache ||= {}
|
|
46
64
|
key = year.to_i
|
|
47
|
-
|
|
65
|
+
fx_cache[[data_root, key]] ||= begin
|
|
48
66
|
path = File.join(data_root, "fx", "usd", "#{key}.json")
|
|
49
67
|
raise DataNotFound, "No FX data for year #{key}" unless File.exist?(path)
|
|
50
68
|
|
|
@@ -54,6 +72,14 @@ module Timeprice
|
|
|
54
72
|
|
|
55
73
|
private
|
|
56
74
|
|
|
75
|
+
def cpi_cache
|
|
76
|
+
@cpi_cache ||= {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def fx_cache
|
|
80
|
+
@fx_cache ||= {}
|
|
81
|
+
end
|
|
82
|
+
|
|
57
83
|
def parse_with_schema(path)
|
|
58
84
|
data = JSON.parse(File.read(path))
|
|
59
85
|
version = data["schema_version"]
|
data/lib/timeprice/errors.rb
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "supported"
|
|
4
|
+
|
|
3
5
|
module Timeprice
|
|
6
|
+
# Base class for every error this library raises. Catch `Timeprice::Error`
|
|
7
|
+
# to handle anything the gem can throw at you.
|
|
4
8
|
class Error < StandardError; end
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
SUPPORTED_CURRENCIES = %w[USD GBP EUR JPY VND].freeze
|
|
8
|
-
|
|
10
|
+
# Raised when a country code is not in {Supported::COUNTRIES}.
|
|
9
11
|
class UnsupportedCountry < Error
|
|
10
12
|
attr_reader :country
|
|
11
13
|
|
|
12
14
|
def initialize(country)
|
|
13
15
|
@country = country
|
|
14
|
-
super("Unsupported country: #{country.inspect} (supported: #{
|
|
16
|
+
super("Unsupported country: #{country.inspect} (supported: #{Supported::COUNTRIES.join(", ")})")
|
|
15
17
|
end
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# Raised when a currency code is not in {Supported::CURRENCIES}.
|
|
18
21
|
class UnsupportedCurrency < Error
|
|
19
22
|
attr_reader :currency
|
|
20
23
|
|
|
21
24
|
def initialize(currency)
|
|
22
25
|
@currency = currency
|
|
23
|
-
super("Unsupported currency: #{currency.inspect} (supported: #{
|
|
26
|
+
super("Unsupported currency: #{currency.inspect} (supported: #{Supported::CURRENCIES.join(", ")})")
|
|
24
27
|
end
|
|
25
28
|
end
|
|
26
29
|
|
|
30
|
+
# Raised when a requested date falls outside the bundled data range.
|
|
27
31
|
class DateOutOfRange < Error
|
|
28
32
|
attr_reader :date, :range
|
|
29
33
|
|
|
@@ -34,12 +38,15 @@ module Timeprice
|
|
|
34
38
|
end
|
|
35
39
|
end
|
|
36
40
|
|
|
41
|
+
# Raised when a CPI or FX lookup has no usable data point.
|
|
37
42
|
class DataNotFound < Error
|
|
38
43
|
def initialize(message = "Data not found")
|
|
39
44
|
super
|
|
40
45
|
end
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
# Raised when a bundled data file declares a schema_version this gem
|
|
49
|
+
# doesn't know how to parse (forward-compat guard).
|
|
43
50
|
class UnsupportedSchemaVersion < Error
|
|
44
51
|
attr_reader :version, :path
|
|
45
52
|
|
data/lib/timeprice/exchange.rb
CHANGED
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
require "date"
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
require_relative "data_loader"
|
|
6
|
+
require_relative "supported"
|
|
6
7
|
|
|
7
8
|
module Timeprice
|
|
8
9
|
ExchangeResult = Data.define(
|
|
9
10
|
:amount, :original_amount, :from, :to, :date, :effective_date, :rate
|
|
10
11
|
)
|
|
11
12
|
|
|
13
|
+
# Historical FX conversion using bundled per-year USD-base rate files.
|
|
14
|
+
# Handles identity (USD→USD), direct lookup, inverse, and triangulation
|
|
15
|
+
# through USD. Weekend/holiday dates fall back up to {MAX_FALLBACK_DAYS}
|
|
16
|
+
# days to the nearest prior trading day.
|
|
12
17
|
module Exchange
|
|
13
18
|
BASE = "USD"
|
|
14
19
|
MAX_FALLBACK_DAYS = 7
|
|
@@ -16,12 +21,19 @@ module Timeprice
|
|
|
16
21
|
module_function
|
|
17
22
|
|
|
18
23
|
# Convert `amount` from currency `from` to currency `to` on `date`.
|
|
19
|
-
#
|
|
24
|
+
#
|
|
25
|
+
# @param amount [Numeric]
|
|
26
|
+
# @param from [String] ISO 4217 source currency
|
|
27
|
+
# @param to [String] ISO 4217 destination currency
|
|
28
|
+
# @param date [String, Date] date as "YYYY-MM-DD" or a Date instance
|
|
29
|
+
# @return [ExchangeResult]
|
|
30
|
+
# @raise [UnsupportedCurrency] if `from` or `to` is not supported
|
|
31
|
+
# @raise [DataNotFound] if no FX point exists within {MAX_FALLBACK_DAYS}
|
|
20
32
|
def convert(amount:, from:, to:, date:)
|
|
21
33
|
from = from.to_s.upcase
|
|
22
34
|
to = to.to_s.upcase
|
|
23
|
-
raise UnsupportedCurrency, from unless
|
|
24
|
-
raise UnsupportedCurrency, to unless
|
|
35
|
+
raise UnsupportedCurrency, from unless Supported::CURRENCIES.include?(from)
|
|
36
|
+
raise UnsupportedCurrency, to unless Supported::CURRENCIES.include?(to)
|
|
25
37
|
|
|
26
38
|
d = parse_date(date)
|
|
27
39
|
|