mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Merge branch 'main' into feat/goals-v2-architecture
This commit is contained in:
@@ -213,6 +213,163 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase
|
||||
assert_not_nil @account.balances.find_by(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
end
|
||||
|
||||
test "computes net_market_flows from equity delta minus trade flows" do
|
||||
# Day 1: total=3000, cash=500, non_cash=2500
|
||||
# Day 2: total=3200, cash=500, non_cash=2700 (Δnon_cash=200)
|
||||
# Buy trade on Day 2: CHF 150 (same currency as account, no FX)
|
||||
# nmf = 200 - 150 = 50
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
{ report_date: "2026-05-07", total: "3000.00" },
|
||||
{ report_date: "2026-05-08", total: "3200.00" }
|
||||
]
|
||||
)
|
||||
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
|
||||
seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00)
|
||||
|
||||
security = Security.create!(ticker: "TEST", name: "Test Stock")
|
||||
@account.entries.create!(
|
||||
name: "Buy 100 TEST",
|
||||
date: Date.new(2026, 5, 8),
|
||||
amount: 150.00,
|
||||
currency: "CHF",
|
||||
entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "CHF")
|
||||
)
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
|
||||
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
|
||||
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
|
||||
assert_equal BigDecimal("0"), day1.net_market_flows
|
||||
assert_equal BigDecimal("50"), day2.net_market_flows
|
||||
|
||||
# Virtual column must still resolve to IBKR's equity total minus cash
|
||||
assert_equal BigDecimal("2500.00"), day1.end_non_cash_balance
|
||||
assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance
|
||||
end
|
||||
|
||||
test "applies fx_rate_to_base when trade currency differs from account currency" do
|
||||
# Trade in EUR with fx_rate_to_base=1.1 → CHF 165, not CHF 150
|
||||
# Day 1: non_cash=2500, Day 2: non_cash=2700 (Δ=200)
|
||||
# nmf = 200 - 165 = 35
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
{ report_date: "2026-05-07", total: "3000.00" },
|
||||
{ report_date: "2026-05-08", total: "3200.00" }
|
||||
]
|
||||
)
|
||||
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
|
||||
seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00)
|
||||
|
||||
security = Security.create!(ticker: "TEST2", name: "Test Stock EUR")
|
||||
trade = Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR")
|
||||
trade.exchange_rate = 1.1
|
||||
@account.entries.create!(
|
||||
name: "Buy 100 TEST2",
|
||||
date: Date.new(2026, 5, 8),
|
||||
amount: 150.00,
|
||||
currency: "EUR",
|
||||
entryable: trade
|
||||
)
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
|
||||
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
assert_in_delta 35.0, day2.net_market_flows.to_f, 0.01
|
||||
assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance
|
||||
end
|
||||
|
||||
test "excludes balance row from upsert when Money::ConversionError prevents FX conversion" do
|
||||
# EUR trade with no exchange_rate stored → custom_rate=nil → ConversionError raised.
|
||||
# The affected date is excluded from the upsert entirely so net_market_flows is not
|
||||
# silently wrong (the trade's value would otherwise flow into market appreciation).
|
||||
# The seeded day2 balance is intentionally different from IBKR's total (3150 vs 3200)
|
||||
# so we can assert the row was not overwritten by sync.
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
{ report_date: "2026-05-07", total: "3000.00" },
|
||||
{ report_date: "2026-05-08", total: "3200.00" }
|
||||
]
|
||||
)
|
||||
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
|
||||
seed_balance(date: Date.new(2026, 5, 8), balance: 3150.00, cash_balance: 500.00)
|
||||
|
||||
security = Security.create!(ticker: "NORATE", name: "No Rate EUR Stock")
|
||||
@account.entries.create!(
|
||||
name: "Buy 100 NORATE",
|
||||
date: Date.new(2026, 5, 8),
|
||||
amount: 150.00,
|
||||
currency: "EUR",
|
||||
entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR")
|
||||
)
|
||||
|
||||
Money.any_instance.stubs(:exchange_to).raises(
|
||||
Money::ConversionError.new(from_currency: "EUR", to_currency: "CHF", date: Date.new(2026, 5, 8))
|
||||
)
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
|
||||
# Day 1 is unaffected — still synced normally
|
||||
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
|
||||
assert_equal BigDecimal("3000.00"), day1.balance
|
||||
|
||||
# Day 2 was excluded from the upsert — seeded values are preserved, not overwritten
|
||||
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
assert_equal BigDecimal("3150.00"), day2.balance # seeded, not IBKR's 3200
|
||||
assert_equal BigDecimal("0"), day2.net_market_flows # seeded, not recomputed
|
||||
end
|
||||
|
||||
test "net_market_flows equals full non_cash delta when account has no trades" do
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
{ report_date: "2026-05-07", total: "3000.00" },
|
||||
{ report_date: "2026-05-08", total: "3300.00" }
|
||||
]
|
||||
)
|
||||
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
|
||||
seed_balance(date: Date.new(2026, 5, 8), balance: 3300.00, cash_balance: 500.00)
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
|
||||
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
|
||||
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
|
||||
assert_equal BigDecimal("0"), day1.net_market_flows
|
||||
assert_equal BigDecimal("300"), day2.net_market_flows
|
||||
assert_equal BigDecimal("2800.00"), day2.end_non_cash_balance
|
||||
end
|
||||
|
||||
test "sell trades reduce net_buy_sell so market loss is isolated in net_market_flows" do
|
||||
# Day 1: total=3000, cash=500, non_cash=2500
|
||||
# Day 2: total=2700, cash=700, non_cash=2000 (Δnon_cash=-500)
|
||||
# Sell 100 at CHF 1.50: entry.amount=-150 (negative = proceeds received)
|
||||
# net_buy_sell=-150; nmf = -500 - (-150) = -350 (market caused -350 loss)
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
{ report_date: "2026-05-07", total: "3000.00" },
|
||||
{ report_date: "2026-05-08", total: "2700.00" }
|
||||
]
|
||||
)
|
||||
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
|
||||
seed_balance(date: Date.new(2026, 5, 8), balance: 2700.00, cash_balance: 700.00)
|
||||
|
||||
security = Security.create!(ticker: "SELL_TEST", name: "Sell Test Stock")
|
||||
@account.entries.create!(
|
||||
name: "Sell 100 SELL_TEST",
|
||||
date: Date.new(2026, 5, 8),
|
||||
amount: -150.00,
|
||||
currency: "CHF",
|
||||
entryable: Trade.new(security: security, qty: -100, price: 1.5, currency: "CHF")
|
||||
)
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
|
||||
|
||||
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
|
||||
assert_equal BigDecimal("-350"), day2.net_market_flows
|
||||
assert_equal BigDecimal("2000.00"), day2.end_non_cash_balance
|
||||
end
|
||||
|
||||
test "writes balance row with zero total for fully liquidated dates" do
|
||||
@ibkr_account.update!(
|
||||
raw_equity_summary_payload: [
|
||||
|
||||
Reference in New Issue
Block a user