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.
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
- true
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: Float(amount),
85
+ amount: parse_amount(amount),
31
86
  from: options[:from],
32
87
  to: options[:to],
33
88
  country: options[:country]
34
89
  )
35
- emit_inflation(result)
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: Float(amount),
99
+ amount: parse_amount(amount),
45
100
  from: from_currency,
46
101
  to: to_currency,
47
102
  date: options[:date]
48
103
  )
49
- emit_exchange(result)
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: Float(amount),
116
+ amount: parse_amount(amount),
62
117
  from: from_tuple,
63
118
  to: to_tuple
64
119
  )
65
- emit_compare(result)
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
- list = Timeprice::Sources.list
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
- # Currencies with no minor unit — render whole numbers, no decimals.
98
- ZERO_DECIMAL_CURRENCIES = %w[JPY VND].freeze
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
- def fmt_rate(rate)
133
- abs = rate.to_f.abs
134
- decimals = if abs >= 1000 then 0
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
- def emit_compare(result)
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
@@ -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: Numeric
41
- # from: [currency, date_or_year] e.g. ["USD", "2010"] or ["USD", "2010-06"]
42
- # to: [currency, date_or_year]
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
- 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)
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: from_currency,
58
- to: to_currency,
59
- date: fx_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: from_date.to_s,
68
- to: to_date.to_s,
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: from_currency,
76
- from_date: from_date.to_s,
77
- to_currency: to_currency,
78
- to_date: to_date.to_s,
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
- # 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
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
- @cpi_cache[[data_root, key]] ||= begin
32
- raise UnsupportedCountry, code unless SUPPORTED_COUNTRIES.include?(code)
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
- @fx_cache[[data_root, key]] ||= begin
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"]
@@ -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
- SUPPORTED_COUNTRIES = %w[US UK EU JP VN].freeze
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: #{SUPPORTED_COUNTRIES.join(", ")})")
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: #{SUPPORTED_CURRENCIES.join(", ")})")
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
 
@@ -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
- # date: "YYYY-MM-DD".
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 SUPPORTED_CURRENCIES.include?(from)
24
- raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to)
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