frankfurter/lib/weighted_average.rb

48 lines
1.5 KiB
Ruby
Raw Permalink Normal View History

2026-05-21 01:38:14 -06:00
# frozen_string_literal: true
# Recency-weighted averaging of rebased exchange rates. Rates within the grace period carry full weight.
# Beyond it, weight decays exponentially so stale rates contribute less without a hard cutoff. Rates marked
# `excluded: true` (e.g. consensus outliers) are surfaced in the providers list but do not contribute to the rate.
class WeightedAverage
DECAY_GRACE_DAYS = 3
DECAY_RATE = 0.5
def initialize(rates)
@rates = rates
end
def calculate
@rates.each { |r| r[:date] = Date.parse(r[:date]) unless r[:date].is_a?(Date) }
reference_date = @rates.map { |r| r[:date] }.max
return [] unless reference_date
@rates.group_by { |r| r[:quote] }.sort.filter_map do |_, group|
contributors = group.reject { |r| r[:excluded] }
next if contributors.empty?
weighted = contributors.map { |r| [r, recency_weight(reference_date - r[:date])] }
total_weight = weighted.sum(&:last)
rate = weighted.sum { |r, w| r[:rate] * w } / total_weight
providers = group
.group_by { |r| r[:provider] }
.map do |key, rows|
latest = rows.max_by { |r| r[:date] }
entry = { key: key, rate: latest[:rate] }
entry[:excluded] = true if latest[:excluded]
entry
end
.sort_by { |p| p[:key] }
contributors.max_by { |r| r[:date] }.merge(rate:, providers:)
end
end
private
def recency_weight(days_old)
Math.exp(-DECAY_RATE * [days_old - DECAY_GRACE_DAYS, 0].max)
end
end