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

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`.

Parameters:

  • amount (Numeric)
  • from (String)

    ISO 4217 source currency

  • to (String)

    ISO 4217 destination currency

  • date (String, Date)

    date as “YYYY-MM-DD” or a Date instance

Returns:

Raises:



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