Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit 1bb3778

Browse files
committed
perf(sync): Preload and cache exchange rate data when syncing.
1 parent 662f2c0 commit 1bb3778

File tree

6 files changed

+226
-44
lines changed

6 files changed

+226
-44
lines changed

app/models/balance/sync_cache.rb

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,69 @@ def get_entries(date)
1515
converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) }
1616
end
1717

18+
def find_rate_by_cache(amount_money, to_currency, date: Date.current, fallback_rate: 1)
19+
raise TypeError unless amount_money.respond_to?(:amount) && amount_money.respond_to?(:currency)
20+
21+
iso_code = Money::Currency.new(amount_money.currency).iso_code
22+
other_iso_code = Money::Currency.new(to_currency).iso_code
23+
24+
return amount_money if iso_code == other_iso_code
25+
26+
exchange_rate = exchange_rates(to_currency)[[ iso_code, date ]]&.last&.rate ||
27+
ExchangeRate.fetch_rate(from: iso_code, to: other_iso_code, date: date)&.rate ||
28+
fallback_rate
29+
30+
raise Money::ConversionError.new(from_currency: iso_code, to_currency: other_iso_code, date: date) unless exchange_rate
31+
32+
Money.new(amount_money.amount * exchange_rate, other_iso_code)
33+
end
34+
1835
private
1936
attr_reader :account
2037

