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
-
.annual_fallback(currency, year) ⇒ Object
Consult data/fx/usd/_annual.json.
-
.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 daily rate; if none, fall back to data/fx/usd/_annual.json (the single source of annual FX truth).
- .parse_date(date) ⇒ Object
-
.resolve_rate(from, to, d) ⇒ Object
Returns [rate (Float), effective_date (Date), granularity (Symbol)].
Class Method Details
.annual_fallback(currency, year) ⇒ Object
Consult data/fx/usd/_annual.json. Returns Float or nil.
112 113 114 115 116 117 |
# File 'lib/timeprice/exchange.rb', line 112 def annual_fallback(currency, year) fallback = DataLoader.load_fx_annual_fallback return nil unless fallback fallback.dig("annual", year.to_s, currency)&.to_f end |
.convert(amount:, from:, to:, date:) ⇒ ExchangeResult
Convert ‘amount` from currency `from` to currency `to` on `date`.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/timeprice/exchange.rb', line 33 def convert(amount:, from:, to:, date:) from = from.to_s.upcase to = to.to_s.upcase raise UnsupportedCurrency, from unless Supported.currency?(from) raise UnsupportedCurrency, to unless Supported.currency?(to) d = parse_date(date) rate, eff_date, granularity = 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, granularity: granularity ) end |
.lookup_usd_base(currency, d) ⇒ Object
Walk back up to MAX_FALLBACK_DAYS to find a daily rate; if none, fall back to data/fx/usd/_annual.json (the single source of annual FX truth). Returns [rate, effective_date, granularity].
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/timeprice/exchange.rb', line 87 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, Granularity::DAILY] end annual_rate = annual_fallback(currency, d.year) return [annual_rate, d, Granularity::ANNUAL] if annual_rate raise DataNotFound, "No FX rate for USD->#{currency} on or before #{d}" end |
.parse_date(date) ⇒ Object
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/timeprice/exchange.rb', line 119 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), granularity (Symbol)]. Granularity is :daily when the rate came from a per-date entry, :annual when it came from the per-year ‘annual` fallback block. Triangulation merges both legs via Granularity.merge (worst-precision-wins). Handles:
- identity (from == to)
- direct lookup of USD-base rate
- inverse (foreign → USD)
- triangulation through USD (both legs must resolve to SAME effective date)
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/timeprice/exchange.rb', line 63 def resolve_rate(from, to, d) return [1.0, d, Granularity::DAILY] if from == to if from == BASE lookup_usd_base(to, d) elsif to == BASE rate, eff, gran = lookup_usd_base(from, d) [1.0 / rate, eff, gran] else # Triangulation: from → USD → to, both legs at the same effective date. usd_to_from, eff_a, gran_a = lookup_usd_base(from, d) usd_to_to, eff_b, gran_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, Granularity.merge(gran_a, gran_b)] end end |