48 lines
1.5 KiB
Ruby
48 lines
1.5 KiB
Ruby
|
|
# 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
|