mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 11:34:13 +00:00
Add improvements from security providers to FX providers also (#1445)
* FIX prefer provider rate always - add debugging also * Move logic from securities over * FIXes * Review fixes * Update provided.rb --------- Signed-off-by: soky srm <sokysrm@gmail.com>
This commit is contained in:
@@ -6,6 +6,15 @@
|
||||
|
||||
<%= render UI::Account::Chart.new(account: account, period: chart_period, view: chart_view) %>
|
||||
|
||||
<% if (fx_coverage_start = fx_coverage_start_date).present? %>
|
||||
<div class="px-3">
|
||||
<%= render DS::Alert.new(
|
||||
message: t("accounts.show.limited_fx_history_warning", date: l(fx_coverage_start, format: :long)),
|
||||
variant: :warning
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div data-testid="account-details">
|
||||
<% if tabs.count > 1 %>
|
||||
<%= render DS::Tabs.new(active_tab: active_tab, url_param_key: "tab") do |tabs_container| %>
|
||||
|
||||
@@ -47,6 +47,23 @@ class UI::AccountPage < ApplicationComponent
|
||||
end
|
||||
end
|
||||
|
||||
def fx_coverage_start_date
|
||||
return @fx_coverage_start_date if defined?(@fx_coverage_start_date)
|
||||
|
||||
result = nil
|
||||
if account.family.present? && account.currency != account.family.currency
|
||||
pair = ExchangeRatePair.for_pair(from: account.currency, to: account.family.currency)
|
||||
if pair.first_provider_rate_on.present?
|
||||
oldest_entry = account.entries.minimum(:date)
|
||||
if oldest_entry.present? && oldest_entry < pair.first_provider_rate_on
|
||||
result = pair.first_provider_rate_on
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@fx_coverage_start_date = result
|
||||
end
|
||||
|
||||
def tab_content_for(tab)
|
||||
case tab
|
||||
when :activity
|
||||
|
||||
@@ -2,6 +2,8 @@ class ExchangeRate::Importer
|
||||
MissingExchangeRateError = Class.new(StandardError)
|
||||
MissingStartRateError = Class.new(StandardError)
|
||||
|
||||
PROVISIONAL_LOOKBACK_DAYS = 5
|
||||
|
||||
def initialize(exchange_rate_provider:, from:, to:, start_date:, end_date:, clear_cache: false)
|
||||
@exchange_rate_provider = exchange_rate_provider
|
||||
@from = from
|
||||
@@ -11,7 +13,6 @@ class ExchangeRate::Importer
|
||||
@clear_cache = clear_cache
|
||||
end
|
||||
|
||||
# Constructs a daily series of rates for the given currency pair for date range
|
||||
def import_provider_rates
|
||||
if !clear_cache && all_rates_exist?
|
||||
Rails.logger.info("No new rates to sync for #{from} to #{to} between #{start_date} and #{end_date}, skipping")
|
||||
@@ -26,6 +27,24 @@ class ExchangeRate::Importer
|
||||
|
||||
prev_rate_value = start_rate_value
|
||||
|
||||
# Always find the earliest valid provider rate for pair metadata tracking.
|
||||
# record_first_provider_rate_on's atomic guard prevents moving the date forward.
|
||||
earliest_valid_provider_date = provider_rates.values
|
||||
.select { |r| r.rate.present? && r.rate.to_f > 0 }
|
||||
.min_by(&:date)&.date
|
||||
|
||||
# When no anchor rate exists, advance the loop start to the earliest provider rate
|
||||
loop_start_date = fill_start_date
|
||||
if prev_rate_value.blank? && earliest_valid_provider_date
|
||||
earliest_rate = provider_rates[earliest_valid_provider_date]
|
||||
Rails.logger.info(
|
||||
"#{from}->#{to}: no provider rate on or before #{start_date}; " \
|
||||
"advancing gapfill start to earliest valid provider date #{earliest_valid_provider_date}"
|
||||
)
|
||||
prev_rate_value = earliest_rate.rate
|
||||
loop_start_date = earliest_valid_provider_date
|
||||
end
|
||||
|
||||
unless prev_rate_value.present?
|
||||
error = MissingStartRateError.new("Could not find a start rate for #{from} to #{to} between #{start_date} and #{end_date}")
|
||||
Rails.logger.error(error.message)
|
||||
@@ -33,20 +52,18 @@ class ExchangeRate::Importer
|
||||
return
|
||||
end
|
||||
|
||||
gapfilled_rates = effective_start_date.upto(end_date).map do |date|
|
||||
# Gapfill with LOCF strategy (last observation carried forward):
|
||||
# when the provider returns nothing for weekends/holidays, carry the previous rate.
|
||||
gapfilled_rates = loop_start_date.upto(end_date).map do |date|
|
||||
db_rate_value = db_rates[date]&.rate
|
||||
provider_rate_value = provider_rates[date]&.rate
|
||||
|
||||
chosen_rate = if clear_cache
|
||||
provider_rate_value || db_rate_value # overwrite when possible
|
||||
chosen_rate = if provider_rate_value.present? && provider_rate_value.to_f > 0
|
||||
provider_rate_value
|
||||
elsif db_rate_value.present? && db_rate_value.to_f > 0
|
||||
db_rate_value
|
||||
else
|
||||
db_rate_value || provider_rate_value # fill gaps
|
||||
end
|
||||
|
||||
# Gapfill with LOCF strategy (last observation carried forward)
|
||||
# Treat nil or zero rates as invalid and use previous rate
|
||||
if chosen_rate.nil? || chosen_rate.to_f <= 0
|
||||
chosen_rate = prev_rate_value
|
||||
prev_rate_value
|
||||
end
|
||||
|
||||
prev_rate_value = chosen_rate
|
||||
@@ -76,14 +93,27 @@ class ExchangeRate::Importer
|
||||
|
||||
upsert_rows(inverse_rates)
|
||||
|
||||
# Also backfill inverse rows for any forward rates that existed in the DB
|
||||
# before effective_start_date (i.e. dates not covered by gapfilled_rates).
|
||||
# Backfill inverse rows for any forward rates that existed in the DB
|
||||
# before the loop range (i.e. dates not covered by gapfilled_rates).
|
||||
backfill_inverse_rates_if_needed
|
||||
|
||||
if earliest_valid_provider_date.present?
|
||||
ExchangeRatePair.record_first_provider_rate_on(
|
||||
from: from, to: to, date: earliest_valid_provider_date,
|
||||
provider_name: current_provider_name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :exchange_rate_provider, :from, :to, :start_date, :end_date, :clear_cache
|
||||
|
||||
# Resolves the provider name the same way as ExchangeRate::Provided.provider:
|
||||
# ENV takes precedence over the DB Setting to stay consistent in env-configured deployments.
|
||||
def current_provider_name
|
||||
@current_provider_name ||= (ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider).to_s
|
||||
end
|
||||
|
||||
def upsert_rows(rows)
|
||||
batch_size = 200
|
||||
|
||||
@@ -102,34 +132,82 @@ class ExchangeRate::Importer
|
||||
total_upsert_count
|
||||
end
|
||||
|
||||
# Since provider may not return values on weekends and holidays, we grab the first rate from the provider that is on or before the start date
|
||||
def start_rate_value
|
||||
provider_rate_value = provider_rates.select { |date, _| date <= start_date }.max_by { |date, _| date }&.last&.rate
|
||||
db_rate_value = db_rates[start_date]&.rate
|
||||
provider_rate_value || db_rate_value
|
||||
if fill_start_date == start_date
|
||||
provider_rate_value = latest_valid_provider_rate(before_or_on: start_date)
|
||||
db_rate_value = db_rates[start_date]&.rate
|
||||
|
||||
return provider_rate_value if provider_rate_value.present?
|
||||
return db_rate_value if db_rate_value.present? && db_rate_value.to_f > 0
|
||||
return nil
|
||||
end
|
||||
|
||||
cutoff_date = fill_start_date
|
||||
|
||||
provider_rate_value = latest_valid_provider_rate(before: cutoff_date)
|
||||
return provider_rate_value if provider_rate_value.present?
|
||||
|
||||
ExchangeRate
|
||||
.where(from_currency: from, to_currency: to)
|
||||
.where("date < ?", cutoff_date)
|
||||
.where("rate > 0")
|
||||
.order(date: :desc)
|
||||
.limit(1)
|
||||
.pick(:rate)
|
||||
end
|
||||
|
||||
# Scans provider_rates for the most recent entry with a positive rate,
|
||||
# rather than just picking the latest row (which could be zero/nil).
|
||||
def latest_valid_provider_rate(before_or_on: nil, before: nil)
|
||||
cutoff = before_or_on || before
|
||||
comparator = before_or_on ? :<= : :<
|
||||
|
||||
provider_rates
|
||||
.select { |date, r| date.send(comparator, cutoff) && r.rate.present? && r.rate.to_f > 0 }
|
||||
.max_by { |date, _| date }&.last&.rate
|
||||
end
|
||||
|
||||
def clamped_start_date
|
||||
@clamped_start_date ||= begin
|
||||
listed = exchange_rate_pair.first_provider_rate_on
|
||||
listed.present? && listed > start_date ? listed : start_date
|
||||
end
|
||||
end
|
||||
|
||||
def exchange_rate_pair
|
||||
@exchange_rate_pair ||= ExchangeRatePair.for_pair(from: from, to: to, provider_name: current_provider_name)
|
||||
end
|
||||
|
||||
def fill_start_date
|
||||
@fill_start_date ||= [ provider_fetch_start_date, effective_start_date ].max
|
||||
end
|
||||
|
||||
def provider_fetch_start_date
|
||||
@provider_fetch_start_date ||= begin
|
||||
base = effective_start_date - PROVISIONAL_LOOKBACK_DAYS.days
|
||||
max_days = exchange_rate_provider.respond_to?(:max_history_days) ? exchange_rate_provider.max_history_days : nil
|
||||
|
||||
if max_days && (end_date - base).to_i > max_days
|
||||
clamped = end_date - max_days.days
|
||||
Rails.logger.info(
|
||||
"#{exchange_rate_provider.class.name} max history is #{max_days} days; " \
|
||||
"clamping #{from}->#{to} start_date from #{base} to #{clamped}"
|
||||
)
|
||||
clamped
|
||||
else
|
||||
base
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# No need to fetch/upsert rates for dates that we already have in the DB
|
||||
def effective_start_date
|
||||
return start_date if clear_cache
|
||||
|
||||
first_missing_date = nil
|
||||
|
||||
start_date.upto(end_date) do |date|
|
||||
unless db_rates.key?(date)
|
||||
first_missing_date = date
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
first_missing_date || end_date
|
||||
(clamped_start_date..end_date).detect { |d| !db_rates.key?(d) } || end_date
|
||||
end
|
||||
|
||||
def provider_rates
|
||||
@provider_rates ||= begin
|
||||
# Always fetch with a 5 day buffer to ensure we have a starting rate (for weekends and holidays)
|
||||
provider_fetch_start_date = effective_start_date - 5.days
|
||||
|
||||
provider_response = exchange_rate_provider.fetch_exchange_rates(
|
||||
from: from,
|
||||
to: to,
|
||||
@@ -138,6 +216,7 @@ class ExchangeRate::Importer
|
||||
)
|
||||
|
||||
if provider_response.success?
|
||||
Rails.logger.debug("Fetched #{provider_response.data.size} rates from #{exchange_rate_provider.class.name} for #{from}/#{to} between #{provider_fetch_start_date} and #{end_date}")
|
||||
provider_response.data.index_by(&:date)
|
||||
else
|
||||
message = "#{exchange_rate_provider.class.name} could not fetch exchange rate pair from: #{from} to: #{to} between: #{effective_start_date} and: #{Date.current}. Provider error: #{provider_response.error.message}"
|
||||
@@ -148,11 +227,8 @@ class ExchangeRate::Importer
|
||||
end
|
||||
end
|
||||
|
||||
# When forward rates already exist but inverse rates are missing (e.g. from a
|
||||
# deployment before inverse computation was added), backfill them from the DB
|
||||
# without making any provider API calls.
|
||||
def backfill_inverse_rates_if_needed
|
||||
existing_inverse_dates = ExchangeRate.where(from_currency: to, to_currency: from, date: start_date..end_date).pluck(:date).to_set
|
||||
existing_inverse_dates = ExchangeRate.where(from_currency: to, to_currency: from, date: clamped_start_date..end_date).pluck(:date).to_set
|
||||
return if existing_inverse_dates.size >= expected_count
|
||||
|
||||
inverse_rows = db_rates.filter_map do |_date, rate|
|
||||
@@ -175,11 +251,13 @@ class ExchangeRate::Importer
|
||||
end
|
||||
|
||||
def expected_count
|
||||
(start_date..end_date).count
|
||||
(clamped_start_date..end_date).count
|
||||
end
|
||||
|
||||
def db_count
|
||||
db_rates.count
|
||||
ExchangeRate
|
||||
.where(from_currency: from, to_currency: to, date: clamped_start_date..end_date)
|
||||
.count
|
||||
end
|
||||
|
||||
def db_rates
|
||||
@@ -190,8 +268,7 @@ class ExchangeRate::Importer
|
||||
end
|
||||
|
||||
# Normalizes an end date so that it never exceeds today's date in the
|
||||
# America/New_York timezone. If the caller passes a future date we clamp
|
||||
# it to today so that upstream provider calls remain valid and predictable.
|
||||
# America/New_York timezone.
|
||||
def normalize_end_date(requested_end_date)
|
||||
today_est = Date.current.in_time_zone("America/New_York").to_date
|
||||
[ requested_end_date, today_est ].min
|
||||
|
||||
@@ -58,6 +58,11 @@ module ExchangeRate::Provided
|
||||
def rates_for(currencies, to:, date: Date.current)
|
||||
currencies.uniq.each_with_object({}) do |currency, map|
|
||||
rate = find_or_fetch_rate(from: currency, to: to, date: date)
|
||||
if rate.nil?
|
||||
Rails.logger.warn("No exchange rate found for #{currency}/#{to} on #{date}, using 1")
|
||||
elsif rate.date != date
|
||||
Rails.logger.debug("FX rate #{currency}/#{to}: using #{rate.date} for #{date} (gap=#{(date - rate.date).to_i}d)")
|
||||
end
|
||||
map[currency] = rate&.rate || 1
|
||||
end
|
||||
end
|
||||
|
||||
42
app/models/exchange_rate_pair.rb
Normal file
42
app/models/exchange_rate_pair.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class ExchangeRatePair < ApplicationRecord
|
||||
validates :from_currency, :to_currency, presence: true
|
||||
|
||||
def self.for_pair(from:, to:, provider_name: nil)
|
||||
pair = find_or_create_by!(from_currency: from, to_currency: to)
|
||||
current_provider = provider_name || resolve_provider_name
|
||||
|
||||
if pair.provider_name != current_provider && pair.first_provider_rate_on.present?
|
||||
ExchangeRatePair
|
||||
.where(id: pair.id)
|
||||
.where.not(provider_name: current_provider)
|
||||
.update_all(first_provider_rate_on: nil, provider_name: current_provider, updated_at: Time.current)
|
||||
pair.reload
|
||||
end
|
||||
|
||||
pair
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
find_by!(from_currency: from, to_currency: to)
|
||||
end
|
||||
|
||||
# Resolves the runtime provider name the same way as ExchangeRate::Provided.provider:
|
||||
# ENV takes precedence over the DB Setting.
|
||||
def self.resolve_provider_name
|
||||
(ENV["EXCHANGE_RATE_PROVIDER"].presence || Setting.exchange_rate_provider).to_s
|
||||
end
|
||||
|
||||
def self.record_first_provider_rate_on(from:, to:, date:, provider_name: nil)
|
||||
return if date.blank?
|
||||
|
||||
current_provider = provider_name || resolve_provider_name
|
||||
pair = for_pair(from: from, to: to, provider_name: current_provider)
|
||||
|
||||
ExchangeRatePair
|
||||
.where(id: pair.id)
|
||||
.where("first_provider_rate_on IS NULL OR first_provider_rate_on > ?", date)
|
||||
.update_all(
|
||||
first_provider_rate_on: date,
|
||||
provider_name: current_provider,
|
||||
updated_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -10,4 +10,12 @@ module Provider::ExchangeRateConcept
|
||||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
|
||||
end
|
||||
|
||||
# Maximum number of calendar days of historical FX data the provider can
|
||||
# return. Returns nil when the provider has no known limit (unbounded).
|
||||
# Callers should clamp start_date when non-nil to avoid requesting data
|
||||
# beyond this window. Override in subclasses with provider-specific limits.
|
||||
def max_history_days
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,6 +60,7 @@ en:
|
||||
title: How would you like to add it?
|
||||
title: What would you like to add?
|
||||
show:
|
||||
limited_fx_history_warning: "Exchange rate history is only available from %{date} onwards. Transactions before this date use approximate currency conversions — this can happen when the FX provider only offers a limited historical window."
|
||||
activity:
|
||||
amount: Amount
|
||||
balance: Balance
|
||||
|
||||
16
db/migrate/20260412120000_create_exchange_rate_pairs.rb
Normal file
16
db/migrate/20260412120000_create_exchange_rate_pairs.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class CreateExchangeRatePairs < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :exchange_rate_pairs, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||
t.string :from_currency, null: false
|
||||
t.string :to_currency, null: false
|
||||
t.date :first_provider_rate_on
|
||||
t.string :provider_name
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :exchange_rate_pairs,
|
||||
[ :from_currency, :to_currency ],
|
||||
unique: true,
|
||||
name: "index_exchange_rate_pairs_on_pair_unique"
|
||||
end
|
||||
end
|
||||
12
db/schema.rb
generated
12
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_04_11_082125) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -551,6 +551,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_11_082125) do
|
||||
t.index ["tags"], name: "index_eval_samples_on_tags", using: :gin
|
||||
end
|
||||
|
||||
create_table "exchange_rate_pairs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "from_currency", null: false
|
||||
t.string "to_currency", null: false
|
||||
t.date "first_provider_rate_on"
|
||||
t.string "provider_name"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["from_currency", "to_currency"], name: "index_exchange_rate_pairs_on_pair_unique", unique: true
|
||||
end
|
||||
|
||||
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "from_currency", null: false
|
||||
t.string "to_currency", null: false
|
||||
|
||||
11
test/fixtures/exchange_rate_pairs.yml
vendored
Normal file
11
test/fixtures/exchange_rate_pairs.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
eur_gbp:
|
||||
from_currency: EUR
|
||||
to_currency: GBP
|
||||
first_provider_rate_on: <%= 10.years.ago.to_date %>
|
||||
provider_name: twelve_data
|
||||
|
||||
usd_jpy_fresh:
|
||||
from_currency: USD
|
||||
to_currency: JPY
|
||||
first_provider_rate_on:
|
||||
provider_name:
|
||||
@@ -169,6 +169,90 @@ class ExchangeRate::ImporterTest < ActiveSupport::TestCase
|
||||
assert_in_delta (1.0 / 0.85), inverse.rate.to_f, 0.0001
|
||||
end
|
||||
|
||||
test "fresh provider values overwrite stale DB rows within the sync window" do
|
||||
ExchangeRate.delete_all
|
||||
|
||||
# Day 1: correct, Day 2: missing (gap), Day 3: stale/wrong, Today: missing.
|
||||
# The gap at day 2 causes effective_start_date = day 2, so the LOCF loop
|
||||
# covers days 2-4. Day 3's stale value should be overwritten by the
|
||||
# provider's fresh value (provider wins over DB).
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 3.days.ago.to_date, rate: 0.86)
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 0.9253)
|
||||
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 0.87),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 0.88),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 0.89)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates)
|
||||
.with(from: "USD", to: "EUR", start_date: get_provider_fetch_start_date(2.days.ago.to_date), end_date: Date.current)
|
||||
.returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 3.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
|
||||
db_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date)
|
||||
assert_equal 4, db_rates.count
|
||||
assert_equal [ 0.86, 0.87, 0.88, 0.89 ], db_rates.map(&:rate)
|
||||
end
|
||||
|
||||
test "backfills missing inverse rates when forward rates already exist" do
|
||||
ExchangeRate.delete_all
|
||||
|
||||
# Create forward rates without inverses (simulating pre-inverse-computation data)
|
||||
(2.days.ago.to_date..Date.current).each_with_index do |date, idx|
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 0.85 + idx * 0.01)
|
||||
end
|
||||
|
||||
# All forward rates exist, so no provider call — but inverse backfill should fire
|
||||
@provider.expects(:fetch_exchange_rates).never
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 2.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
|
||||
inverse_rates = ExchangeRate.where(from_currency: "EUR", to_currency: "USD").order(:date)
|
||||
assert_equal 3, inverse_rates.count
|
||||
|
||||
inverse_rates.each do |inv|
|
||||
forward = ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: inv.date)
|
||||
assert_in_delta (1.0 / forward.rate.to_f), inv.rate.to_f, 0.0001
|
||||
end
|
||||
end
|
||||
|
||||
test "logs error and imports nothing when provider returns only zero and nil rates" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 0),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: nil),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 0)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates).returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 2.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
|
||||
assert_equal 0, ExchangeRate.where(from_currency: "USD", to_currency: "EUR").count
|
||||
end
|
||||
|
||||
test "handles rate limit error gracefully" do
|
||||
ExchangeRate.delete_all
|
||||
|
||||
@@ -190,9 +274,222 @@ class ExchangeRate::ImporterTest < ActiveSupport::TestCase
|
||||
assert_equal 0, ExchangeRate.count, "No rates should be imported on rate limit error"
|
||||
end
|
||||
|
||||
# === Clamping tests (Phase 2) ===
|
||||
|
||||
test "advances gapfill start when pair predates provider history" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
# Provider only returns rates starting 5 days ago (simulating limited history).
|
||||
# start_date is 30 days ago — provider can't serve anything before 5 days ago.
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 5.days.ago.to_date, rate: 1.1),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 4.days.ago.to_date, rate: 1.2),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 3.days.ago.to_date, rate: 1.3),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.4),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.5),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.6)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates).returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 30.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
|
||||
forward_rates = ExchangeRate.where(from_currency: "USD", to_currency: "EUR").order(:date)
|
||||
assert_equal 6, forward_rates.count
|
||||
assert_equal 5.days.ago.to_date, forward_rates.first.date
|
||||
|
||||
pair = ExchangeRatePair.find_by(from_currency: "USD", to_currency: "EUR")
|
||||
assert_equal 5.days.ago.to_date, pair.first_provider_rate_on
|
||||
end
|
||||
|
||||
test "pre-coverage fallback picks earliest valid provider row, skipping zero leaders" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 4.days.ago.to_date, rate: 0),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 3.days.ago.to_date, rate: nil),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.3),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates).returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 30.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
|
||||
pair = ExchangeRatePair.find_by(from_currency: "USD", to_currency: "EUR")
|
||||
assert_equal 2.days.ago.to_date, pair.first_provider_rate_on
|
||||
end
|
||||
|
||||
test "first_provider_rate_on is moved earlier when provider extends backward coverage" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
ExchangeRatePair.create!(
|
||||
from_currency: "USD", to_currency: "EUR",
|
||||
first_provider_rate_on: 3.days.ago.to_date,
|
||||
provider_name: Setting.exchange_rate_provider.to_s
|
||||
)
|
||||
|
||||
# Provider now returns an earlier date with clear_cache
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 10.days.ago.to_date, rate: 1.0),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 9.days.ago.to_date, rate: 1.1),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates).returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 30.days.ago.to_date,
|
||||
end_date: Date.current,
|
||||
clear_cache: true
|
||||
).import_provider_rates
|
||||
|
||||
pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR")
|
||||
assert_equal 10.days.ago.to_date, pair.first_provider_rate_on
|
||||
end
|
||||
|
||||
test "first_provider_rate_on is NOT moved forward when provider shrinks coverage" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
ExchangeRatePair.create!(
|
||||
from_currency: "USD", to_currency: "EUR",
|
||||
first_provider_rate_on: 10.days.ago.to_date,
|
||||
provider_name: Setting.exchange_rate_provider.to_s
|
||||
)
|
||||
|
||||
# Provider now only returns from 3 days ago (shrunk window)
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: 3.days.ago.to_date, rate: 1.3),
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates).returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 30.days.ago.to_date,
|
||||
end_date: Date.current,
|
||||
clear_cache: true
|
||||
).import_provider_rates
|
||||
|
||||
pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR")
|
||||
assert_equal 10.days.ago.to_date, pair.first_provider_rate_on
|
||||
end
|
||||
|
||||
test "incremental sync on pre-coverage pair skips pre-coverage window" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
clamp_date = 5.days.ago.to_date
|
||||
ExchangeRatePair.create!(
|
||||
from_currency: "USD", to_currency: "EUR",
|
||||
first_provider_rate_on: clamp_date,
|
||||
provider_name: Setting.exchange_rate_provider.to_s
|
||||
)
|
||||
|
||||
# Seed DB with rates from clamp to yesterday
|
||||
(clamp_date..1.day.ago.to_date).each_with_index do |date, idx|
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 1.0 + idx * 0.01)
|
||||
end
|
||||
|
||||
# Provider returns today's rate
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5)
|
||||
])
|
||||
|
||||
@provider.expects(:fetch_exchange_rates)
|
||||
.with(from: "USD", to: "EUR",
|
||||
start_date: get_provider_fetch_start_date(Date.current),
|
||||
end_date: Date.current)
|
||||
.returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 30.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
|
||||
assert_equal 1.5, ExchangeRate.find_by(from_currency: "USD", to_currency: "EUR", date: Date.current).rate
|
||||
end
|
||||
|
||||
test "skips provider call when all rates exist in clamped range" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
clamp_date = 3.days.ago.to_date
|
||||
ExchangeRatePair.create!(
|
||||
from_currency: "USD", to_currency: "EUR",
|
||||
first_provider_rate_on: clamp_date,
|
||||
provider_name: Setting.exchange_rate_provider.to_s
|
||||
)
|
||||
|
||||
(clamp_date..Date.current).each_with_index do |date, idx|
|
||||
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR", date: date, rate: 1.0 + idx * 0.01)
|
||||
end
|
||||
|
||||
@provider.expects(:fetch_exchange_rates).never
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 30.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
end
|
||||
|
||||
test "clamps provider fetch to max_history_days when provider exposes limit" do
|
||||
ExchangeRate.delete_all
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
@provider.stubs(:max_history_days).returns(10)
|
||||
|
||||
provider_response = provider_success_response([
|
||||
OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.5)
|
||||
])
|
||||
|
||||
expected_start = Date.current - 10.days
|
||||
@provider.expects(:fetch_exchange_rates)
|
||||
.with(from: "USD", to: "EUR",
|
||||
start_date: expected_start,
|
||||
end_date: Date.current)
|
||||
.returns(provider_response)
|
||||
|
||||
ExchangeRate::Importer.new(
|
||||
exchange_rate_provider: @provider,
|
||||
from: "USD",
|
||||
to: "EUR",
|
||||
start_date: 60.days.ago.to_date,
|
||||
end_date: Date.current
|
||||
).import_provider_rates
|
||||
end
|
||||
|
||||
private
|
||||
def get_provider_fetch_start_date(start_date)
|
||||
# We fetch with a 5 day buffer to account for weekends and holidays
|
||||
start_date - 5.days
|
||||
start_date - ExchangeRate::Importer::PROVISIONAL_LOOKBACK_DAYS.days
|
||||
end
|
||||
end
|
||||
|
||||
78
test/models/exchange_rate_pair_test.rb
Normal file
78
test/models/exchange_rate_pair_test.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
require "test_helper"
|
||||
|
||||
class ExchangeRatePairTest < ActiveSupport::TestCase
|
||||
test "for_pair creates a new pair if none exists" do
|
||||
ExchangeRatePair.delete_all
|
||||
pair = ExchangeRatePair.for_pair(from: "USD", to: "EUR")
|
||||
|
||||
assert_equal "USD", pair.from_currency
|
||||
assert_equal "EUR", pair.to_currency
|
||||
assert_nil pair.first_provider_rate_on
|
||||
end
|
||||
|
||||
test "for_pair returns existing pair idempotently" do
|
||||
ExchangeRatePair.delete_all
|
||||
pair1 = ExchangeRatePair.for_pair(from: "USD", to: "EUR")
|
||||
pair2 = ExchangeRatePair.for_pair(from: "USD", to: "EUR")
|
||||
|
||||
assert_equal pair1.id, pair2.id
|
||||
end
|
||||
|
||||
test "for_pair auto-resets clamp when provider changes" do
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
original_provider = Setting.exchange_rate_provider
|
||||
begin
|
||||
Setting.exchange_rate_provider = "twelve_data"
|
||||
ExchangeRatePair.create!(
|
||||
from_currency: "USD",
|
||||
to_currency: "EUR",
|
||||
first_provider_rate_on: 1.year.ago.to_date,
|
||||
provider_name: "twelve_data"
|
||||
)
|
||||
|
||||
Setting.exchange_rate_provider = "yahoo_finance"
|
||||
refreshed = ExchangeRatePair.for_pair(from: "USD", to: "EUR")
|
||||
|
||||
assert_nil refreshed.first_provider_rate_on
|
||||
assert_equal "yahoo_finance", refreshed.provider_name
|
||||
ensure
|
||||
Setting.exchange_rate_provider = original_provider
|
||||
end
|
||||
end
|
||||
|
||||
test "record_first_provider_rate_on sets date on NULL" do
|
||||
ExchangeRatePair.delete_all
|
||||
ExchangeRatePair.for_pair(from: "USD", to: "EUR")
|
||||
|
||||
ExchangeRatePair.record_first_provider_rate_on(from: "USD", to: "EUR", date: 6.months.ago.to_date)
|
||||
|
||||
pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR")
|
||||
assert_equal 6.months.ago.to_date, pair.first_provider_rate_on
|
||||
end
|
||||
|
||||
test "record_first_provider_rate_on moves earlier but not forward" do
|
||||
ExchangeRatePair.delete_all
|
||||
|
||||
original_provider = Setting.exchange_rate_provider
|
||||
begin
|
||||
Setting.exchange_rate_provider = "twelve_data"
|
||||
ExchangeRatePair.create!(
|
||||
from_currency: "USD",
|
||||
to_currency: "EUR",
|
||||
first_provider_rate_on: 6.months.ago.to_date,
|
||||
provider_name: "twelve_data"
|
||||
)
|
||||
|
||||
ExchangeRatePair.record_first_provider_rate_on(from: "USD", to: "EUR", date: 1.year.ago.to_date)
|
||||
pair = ExchangeRatePair.find_by!(from_currency: "USD", to_currency: "EUR")
|
||||
assert_equal 1.year.ago.to_date, pair.first_provider_rate_on
|
||||
|
||||
ExchangeRatePair.record_first_provider_rate_on(from: "USD", to: "EUR", date: 3.months.ago.to_date)
|
||||
pair.reload
|
||||
assert_equal 1.year.ago.to_date, pair.first_provider_rate_on
|
||||
ensure
|
||||
Setting.exchange_rate_provider = original_provider
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user