frankfurter/lib/versions/v2/rate_query.rb

331 lines
9.4 KiB
Ruby
Raw Normal View History

2026-05-21 01:38:14 -06:00
# frozen_string_literal: true
require "digest"
require "set"
require "roda"
require "rate"
require "weekly_rate"
require "monthly_rate"
require "roundable"
require "blender"
require "carry_forward"
require "money/currency"
require "peg"
require "peg_anchor"
module Versions
class V2 < Roda
class RateQuery
include Roundable
class ValidationError < StandardError; end
ALLOWED_EXPANSIONS = ["providers"].freeze
ALLOWED_PARAMS = ["base", "quotes", "providers", "date", "from", "to", "group", "expand"].freeze
CHUNK_MONTHS = { "week" => 21, "month" => 84 }.freeze
DEFAULT_CHUNK_MONTHS = 3
PIVOT = "USD"
def initialize(params)
@params = params
validate!
end
def to_a
@rates ||= [].tap { |a| each { |r| a << r } }
end
def each(&block)
return to_enum(:each) unless block
if date_scope.is_a?(Range)
each_chunk(date_scope) do |chunk_range|
ds = range_dataset
date_col = ds.model.date_column
rows = ds.between(chunk_range).all
normalize_dates!(rows, date_col) if date_col != :date
rows.group_by { |r| r[:date] }.each do |_, group_rows|
emit_blended(group_rows, &block)
end
end
else
window = raw_dataset.where(date: (date_scope - CarryForward::LOOKBACK_DAYS)..date_scope)
rows = CarryForward.apply(window.naked.all, date: date_scope)
emit_blended(rows, &block)
end
end
def range?
date_scope.is_a?(Range)
end
def cache_key
Digest::MD5.hexdigest([max_date, expand].join("|"))
end
def expand_providers?
expand&.include?("providers") || false
end
private
def max_date
ds = raw_dataset
if date_scope.is_a?(Range)
ds.where(date: date_scope).max(:date)
else
ds.where(date: (date_scope - CarryForward::LOOKBACK_DAYS)..date_scope).max(:date)
end
end
def rollup?
range? && ["week", "month"].include?(group)
end
def rollup_model
case group
when "week" then WeeklyRate
when "month" then MonthlyRate
end
end
def apply_filters(ds)
ds = ds.where(provider: providers) if providers
if quotes
currencies = Set.new(quotes)
currencies << effective_base
currencies << PIVOT
quotes.each { |q| (peg = Peg.find(q)) && currencies << peg.base }
ds = ds.only(*currencies)
end
ds
end
def raw_dataset
apply_filters(Rate.dataset)
end
def range_dataset
apply_filters(rollup? ? rollup_model.dataset : Rate.dataset)
end
def base
@params[:base]&.upcase || "EUR"
end
def base_peg
return @base_peg if defined?(@base_peg)
@base_peg = Peg.find(base)
end
def effective_base
base_peg&.base || base
end
def quotes
@params[:quotes]&.upcase&.split(",")
end
def providers
@params[:providers]&.upcase&.split(",")
end
def group
@params[:group]&.downcase
end
def expand
@params[:expand]&.downcase&.split(",")
end
def date
parse_date(@params[:date])
end
def start_date
parse_date(@params[:from])
end
def end_date
parse_date(@params[:to])
end
def parse_date(value)
return unless value
Date.parse(value)
rescue Date::Error
nil
end
def validate!
validate_params!
validate_dates!
validate_conflicting_params!
validate_group!
validate_expand!
validate_currencies!
end
def validate_params!
unknown = @params.keys.map(&:to_s) - ALLOWED_PARAMS
raise ValidationError, "unknown parameter: #{unknown.join(", ")}" if unknown.any?
end
def validate_dates!
raise ValidationError, "invalid date" if [:date, :from, :to].any? { |key| @params[key] && !parse_date(@params[key]) }
end
def validate_conflicting_params!
raise ValidationError, "conflicting params" if date && (start_date || end_date)
end
def validate_group!
raise ValidationError, "invalid group" if group && !["week", "month"].include?(group)
end
def validate_expand!
return unless expand
unknown = expand - ALLOWED_EXPANSIONS
raise ValidationError, "invalid expand: #{unknown.join(",")}" if unknown.any?
end
def validate_currencies!
invalid = []
invalid << base if @params[:base] && !Money::Currency.find(base)
invalid.concat(quotes.reject { |q| Money::Currency.find(q) }) if quotes
raise ValidationError, "invalid currency: #{invalid.join(",")}" if invalid.any?
end
def date_scope
if date
date
elsif start_date
start_date..(end_date || Date.today)
else
Date.today
end
end
def emit_blended(rows, &block)
blended = if fast_path?(rows)
fast_path_blend(rows)
else
pivot_path_blend(rows)
end
return if blended.empty?
records = blended.filter_map do |r|
next if quotes && !quotes.include?(r[:quote])
record = { date: r[:date].to_s, base: r[:base], quote: r[:quote], rate: round(r[:rate]) }
if expand_providers? && r[:providers]
record[:providers] = r[:providers].map do |p|
entry = { key: p[:key], rate: round(p[:rate]) }
entry[:excluded] = true if p[:excluded]
entry
end
end
record
end
records.sort_by! { |r| r[:quote] }
records.each(&block)
end
def fast_path_blend(rows)
return [] if providers && base_peg
blended = Blender.new(rows, base: effective_base).blend
blended = PegAnchor.apply(blended, base: effective_base) unless providers
scale_for_pegged_base(blended)
end
def pivot_path_blend(rows)
blended = Blender.new(rows, base: PIVOT).blend
blended = PegAnchor.apply(blended, base: PIVOT) unless providers
return [] if blended.empty?
return blended if base == PIVOT
derive(blended, target: base)
end
# Fast-path counterpart to derive: when the request base is pegged but every input row already lives in the
# peg's base (effective_base), the blend stays in effective_base. We then scale the rows to the user's base by
# 1/peg.rate and append a base->peg.base row. Mirrors the legacy PegAnchor#scale_to_user_base + base_peg_row,
# confined to the fast path; the pivot path's `derive` already yields rows in the requested base.
def scale_for_pegged_base(rows)
return rows unless base_peg
return [] if rows.empty?
scaled = rows.filter_map do |r|
next if r[:quote] == base
rebase_row(r, base: base) { |x| x / base_peg.rate }
end
unless scaled.any? { |r| r[:quote] == base_peg.base }
ref = scaled.map { |r| r[:date] }.max
scaled << { date: ref, base: base, quote: base_peg.base, rate: 1.0 / base_peg.rate }
end
scaled
end
def normalize_dates!(rows, date_col)
rows.each { |r| r.values[:date] = r.values.delete(date_col) }
end
def each_chunk(range)
months = CHUNK_MONTHS.fetch(group, DEFAULT_CHUNK_MONTHS)
cursor = range.begin
while cursor <= range.end
chunk_end = [(cursor >> months) - 1, range.end].min
yield cursor..chunk_end
cursor = chunk_end + 1
end
end
# True iff every input row already has effective_base as its :base. In that case there is nothing to
# cross-convert, so the blend can run directly in user's base without round-tripping through PIVOT.
def fast_path?(rows)
rows.all? { |r| r[:base] == effective_base }
end
# Given rows blended in PIVOT (one per :quote), return rows rebased to `target` by division. Appends a
# `target -> PIVOT` row. Rows whose quote is `target` are dropped (no base->base row). Returns [] if
# `target` is not present in the input (no path to derive).
def derive(rows, target:)
return [] if rows.empty?
pivot_to_target = rows.find { |r| r[:quote] == target }
return [] unless pivot_to_target
target_rate = pivot_to_target[:rate]
derived = rows.filter_map do |r|
next if r[:quote] == target
rebase_row(r, base: target) { |x| x / target_rate }
end
derived << rebase_row(pivot_to_target, base: target, quote: pivot_to_target[:base]) { |x| 1.0 / x }
derived
end
# Apply the same transform to a row's :rate and to each provider's :rate (when present). Other row fields
# can be overridden via keyword args (e.g. base:, quote:). Used to rebase blended rows together with the
# per-provider rates that produced them, so the providers list stays consistent with the row's base.
def rebase_row(row, **overrides)
new_row = row.merge(**overrides, rate: yield(row[:rate]))
if row[:providers]
new_row[:providers] = row[:providers].map { |p| p.merge(rate: yield(p[:rate])) }
end
new_row
end
end
end
end