-
Notifications
You must be signed in to change notification settings - Fork 292
Expand file tree
/
Copy pathreverse_calculator_test.rb
More file actions
446 lines (351 loc) · 21.4 KB
/
Copy pathreverse_calculator_test.rb
File metadata and controls
446 lines (351 loc) · 21.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
require "test_helper"
class Holding::ReverseCalculatorTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(
name: "Test",
balance: 20000,
cash_balance: 20000,
currency: "USD",
accountable: Investment.new
)
end
test "no holdings" do
empty_snapshot = OpenStruct.new(to_h: {})
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: empty_snapshot).calculate
assert_equal [], calculated
end
test "holding generation respects user timezone and last generated date is current user date" do
# Simulate user in EST timezone
Time.use_zone("America/New_York") do
# Set current time to 1am UTC on Jan 5, 2025
# This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate holdings for)
travel_to Time.utc(2025, 01, 05, 1, 0, 0)
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
Security::Price.create!(security: voo, date: "2025-01-02", price: 500)
Security::Price.create!(security: voo, date: "2025-01-03", price: 500)
Security::Price.create!(security: voo, date: "2025-01-04", price: 500)
# Today's holdings (provided)
@account.holdings.create!(security: voo, date: "2025-01-04", qty: 10, price: 500, amount: 5000, currency: "USD")
create_trade(voo, qty: 10, date: "2025-01-03", price: 500, account: @account)
expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 5000 ], [ "2025-01-04", 5000 ] ]
# Mock snapshot with the holdings we created
snapshot = OpenStruct.new(to_h: { voo.id => 10 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.amount ] }
end
end
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
test "reverse portfolio with trades but without current day holdings" do
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
Security::Price.create!(security: voo, date: Date.current, price: 470)
Security::Price.create!(security: voo, date: 1.day.ago.to_date, price: 470)
create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)
# Mock empty portfolio since no current day holdings
snapshot = OpenStruct.new(to_h: { voo.id => 0 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
assert_equal 2, calculated.length
end
test "reverse portfolio calculation" do
load_today_portfolio
# Build up to 10 shares of VOO (current value $5000)
create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)
create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)
create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)
# Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio
create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)
create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)
# Build up to 100 shares of WMT (current value $10000)
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
expected = [
# 4 days ago
Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
# 3 days ago
Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
# 2 days ago
Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
# 1 day ago
Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
# Today
Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
]
# Mock snapshot with today's portfolio from load_today_portfolio
snapshot = OpenStruct.new(to_h: { @voo.id => 10, @wmt.id => 100, @amzn.id => 0 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
assert_equal expected.length, calculated.length
expected.each do |expected_entry|
calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
end
end
# For a reverse sync, Plaid will provide today's holdings + prices. We need to match those exactly so balances match in net worth rollups.
test "current day holdings always match provided holdings and prices" do
# Provider gives us total value of $10,000 ($5,000 cash, $5,000 in holdings)
@account.update!(balance: 10000, cash_balance: 5000)
wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.")
create_trade(wmt, qty: 50, date: 1.day.ago.to_date, price: 98, account: @account)
@account.holdings.create!(
date: Date.current,
price: 100,
qty: 50,
amount: 5000,
currency: "USD",
security: wmt
)
Security::Price.create!(security: wmt, date: Date.current, price: 102) # This price should be ignored on current day
Security::Price.create!(security: wmt, date: 1.day.ago, price: 98) # This price will be used for historical holding calculation
Security::Price.create!(security: wmt, date: 2.days.ago, price: 95) # This price will be used for historical holding calculation
expected = [
Holding.new(security: wmt, date: 2.days.ago.to_date, qty: 0, price: 95, amount: 0), # Uses market price, empty holding
Holding.new(security: wmt, date: 1.day.ago.to_date, qty: 50, price: 98, amount: 4900), # Uses market price
Holding.new(security: wmt, date: Date.current, qty: 50, price: 100, amount: 5000) # Uses holding price, not market price
]
# Mock snapshot with WMT holding from the test setup
snapshot = OpenStruct.new(to_h: { wmt.id => 50 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
assert_equal expected.length, calculated.length
expected.each do |expected_entry|
calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
end
end
# cost_basis_for
test "cost_basis_for returns nil when there are no buy trades" do
security = Security.create!(ticker: "TST", name: "Test")
calc = calculator_with_trades(security)
assert_nil cost_basis_for(calc, security, Date.current)
end
test "cost_basis_for returns nil for dates before the first buy" do
security = Security.create!(ticker: "TST", name: "Test")
buy_date = 5.days.ago.to_date
calc = calculator_with_trades(security) do
create_trade(security, account: @account, qty: 10, price: 100, date: buy_date)
end
assert_nil cost_basis_for(calc, security, buy_date - 1)
end
test "cost_basis_for returns weighted average cost on buy date" do
security = Security.create!(ticker: "TST", name: "Test")
buy_date = 5.days.ago.to_date
calc = calculator_with_trades(security) do
create_trade(security, account: @account, qty: 10, price: 100, date: buy_date)
end
assert_in_delta 100.0, cost_basis_for(calc, security, buy_date).to_f, 1e-6
end
test "cost_basis_for carries forward to dates between buys" do
security = Security.create!(ticker: "TST", name: "Test")
first_buy = 10.days.ago.to_date
second_buy = 3.days.ago.to_date
calc = calculator_with_trades(security) do
create_trade(security, account: @account, qty: 10, price: 100, date: first_buy)
create_trade(security, account: @account, qty: 5, price: 130, date: second_buy)
end
# Between the two buys, cost basis is from the first buy only
assert_in_delta 100.0, cost_basis_for(calc, security, first_buy + 1).to_f, 1e-6
assert_in_delta 100.0, cost_basis_for(calc, security, second_buy - 1).to_f, 1e-6
# After second buy: WAC = (10*100 + 5*130) / 15 = 110.0
assert_in_delta 110.0, cost_basis_for(calc, security, second_buy).to_f, 1e-6
assert_in_delta 110.0, cost_basis_for(calc, security, Date.current).to_f, 1e-6
end
test "cost_basis_for accumulates multiple buys on the same date" do
security = Security.create!(ticker: "TST", name: "Test")
buy_date = 5.days.ago.to_date
calc = calculator_with_trades(security) do
create_trade(security, account: @account, qty: 10, price: 100, date: buy_date)
create_trade(security, account: @account, qty: 5, price: 130, date: buy_date)
end
# WAC = (10*100 + 5*130) / 15 = 110.0 — not the intermediate value after only the first trade
assert_in_delta 110.0, cost_basis_for(calc, security, buy_date).to_f, 1e-6
end
test "cost_basis_for ignores sell trades" do
security = Security.create!(ticker: "TST", name: "Test")
buy_date = 10.days.ago.to_date
sell_date = 5.days.ago.to_date
calc = calculator_with_trades(security) do
create_trade(security, account: @account, qty: 10, price: 100, date: buy_date)
create_trade(security, account: @account, qty: -5, price: 120, date: sell_date)
end
# Sell does not change cost basis
assert_in_delta 100.0, cost_basis_for(calc, security, sell_date).to_f, 1e-6
assert_in_delta 100.0, cost_basis_for(calc, security, Date.current).to_f, 1e-6
end
# Cost basis FX: trade-date rate must be used, not today's rate
test "cost_basis uses trade-date exchange rate for foreign-currency buy" do
travel_to Date.new(2025, 6, 1) do
eur_stock = Security.create!(ticker: "EURST", name: "EUR Stock")
buy_date = Date.new(2025, 5, 27)
Security::Price.create!(security: eur_stock, date: buy_date, price: 100, currency: "EUR")
Security::Price.create!(security: eur_stock, date: Date.current, price: 100, currency: "EUR")
@account.holdings.create!(security: eur_stock, date: Date.current,
qty: 10, price: 100, amount: 1000, currency: "EUR")
# EUR/USD was 1.10 on trade date; today it is 1.50 — cost basis must use 1.10
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: buy_date, rate: 1.10)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.50)
create_trade(eur_stock, qty: 10, date: buy_date, price: 100, currency: "EUR", account: @account)
snapshot = OpenStruct.new(to_h: { eur_stock.id => 10 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
today_holding = calculated.find { |h| h.date == Date.current && h.security == eur_stock }
# cost_basis per share = 100 EUR × 1.10 (trade-date rate) = 110 USD
assert_in_delta 110.0, today_holding.cost_basis.to_f, 0.01,
"cost_basis must use the trade-date FX rate (1.10), not today's rate (1.50)"
end
end
test "cost_basis uses provider-supplied exchange_rate when present" do
travel_to Date.new(2025, 6, 1) do
eur_stock = Security.create!(ticker: "EURST2", name: "EUR Stock 2")
buy_date = Date.new(2025, 5, 27)
Security::Price.create!(security: eur_stock, date: buy_date, price: 100, currency: "EUR")
Security::Price.create!(security: eur_stock, date: Date.current, price: 100, currency: "EUR")
@account.holdings.create!(security: eur_stock, date: Date.current,
qty: 10, price: 100, amount: 1000, currency: "EUR")
# DB has a different rate — the provider-supplied rate (1.20) must win
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: buy_date, rate: 1.10)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.50)
create_trade(eur_stock, qty: 10, date: buy_date, price: 100, currency: "EUR",
exchange_rate: 1.20, account: @account)
snapshot = OpenStruct.new(to_h: { eur_stock.id => 10 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
today_holding = calculated.find { |h| h.date == Date.current && h.security == eur_stock }
# cost_basis per share = 100 EUR × 1.20 (provider rate) = 120 USD
assert_in_delta 120.0, today_holding.cost_basis.to_f, 0.01,
"cost_basis must prefer the provider-supplied exchange_rate over the DB lookup"
end
end
test "cost_basis is nil for all dates after a failed FX conversion even when a later buy succeeds" do
travel_to Date.new(2025, 6, 1) do
eur_stock = Security.create!(ticker: "EURST4", name: "EUR Stock 4")
first_buy = Date.new(2025, 5, 20)
failed_buy = Date.new(2025, 5, 27)
later_buy = Date.new(2025, 5, 30)
Security::Price.create!(security: eur_stock, date: first_buy, price: 100, currency: "EUR")
Security::Price.create!(security: eur_stock, date: failed_buy, price: 100, currency: "EUR")
Security::Price.create!(security: eur_stock, date: later_buy, price: 100, currency: "EUR")
Security::Price.create!(security: eur_stock, date: Date.current, price: 100, currency: "EUR")
@account.holdings.create!(security: eur_stock, date: Date.current,
qty: 30, price: 100, amount: 3000, currency: "EUR")
# first_buy and later_buy have rates; failed_buy does not — it will raise ConversionError
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: first_buy, rate: 1.10)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: later_buy, rate: 1.20)
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.50)
create_trade(eur_stock, qty: 10, date: first_buy, price: 100, currency: "EUR", account: @account)
create_trade(eur_stock, qty: 10, date: failed_buy, price: 100, currency: "EUR", account: @account)
create_trade(eur_stock, qty: 10, date: later_buy, price: 100, currency: "EUR", account: @account)
snapshot = OpenStruct.new(to_h: { eur_stock.id => 30 })
calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate
first_buy_holding = calculated.find { |h| h.date == first_buy && h.security == eur_stock }
assert_in_delta 110.0, first_buy_holding.cost_basis.to_f, 0.01,
"cost_basis should be valid (110 USD = 100 EUR × 1.10) before the FX failure on #{failed_buy}"
failed_date_holding = calculated.find { |h| h.date == failed_buy && h.security == eur_stock }
assert_nil failed_date_holding.cost_basis,
"cost_basis should be nil starting from the FX failure date"
later_date_holding = calculated.find { |h| h.date == later_buy && h.security == eur_stock }
assert_nil later_date_holding.cost_basis,
"cost_basis should remain nil even for dates with successful conversions after the failure"
today_holding = calculated.find { |h| h.date == Date.current && h.security == eur_stock }
assert_equal 30, today_holding.qty, "qty should still be calculated despite nil cost_basis"
assert_nil today_holding.cost_basis,
"cost_basis must be nil after FX failure even when a later buy converts successfully"
end
end
test "cost_basis is nil and trade is skipped when FX conversion fails" do
travel_to Date.new(2025, 6, 1) do
eur_stock = Security.create!(ticker: "EURST3", name: "EUR Stock 3")
buy_date = Date.new(2025, 5, 27)
Security::Price.create!(security: eur_stock, date: buy_date, price: 100, currency: "EUR")
Security::Price.create!(security: eur_stock, date: Date.current, price: 100, currency: "EUR")
@account.holdings.create!(security: eur_stock, date: Date.current,
qty: 10, price: 100, amount: 1000, currency: "EUR")
# No exchange rate for buy_date and no custom_rate — conversion will fail
ExchangeRate.create!(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.50)
create_trade(eur_stock, qty: 10, date: buy_date, price: 100, currency: "EUR", account: @account)
snapshot = OpenStruct.new(to_h: { eur_stock.id => 10 })
calculated = nil
assert_nothing_raised { calculated = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot).calculate }
today_holding = calculated.find { |h| h.date == Date.current && h.security == eur_stock }
assert_nil today_holding.cost_basis,
"cost_basis must be nil when FX conversion fails (trade excluded to avoid mixing currencies)"
end
end
private
def assert_holdings(expected, calculated)
expected.each do |expected_entry|
calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
end
end
def load_prices
@voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460)
Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470)
Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480)
Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490)
Security::Price.create!(security: @voo, date: Date.current, price: 500)
@wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.")
Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: Date.current, price: 100)
@amzn = Security.create!(ticker: "AMZN", name: "Amazon.com Inc.")
Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: Date.current, price: 200)
end
# Portfolio holdings:
# +--------+-----+--------+---------+
# | Ticker | Qty | Price | Amount |
# +--------+-----+--------+---------+
# | VOO | 10 | $500 | $5,000 |
# | WMT | 100 | $100 | $10,000 |
# +--------+-----+--------+---------+
# Brokerage Cash: $5,000
# Holdings Value: $15,000
# Total Balance: $20,000
def calculator_with_trades(security)
yield if block_given?
snapshot = OpenStruct.new(to_h: { security.id => 10 })
calc = Holding::ReverseCalculator.new(@account, portfolio_snapshot: snapshot)
calc.send(:precompute_cost_basis)
calc
end
def cost_basis_for(calc, security, date)
calc.send(:cost_basis_for, security.id, date)
end
def load_today_portfolio
@account.update!(cash_balance: 5000)
load_prices
@account.holdings.create!(
date: Date.current,
price: 500,
qty: 10,
amount: 5000,
currency: "USD",
security: @voo
)
@account.holdings.create!(
date: Date.current,
price: 100,
qty: 100,
amount: 10000,
currency: "USD",
security: @wmt
)
end
end