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

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



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

Raises:



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