322 lines
11 KiB
Ruby
322 lines
11 KiB
Ruby
|
|
# frozen_string_literal: true
|
||
|
|
|
||
|
|
require_relative "../../helper"
|
||
|
|
require "versions/v2/rate_query"
|
||
|
|
|
||
|
|
module Versions
|
||
|
|
describe V2::RateQuery do
|
||
|
|
it "raises on invalid date" do
|
||
|
|
_ { V2::RateQuery.new(date: "not-a-date") }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "raises on conflicting date params" do
|
||
|
|
date = Fixtures.latest_date.to_s
|
||
|
|
|
||
|
|
_ { V2::RateQuery.new(date:, from: (Fixtures.latest_date - 30).to_s) }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "raises on invalid group" do
|
||
|
|
_ { V2::RateQuery.new(group: "day") }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "accepts valid group" do
|
||
|
|
range_start = (Fixtures.latest_date - 90).to_s
|
||
|
|
range_end = Fixtures.latest_date.to_s
|
||
|
|
query = V2::RateQuery.new(from: range_start, to: range_end, group: "month")
|
||
|
|
|
||
|
|
_(query.to_a).wont_be_empty
|
||
|
|
end
|
||
|
|
|
||
|
|
it "filters by quotes" do
|
||
|
|
query = V2::RateQuery.new(quotes: "USD,GBP")
|
||
|
|
quotes = query.to_a.map { |r| r[:quote] }.uniq.sort
|
||
|
|
|
||
|
|
_(quotes).must_equal(["GBP", "USD"])
|
||
|
|
end
|
||
|
|
|
||
|
|
it "returns empty array for dates before dataset" do
|
||
|
|
query = V2::RateQuery.new(date: "1901-01-01")
|
||
|
|
|
||
|
|
_(query.to_a).must_be_empty
|
||
|
|
end
|
||
|
|
|
||
|
|
it "raises on invalid base currency" do
|
||
|
|
_ { V2::RateQuery.new(base: "FOO") }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "raises on invalid quote currency" do
|
||
|
|
_ { V2::RateQuery.new(quotes: "USD,FOO") }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "raises on invalid base and quotes together" do
|
||
|
|
error = _ { V2::RateQuery.new(base: "FOO", quotes: "BAR") }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
_(error.message).must_include("FOO")
|
||
|
|
_(error.message).must_include("BAR")
|
||
|
|
end
|
||
|
|
|
||
|
|
it "snaps to nearest business day" do
|
||
|
|
sunday = Fixtures.recent_sunday
|
||
|
|
friday = Fixtures.preceding_friday(sunday)
|
||
|
|
query = V2::RateQuery.new(date: sunday.to_s)
|
||
|
|
|
||
|
|
_(query.to_a.first[:date]).must_equal(friday.to_s)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "stamps each row with its own observation date on latest path" do
|
||
|
|
# Carry-forward on the latest path should report each pair's actual observation date,
|
||
|
|
# not flatten everything to the batch max.
|
||
|
|
stale_date = Fixtures.latest_date - 5
|
||
|
|
Rate.dataset.insert(date: stale_date, base: "EUR", quote: "RON", rate: 4.97, provider: "ECB")
|
||
|
|
|
||
|
|
query = V2::RateQuery.new(date: Fixtures.latest_date.to_s)
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
ron = results.find { |r| r[:base] == "EUR" && r[:quote] == "RON" }
|
||
|
|
usd = results.find { |r| r[:base] == "EUR" && r[:quote] == "USD" }
|
||
|
|
|
||
|
|
_(ron[:date]).must_equal(stale_date.to_s)
|
||
|
|
_(usd[:date]).must_equal(Fixtures.latest_date.to_s)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "does not carry forward in range queries" do
|
||
|
|
# A pair published only on a single date inside the range should appear once on that date,
|
||
|
|
# not be carried forward into subsequent days.
|
||
|
|
from = Fixtures.latest_date - 6
|
||
|
|
to = Fixtures.latest_date
|
||
|
|
stale_date = Fixtures.latest_date - 5
|
||
|
|
Rate.dataset.insert(date: stale_date, base: "EUR", quote: "RON", rate: 4.97, provider: "ECB")
|
||
|
|
|
||
|
|
query = V2::RateQuery.new(from: from.to_s, to: to.to_s)
|
||
|
|
ron_rows = query.to_a.select { |r| r[:base] == "EUR" && r[:quote] == "RON" }
|
||
|
|
|
||
|
|
_(ron_rows.size).must_equal(1)
|
||
|
|
_(ron_rows.first[:date]).must_equal(stale_date.to_s)
|
||
|
|
end
|
||
|
|
|
||
|
|
describe "with expand=providers" do
|
||
|
|
it "is omitted by default" do
|
||
|
|
query = V2::RateQuery.new(date: Fixtures.latest_date.to_s, quotes: "USD")
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
_(results).wont_be_empty
|
||
|
|
_(results.first.key?(:providers)).must_equal(false)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "adds providers list to blended rows as {key, rate} objects" do
|
||
|
|
query = V2::RateQuery.new(date: Fixtures.latest_date.to_s, quotes: "USD", expand: "providers")
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
_(results).wont_be_empty
|
||
|
|
providers = results.first[:providers]
|
||
|
|
|
||
|
|
_(providers).must_be_kind_of(Array)
|
||
|
|
_(providers).wont_be_empty
|
||
|
|
_(providers.first).must_be_kind_of(Hash)
|
||
|
|
_(providers.first.keys.sort).must_equal([:key, :rate])
|
||
|
|
_(providers.first[:key]).must_be_kind_of(String)
|
||
|
|
_(providers.first[:rate]).must_be_kind_of(Numeric)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "marks all providers excluded on peg-snapped rows" do
|
||
|
|
date = Fixtures.latest_date
|
||
|
|
Rate.dataset.insert(provider: "ECB", date:, base: "EUR", quote: "AED", rate: 3.97)
|
||
|
|
|
||
|
|
query = V2::RateQuery.new(date: date.to_s, base: "USD", quotes: "AED", expand: "providers")
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
_(results).wont_be_empty
|
||
|
|
_(results.first[:rate]).must_equal(3.6725)
|
||
|
|
providers = results.first[:providers]
|
||
|
|
|
||
|
|
_(providers).wont_be_empty
|
||
|
|
_(providers.all? { |p| p[:excluded] == true }).must_equal(true)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "works with rollup queries" do
|
||
|
|
range_start = (Fixtures.latest_date - 90).to_s
|
||
|
|
range_end = Fixtures.latest_date.to_s
|
||
|
|
query = V2::RateQuery.new(from: range_start, to: range_end, group: "week", quotes: "USD", expand: "providers")
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
_(results).wont_be_empty
|
||
|
|
providers = results.first[:providers]
|
||
|
|
|
||
|
|
_(providers).must_be_kind_of(Array)
|
||
|
|
_(providers).wont_be_empty
|
||
|
|
_(providers.first.keys.sort).must_equal([:key, :rate])
|
||
|
|
end
|
||
|
|
|
||
|
|
it "raises on unknown expand value" do
|
||
|
|
_ { V2::RateQuery.new(expand: "weights") }.must_raise(V2::RateQuery::ValidationError)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
describe "peg gap filling" do
|
||
|
|
# BTN is pegged 1:1 to INR (since 1974). Fixtures have ECB providing INR.
|
||
|
|
# Add a provider that starts covering BTN only recently, leaving older dates
|
||
|
|
# to be filled by the peg.
|
||
|
|
before do
|
||
|
|
cutoff = Fixtures.business_day(30)
|
||
|
|
days = []
|
||
|
|
date = Fixtures.latest_date
|
||
|
|
while date >= cutoff
|
||
|
|
days << date unless date.saturday? || date.sunday?
|
||
|
|
date -= 1
|
||
|
|
end
|
||
|
|
|
||
|
|
records = days.map do |date|
|
||
|
|
{ provider: "TEST", date:, base: "EUR", quote: "BTN", rate: 90.0 }
|
||
|
|
end
|
||
|
|
Rate.dataset.multi_insert(records)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "fills dates before provider coverage with peg-derived rates" do
|
||
|
|
early_date = Fixtures.business_day(60)
|
||
|
|
query = V2::RateQuery.new(date: early_date.to_s, quotes: "BTN")
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
_(results).wont_be_empty
|
||
|
|
_(results.first[:quote]).must_equal("BTN")
|
||
|
|
end
|
||
|
|
|
||
|
|
it "uses provider rates when available" do
|
||
|
|
recent_date = Fixtures.latest_date.to_s
|
||
|
|
query = V2::RateQuery.new(date: recent_date, quotes: "BTN")
|
||
|
|
results = query.to_a
|
||
|
|
|
||
|
|
_(results).wont_be_empty
|
||
|
|
_(results.first[:quote]).must_equal("BTN")
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
describe "#scale_for_pegged_base" do
|
||
|
|
it "drops the base->base row that PegAnchor synthesizes for the user's pegged base" do
|
||
|
|
date = Date.parse("2024-01-15")
|
||
|
|
query = V2::RateQuery.new(base: "AED")
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "USD", quote: "EUR", rate: 0.93 },
|
||
|
|
{ date:, base: "USD", quote: "AED", rate: 3.6725 },
|
||
|
|
]
|
||
|
|
|
||
|
|
result = query.send(:scale_for_pegged_base, rows)
|
||
|
|
|
||
|
|
_(result.find { |r| r[:quote] == "AED" }).must_be_nil
|
||
|
|
_(result.find { |r| r[:quote] == "USD" }[:rate]).must_be_close_to(1.0 / 3.6725)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
describe "?providers= with pegged base" do
|
||
|
|
it "returns empty (peg layer is bypassed when source set is restricted)" do
|
||
|
|
recent_date = Fixtures.latest_date.to_s
|
||
|
|
query = V2::RateQuery.new(date: recent_date, providers: "ECB", base: "AED", quotes: "USD")
|
||
|
|
|
||
|
|
_(query.to_a).must_be_empty
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
describe "#derive" do
|
||
|
|
let(:date) { Date.parse("2024-01-15") }
|
||
|
|
|
||
|
|
def call_derive(rows, target:)
|
||
|
|
V2::RateQuery.allocate.send(:derive, rows, target: target)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "returns [] when target is not in input" do
|
||
|
|
rows = [{ date:, base: "USD", quote: "EUR", rate: 0.93 }]
|
||
|
|
|
||
|
|
_(call_derive(rows, target: "GBP")).must_equal([])
|
||
|
|
end
|
||
|
|
|
||
|
|
it "returns [] when input is empty" do
|
||
|
|
_(call_derive([], target: "GBP")).must_equal([])
|
||
|
|
end
|
||
|
|
|
||
|
|
it "rebases each row to target by division and appends target->pivot row" do
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "USD", quote: "EUR", rate: 0.93, providers: [{ key: "ECB", rate: 0.93 }] },
|
||
|
|
{ date:, base: "USD", quote: "GBP", rate: 0.79, providers: [{ key: "ECB", rate: 0.79 }] },
|
||
|
|
]
|
||
|
|
|
||
|
|
result = call_derive(rows, target: "EUR")
|
||
|
|
gbp = result.find { |r| r[:quote] == "GBP" }
|
||
|
|
usd = result.find { |r| r[:quote] == "USD" }
|
||
|
|
|
||
|
|
_(gbp[:base]).must_equal("EUR")
|
||
|
|
_(gbp[:rate]).must_be_close_to(0.79 / 0.93)
|
||
|
|
_(gbp[:providers].first[:rate]).must_be_close_to(0.79 / 0.93)
|
||
|
|
_(usd[:base]).must_equal("EUR")
|
||
|
|
_(usd[:rate]).must_be_close_to(1.0 / 0.93)
|
||
|
|
_(usd[:providers].first[:rate]).must_be_close_to(1.0 / 0.93)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "drops the target row from output (no base->base row)" do
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "USD", quote: "EUR", rate: 0.93 },
|
||
|
|
{ date:, base: "USD", quote: "GBP", rate: 0.79 },
|
||
|
|
]
|
||
|
|
|
||
|
|
result = call_derive(rows, target: "EUR")
|
||
|
|
|
||
|
|
_(result.find { |r| r[:quote] == "EUR" }).must_be_nil
|
||
|
|
end
|
||
|
|
|
||
|
|
it "produces exact reciprocals between any two non-pivot quotes" do
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "USD", quote: "EUR", rate: 0.93 },
|
||
|
|
{ date:, base: "USD", quote: "GBP", rate: 0.79 },
|
||
|
|
]
|
||
|
|
|
||
|
|
eur_view = call_derive(rows, target: "EUR")
|
||
|
|
gbp_view = call_derive(rows, target: "GBP")
|
||
|
|
eur_to_gbp = eur_view.find { |r| r[:quote] == "GBP" }[:rate]
|
||
|
|
gbp_to_eur = gbp_view.find { |r| r[:quote] == "EUR" }[:rate]
|
||
|
|
|
||
|
|
_(eur_to_gbp * gbp_to_eur).must_be_close_to(1.0, 1e-12)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
describe "#fast_path?" do
|
||
|
|
let(:date) { Date.parse("2024-01-15") }
|
||
|
|
|
||
|
|
def call_fast_path(query, rows)
|
||
|
|
query.send(:fast_path?, rows)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "is true when every row's :base equals effective_base" do
|
||
|
|
query = V2::RateQuery.new(base: "EUR")
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "EUR", quote: "USD", rate: 1.08, provider: "ECB" },
|
||
|
|
{ date:, base: "EUR", quote: "GBP", rate: 0.86, provider: "ECB" },
|
||
|
|
]
|
||
|
|
|
||
|
|
_(call_fast_path(query, rows)).must_equal(true)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "is false when any row has a different :base" do
|
||
|
|
query = V2::RateQuery.new(base: "EUR")
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "EUR", quote: "USD", rate: 1.08, provider: "ECB" },
|
||
|
|
{ date:, base: "CAD", quote: "USD", rate: 0.74, provider: "BOC" },
|
||
|
|
]
|
||
|
|
|
||
|
|
_(call_fast_path(query, rows)).must_equal(false)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "uses the peg's base as effective_base for a pegged request base" do
|
||
|
|
query = V2::RateQuery.new(base: "AED")
|
||
|
|
rows = [
|
||
|
|
{ date:, base: "USD", quote: "EUR", rate: 0.93, provider: "ECB" },
|
||
|
|
]
|
||
|
|
|
||
|
|
_(call_fast_path(query, rows)).must_equal(true)
|
||
|
|
end
|
||
|
|
|
||
|
|
it "is true on empty input (vacuous)" do
|
||
|
|
query = V2::RateQuery.new(base: "EUR")
|
||
|
|
|
||
|
|
_(call_fast_path(query, [])).must_equal(true)
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|
||
|
|
end
|