2138
def converted_entries
22-
@converted_entries ||= account.entries.order(:date).to_a.map do |e|
39+
@converted_entries ||= entries.map do |e|
2340
converted_entry = e.dup
24-
converted_entry.amount = converted_entry.amount_money.exchange_to(
41+
converted_entry.amount = find_rate_by_cache(
42+
converted_entry.amount_money,
2543
account.currency,
2644
date: e.date,
27-
fallback_rate: 1
2845
).amount
2946
converted_entry.currency = account.currency
3047
converted_entry
3148
end
3249
end
3350

3451
def converted_holdings
35-
@converted_holdings ||= account.holdings.map do |h|
52+
@converted_holdings ||= holdings.map do |h|
3653
converted_holding = h.dup
37-
converted_holding.amount = converted_holding.amount_money.exchange_to(
54+
converted_holding.amount = find_rate_by_cache(
55+
converted_holding.amount_money,
3856
account.currency,
3957
date: h.date,
40-
fallback_rate: 1
4158
).amount
4259
converted_holding.currency = account.currency
4360
converted_holding
4461
end
4562
end
63+
64+
def entries
65+
@entries ||= account.entries.order(:date).to_a
66+
end
67+
68+
def holdings
69+
@holdings ||= account.holdings
70+
end
71+
72+
def exchange_rates(to_currency)
73+
combined = entries + holdings
74+
all_dates = combined.map(&:date).uniq
75+
all_currencies = combined.map(&:currency).uniq
76+
77+
@exchange_rates ||= ExchangeRate
78+
.where(from_currency: all_currencies, to_currency: to_currency)
79+
.where(date: all_dates)
80+
.order(:date)
81+
.group_by { |r| [ r.from_currency, r.date.to_date ] }
82+
end
4683
end

app/models/exchange_rate/provided.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
2727
rate
2828
end
2929

30+
def fetch_rate(from:, to:, date: Date.current, cache: true)
31+
return nil unless provider.present? # No provider configured (some self-hosted apps)
32+
33+
response = provider.fetch_exchange_rate(from: from, to: to, date: date)
34+
35+
return nil unless response.success? # Provider error
36+
37+
rate = response.data
38+
ExchangeRate.find_or_create_by!(
39+
from_currency: rate.from,
40+
to_currency: rate.to,
41+
date: rate.date,
42+
rate: rate.rate
43+
) if cache
44+
rate
45+
end
46+
3047
# @return [Integer] The number of exchange rates synced
3148
def import_provider_rates(from:, to:, start_date:, end_date:, clear_cache: false)
3249
unless provider.present?

test/models/balance/forward_calculator_test.rb

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
44
include EntriesTestHelper
5+
include ExchangeRateTestHelper
56

67
setup do
78
@account = families(:empty).accounts.create!(
@@ -55,8 +56,10 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
5556
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
5657

5758
expected = [ 0, 500, 500, 400, 400, 400 ]
58-
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
59-
59+
calculated = nil
60+
assert_queries_count(3) do
61+
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
62+
end
6063
assert_equal expected, calculated
6164
end
6265

@@ -75,17 +78,30 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
7578
end
7679

7780
test "multi-currency sync" do
78-
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
81+
load_exchange_prices
7982

80-
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
81-
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
83+
expected = [ 0.0 ]
8284

83-
# Transaction in different currency than the account's main currency
84-
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
85+
for i in (3).downto(0) do
86+
amount = 0
87+
create_transaction(account: @account, date: i.days.ago.to_date, amount: -100, currency: "USD")
88+
create_transaction(account: @account, date: i.days.ago.to_date, amount: -100, currency: "CAD")
89+
create_transaction(account: @account, date: i.days.ago.to_date, amount: -100, currency: "EUR")
8590

86-
expected = [ 0, 100, 400, 1000, 1000 ]
87-
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
91+
amount = @account.entries.where(date: i.days.ago.to_date).map do |e|
92+
e.amount_money.exchange_to(
93+
@account.currency,
94+
date: e.date,
95+
).amount
96+
end
8897

98+
expected << expected.last + (amount.sum * -1)
99+
end
100+
101+
calculated = nil
102+
assert_queries_count(4) do
103+
calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
104+
end
89105
assert_equal expected, calculated
90106
end
91107

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
require "test_helper"
2+
3+
class Balance::SyncCacheTest < ActiveSupport::TestCase
4+
include EntriesTestHelper
5+
include ExchangeRateTestHelper
6+
7+
setup do
8+
@account = families(:empty).accounts.create!(
9+
name: "Test",
10+
balance: 20000,
11+
cash_balance: 20000,
12+
currency: "USD",
13+
accountable: Investment.new
14+
)
15+
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "CAD")
16+
@sync_cache = Balance::SyncCache.new(@account)
17+
end
18+
19+
test "convert currency when rate available by cache" do
20+
load_exchange_prices
21+
money = Money.new(100, "CAD")
22+
converted_money = nil
23+
assert_queries_count(3) do
24+
converted_money = @sync_cache.find_rate_by_cache(money, @account.currency, date: 1.day.ago.to_date)
25+
end
26+
expected_money = money.exchange_to(@account.currency, date: 1.day.ago.to_date)
27+
28+
assert_equal expected_money.amount, converted_money.amount
29+
assert_equal expected_money.currency, converted_money.currency
30+
end
31+
32+
test "convert currency after fetching rate" do
33+
ExchangeRate.expects(:fetch_rate).returns(Provider::ExchangeRateConcept::Rate.new(date: 1.day.ago.to_date, from: "JPY", to: "USD", rate: 0.007))
34+
money = Money.new(1000, "JPY")
35+
36+
assert_equal Money.new(7, "USD"), @sync_cache.find_rate_by_cache(money, @account.currency, date: 1.day.ago.to_date)
37+
end
38+
39+
test "converts currency with a fallback rate" do
40+
ExchangeRate.expects(:fetch_rate).returns(nil).twice
41+
money = Money.new(1000, "CAD")
42+
43+
assert_queries_count(3) do
44+
assert_equal Money.new(0, "USD"), @sync_cache.find_rate_by_cache(money, @account.currency, date: 1.day.ago.to_date, fallback_rate: 0)
45+
end
46+
47+
assert_equal Money.new(1000, "USD"), @sync_cache.find_rate_by_cache(money, @account.currency, date: 1.day.ago.to_date, fallback_rate: 1)
48+
end
49+
50+
test "raises when no conversion rate available and no fallback rate provided" do
51+
money = Money.new(1000, "JPY")
52+
ExchangeRate.expects(:fetch_rate).returns(nil)
53+
54+
assert_raises Money::ConversionError do
55+
@sync_cache.find_rate_by_cache(money, @account.currency, date: 1.day.ago.to_date, fallback_rate: nil)
56+
end
57+
end
58+
59+
test "raises if input is not Money-like" do
60+
assert_raises(TypeError) do
61+
@sync_cache.find_rate_by_cache(12345, "USD")
62+
end
63+
end
64+
65+
test "returns original money when source and target currency are the same" do
66+
money = Money.new(500, "USD")
67+
result = nil
68+
assert_queries_count(0) do
69+
result = @sync_cache.find_rate_by_cache(money, "USD", date: Date.today)
70+
end
71+
assert_same money, result
72+
end
73+
74+
test "query and cache exchange rates" do
75+
load_exchange_prices
76+
assert_queries_count(3) do
77+
@sync_cache.find_rate_by_cache(Money.new(100, "CAD"), @account.currency, date: 1.day.ago.to_date)
78+
end
79+
80+
assert_queries_count(0) do
81+
@sync_cache.find_rate_by_cache(Money.new(100, "CAD"), @account.currency, date: 1.day.ago.to_date)
82+
@sync_cache.find_rate_by_cache(Money.new(200, "CAD"), @account.currency, date: 1.day.ago.to_date)
83+
end
84+
end
85+
end

test/models/family/auto_transfer_matchable_test.rb

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
44
include EntriesTestHelper
5+
include ExchangeRateTestHelper
56

67
setup do
78
@family = families(:dylan_family)
@@ -116,33 +117,4 @@ class Family::AutoTransferMatchableTest < ActiveSupport::TestCase
116117
@family.auto_match_transfers!
117118
end
118119
end
119-
120-
private
121-
def load_exchange_prices
122-
rates = {
123-
4.days.ago.to_date => 1.36,
124-
3.days.ago.to_date => 1.37,
125-
2.days.ago.to_date => 1.38,
126-
1.day.ago.to_date => 1.39,
127-
Date.current => 1.40
128-
}
129-
130-
rates.each do |date, rate|
131-
# USD to CAD
132-
ExchangeRate.create!(
133-
from_currency: "USD",
134-
to_currency: "CAD",
135-
date: date,
136-
rate: rate
137-
)
138-
139-
# CAD to USD (inverse)
140-
ExchangeRate.create!(
141-
from_currency: "CAD",
142-
to_currency: "USD",
143-
date: date,
144-
rate: (1.0 / rate).round(6)
145-
)
146-
end
147-
end
148120
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
module ExchangeRateTestHelper
2+
def load_exchange_prices
3+
cad_rates = {
4+
4.days.ago.to_date => 1.36,
5+
3.days.ago.to_date => 1.37,
6+
2.days.ago.to_date => 1.38,
7+
1.day.ago.to_date => 1.39,
8+
Date.current => 1.40
9+
}
10+
11+
eur_rates = {
12+
4.days.ago.to_date => 1.17,
13+
3.days.ago.to_date => 1.18,
14+
2.days.ago.to_date => 1.19,
15+
1.day.ago.to_date => 1.2,
16+
Date.current => 1.21
17+
}
18+
19+
cad_rates.each do |date, rate|
20+
# USD to CAD
21+
ExchangeRate.create!(
22+
from_currency: "USD",
23+
to_currency: "CAD",
24+
date: date,
25+
rate: rate
26+
)
27+
28+
# CAD to USD (inverse)
29+
ExchangeRate.create!(
30+
from_currency: "CAD",
31+
to_currency: "USD",
32+
date: date,
33+
rate: (1.0 / rate).round(6)
34+
)
35+
end
36+
37+
eur_rates.each do |date, rate|
38+
# EUR to USD
39+
ExchangeRate.create!(
40+
from_currency: "EUR",
41+
to_currency: "USD",
42+
date: date,
43+
rate: rate
44+
)
45+
46+
# USD to EUR (inverse)
47+
ExchangeRate.create!(
48+
from_currency: "USD",
49+
to_currency: "EUR",
50+
date: date,
51+
rate: (1.0 / rate).round(6)
52+
)
53+
end
54+
end
55+
end

0 commit comments

Comments
 (0)