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

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

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:



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

Raises:



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