Module: Timeprice::Exchange Private
- Defined in:
- lib/timeprice/exchange.rb
Overview
This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.
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.
The supported public entry point is exchange. Direct references will move to ‘Timeprice::Internal::Exchange` in a future release.
Constant Summary collapse
- BASE =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
"USD"- MAX_FALLBACK_DAYS =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
7
Class Method Summary collapse
-
.annual_fallback(currency, year) ⇒ Object
private
Consult data/fx/usd/_annual.json.
-
.convert(amount:, from:, to:, date:) ⇒ ExchangeResult
private
Convert ‘amount` from currency `from` to currency `to` on `date`.
-
.lookup_usd_base(currency, d) ⇒ Object
private
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 private
-
.reconcile_triangulation_dates(from, to, d, leg_a, leg_b) ⇒ Object
private
Pick a single effective date for a triangulated rate.
- .require_daily!(date) ⇒ Object private
-
.resolve_rate(from, to, d) ⇒ Object
private
Returns [rate (Float), effective_date (Date), granularity (Symbol)].
Class Method Details
.annual_fallback(currency, year) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Consult data/fx/usd/_annual.json. Returns Float or nil.
132 133 134 135 136 137 |
# File 'lib/timeprice/exchange.rb', line 132 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
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Convert ‘amount` from currency `from` to currency `to` on `date`.
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/timeprice/exchange.rb', line 39 def convert(amount:, from:, to:, date:) from = from.to_s.upcase to = to.to_s.upcase fail UnsupportedCurrency, from unless Supported.currency?(from) fail 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
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
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].
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/timeprice/exchange.rb', line 107 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 fail DataNotFound, "No FX rate for USD->#{currency} on or before #{d}" end |
.parse_date(date) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/timeprice/exchange.rb', line 139 def parse_date(date) case date when ::Date date when Timeprice::Date require_daily!(date) ::Date.new(date.year, date.month, date.day) when String parsed = Timeprice::Date.coerce(date) require_daily!(parsed) ::Date.new(parsed.year, parsed.month, parsed.day) else fail ArgumentError, "Invalid date: #{date.inspect}" end rescue ::Date::Error raise ArgumentError, "Invalid date: #{date.inspect} is not a real calendar date" end |
.reconcile_triangulation_dates(from, to, d, leg_a, leg_b) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Pick a single effective date for a triangulated rate. Daily legs must agree; an annual leg is year-wide so it adopts the daily leg’s date. When both legs are annual we fall back to the requested date.
91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/timeprice/exchange.rb', line 91 def reconcile_triangulation_dates(from, to, d, leg_a, leg_b) eff_a, gran_a = leg_a eff_b, gran_b = leg_b return eff_a if eff_a == eff_b return d if gran_a == Granularity::ANNUAL && gran_b == Granularity::ANNUAL return eff_b if gran_a == Granularity::ANNUAL return eff_a if gran_b == Granularity::ANNUAL fail DataNotFound, "FX triangulation date mismatch for #{from}->#{to} on #{d}: " \ "USD->#{from} resolved #{eff_a}, USD->#{to} resolved #{eff_b}" end |
.require_daily!(date) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
157 158 159 160 161 |
# File 'lib/timeprice/exchange.rb', line 157 def require_daily!(date) return if date.granularity == :daily fail ArgumentError, "Invalid date: Exchange needs YYYY-MM-DD, got #{date}" end |
.resolve_rate(from, to, d) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
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)
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/timeprice/exchange.rb', line 69 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. Daily legs must agree on the # effective date; an annual leg is valid for any date in its year, so # we adopt the daily leg's date and let Granularity.merge demote. rate_a, *leg_a = lookup_usd_base(from, d) rate_b, *leg_b = lookup_usd_base(to, d) eff = reconcile_triangulation_dates(from, to, d, leg_a, leg_b) [rate_b / rate_a, eff, Granularity.merge(leg_a[1], leg_b[1])] end end |