Module: Timeprice::Exchange
- Defined in:
- lib/timeprice/exchange.rb
Overview
Historical FX conversion using bundled per-year USD-base rate files. Handles identity (USD→USD), direct lookup, inverse, and triangulation through USD. Weekend/holiday dates fall back up to MAX_FALLBACK_DAYS days to the nearest prior trading day.
Constant Summary collapse
- BASE =
"USD"- MAX_FALLBACK_DAYS =
7
Class Method Summary collapse
-
.convert(amount:, from:, to:, date:) ⇒ ExchangeResult
Convert ‘amount` from currency `from` to currency `to` on `date`.
-
.lookup_usd_base(currency, d) ⇒ Object
Walk back up to MAX_FALLBACK_DAYS to find a rate.
- .parse_date(date) ⇒ Object
-
.resolve_rate(from, to, d) ⇒ Object
Returns [rate (Float), effective_date (Date)].
Class Method Details
.convert(amount:, from:, to:, date:) ⇒ ExchangeResult
Convert ‘amount` from currency `from` to currency `to` on `date`.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/timeprice/exchange.rb', line 31 def convert(amount:, from:, to:, date:) from = from.to_s.upcase to = to.to_s.upcase raise UnsupportedCurrency, from unless SUPPORTED_CURRENCIES.include?(from) raise UnsupportedCurrency, to unless SUPPORTED_CURRENCIES.include?(to) d = parse_date(date) rate, eff_date = resolve_rate(from, to, d) ExchangeResult.new( amount: amount.to_f * rate, original_amount: amount.to_f, from: from, to: to, date: d.to_s, effective_date: eff_date.to_s, rate: rate ) end |
.lookup_usd_base(currency, d) ⇒ Object
Walk back up to MAX_FALLBACK_DAYS to find a rate. Returns [rate, effective_date].
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/timeprice/exchange.rb', line 81 def lookup_usd_base(currency, d) (0..MAX_FALLBACK_DAYS).each do |offset| candidate = d - offset year_data = begin DataLoader.load_fx_year(candidate.year) rescue DataNotFound next end rates_for_day = year_data.dig("rates", candidate.to_s) next unless rates_for_day rate = rates_for_day[currency] next unless rate return [rate.to_f, candidate] end raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}" end |
.parse_date(date) ⇒ Object
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/timeprice/exchange.rb', line 101 def parse_date(date) case date when Date then date when String unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/) raise ArgumentError, "Invalid date format: #{date.inspect} (use YYYY-MM-DD)" end begin Date.parse(date) rescue Date::Error raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date" end else raise ArgumentError, "Invalid date: #{date.inspect}" end end |
.resolve_rate(from, to, d) ⇒ Object
Returns [rate (Float), effective_date (Date)]. Handles:
- identity (from == to)
- direct lookup of USD-base rate
- inverse (foreign → USD)
- triangulation through USD (both legs must resolve to SAME effective date)
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/timeprice/exchange.rb', line 57 def resolve_rate(from, to, d) return [1.0, d] if from == to if from == BASE rate, eff = lookup_usd_base(to, d) [rate, eff] elsif to == BASE rate, eff = lookup_usd_base(from, d) [1.0 / rate, eff] else # Triangulation: from → USD → to, both legs at the same effective date. usd_to_from, eff_a = lookup_usd_base(from, d) usd_to_to, eff_b = lookup_usd_base(to, d) if eff_a != eff_b raise DataNotFound, "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \ "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}" end [usd_to_to / usd_to_from, eff_a] end end |