-
Notifications
You must be signed in to change notification settings - Fork 292
Expand file tree
/
Copy pathforward_calculator_test.rb
More file actions
236 lines (184 loc) · 11.7 KB
/
Copy pathforward_calculator_test.rb
File metadata and controls
236 lines (184 loc) · 11.7 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
require "test_helper"
class Holding::ForwardCalculatorTest < 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
calculated = Holding::ForwardCalculator.new(@account).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)
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 ] ]
calculated = Holding::ForwardCalculator.new(@account).calculate
assert_equal expected, calculated.map { |b| [ b.date.to_s, b.amount ] }
end
end
test "forward portfolio calculation" do
load_prices
# 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)
]
calculated = Holding::ForwardCalculator.new(@account).calculate
assert_equal expected.length, calculated.length
assert_holdings(expected, calculated)
end
# Carries the previous record forward if no holding exists for a date
# to ensure that net worth historical rollups have a value for every date
test "uses locf to fill missing holdings" do
load_prices
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
expected = [
Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)
]
# Price missing today, so we should carry forward the holding from 1 day ago
Security.stubs(:find).returns(@wmt)
Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))
Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100))
Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil)
calculated = Holding::ForwardCalculator.new(@account).calculate
assert_equal expected.length, calculated.length
assert_holdings(expected, calculated)
end
test "offline tickers sync holdings based on most recent trade price" do
offline_security = Security.create!(ticker: "OFFLINE", name: "Offline Ticker")
create_trade(offline_security, qty: 1, date: 3.days.ago.to_date, price: 90, account: @account)
create_trade(offline_security, qty: 1, date: 1.day.ago.to_date, price: 100, account: @account)
expected = [
Holding.new(security: offline_security, date: 3.days.ago.to_date, qty: 1, price: 90, amount: 90),
Holding.new(security: offline_security, date: 2.days.ago.to_date, qty: 1, price: 90, amount: 90),
Holding.new(security: offline_security, date: 1.day.ago.to_date, qty: 2, price: 100, amount: 200),
Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200)
]
calculated = Holding::ForwardCalculator.new(@account).calculate
assert_equal expected.length, calculated.length
assert_holdings(expected, calculated)
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")
# 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)
calculated = Holding::ForwardCalculator.new(@account).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 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")
# 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)
calculated = nil
assert_nothing_raised { calculated = Holding::ForwardCalculator.new(@account).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
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")
# 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)
calculated = Holding::ForwardCalculator.new(@account).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
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
end