Merge branch 'main' into feat/goals-v2-architecture

This commit is contained in:
Guillem Arias Fauste
2026-05-27 09:48:35 +02:00
committed by GitHub
15 changed files with 342 additions and 33 deletions

View File

@@ -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: [