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

This commit is contained in:
Guillem Arias Fauste
2026-05-18 21:54:47 +02:00
committed by GitHub
10 changed files with 368 additions and 118 deletions

View File

@@ -1 +1 @@
0.7.1-alpha.9
0.7.1-alpha.10

View File

@@ -13,15 +13,14 @@ class IbkrAccount::ActivitiesProcessor
activities = (@ibkr_account.raw_activities_payload || {}).with_indifferent_access
trades = Array(activities[:trades])
cash_transactions = Array(activities[:cash_transactions])
@fee_transactions_count = 0
trades_count = trades.sum { |trade| process_trade(trade.with_indifferent_access) ? 1 : 0 }
cash_transactions_count = cash_transactions.sum { |cash_transaction| process_cash_transaction(cash_transaction.with_indifferent_access) ? 1 : 0 }
trade_results = trades.map { |trade| process_trade(trade.with_indifferent_access) }
trades_count = trade_results.count { |r| r[:imported] }
fee_count = trade_results.sum { |r| r[:fees] }
{
trades: trades_count,
transactions: cash_transactions_count + @fee_transactions_count
}
cash_count = cash_transactions.sum { |t| process_cash_transaction(t.with_indifferent_access) ? 1 : 0 }
{ trades: trades_count, transactions: cash_count + fee_count }
end
private
@@ -35,14 +34,14 @@ class IbkrAccount::ActivitiesProcessor
end
def process_trade(row)
return false unless supported_trade?(row)
return { imported: false, fees: 0 } unless supported_trade?(row)
security = resolve_security(row)
return false unless security
return { imported: false, fees: 0 } unless security
quantity = parse_decimal(row[:quantity])
native_price = parse_decimal(row[:trade_price])
return false if quantity.nil? || native_price.nil?
return { imported: false, fees: 0 } if quantity.nil? || native_price.nil?
buy_sell = row[:buy_sell].to_s.upcase
signed_quantity = buy_sell == "SELL" ? -quantity.abs : quantity.abs
@@ -65,11 +64,11 @@ class IbkrAccount::ActivitiesProcessor
exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f
)
import_commission_transaction(row, security, date)
true
fees = import_commission_transaction(row, security, date) ? 1 : 0
{ imported: true, fees: fees }
rescue => e
Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process trade #{row[:trade_id]}: #{e.message}")
false
{ imported: false, fees: 0 }
end
def process_cash_transaction(row)
@@ -114,9 +113,10 @@ class IbkrAccount::ActivitiesProcessor
def import_commission_transaction(row, security, date)
commission = parse_decimal(row[:ib_commission])
return if commission.nil? || commission.zero?
currency = row.with_indifferent_access[:ib_commission_currency].to_s.upcase.presence || @ibkr_account.currency
ticker = security&.ticker || row.with_indifferent_access[:symbol]
return false if commission.nil? || commission.zero?
currency = row[:ib_commission_currency].to_s.upcase.presence || @ibkr_account.currency
ticker = security&.ticker || row[:symbol]
result = import_adapter.import_transaction(
external_id: "ibkr_trade_fee_#{row[:trade_id]}",
@@ -139,7 +139,7 @@ class IbkrAccount::ActivitiesProcessor
}
)
@fee_transactions_count += 1 if result
!!result
end
def build_trade_name(ticker, signed_quantity)
@@ -170,6 +170,7 @@ class IbkrAccount::ActivitiesProcessor
type != "DIVIDENDS" || row[:conid].present?
end
# supported_cash_transaction? ensures only known types reach here; no else branch needed
def classify_cash_transaction(row, amount)
type = row[:type].to_s.upcase.strip
@@ -178,8 +179,6 @@ class IbkrAccount::ActivitiesProcessor
amount.positive? ? [ "Contribution", -amount.abs ] : [ "Withdrawal", amount.abs ]
when "DIVIDENDS"
[ "Dividend", -amount.abs ]
else
[ nil, nil ]
end
end
@@ -205,12 +204,12 @@ class IbkrAccount::ActivitiesProcessor
end&.with_indifferent_access&.dig(:symbol)
return holding_symbol if holding_symbol.present?
Array(@ibkr_account.raw_activities_payload&.dig("trades") || @ibkr_account.raw_activities_payload&.dig(:trades)).find do |trade|
activities = (@ibkr_account.raw_activities_payload || {}).with_indifferent_access
Array(activities[:trades]).find do |trade|
trade.with_indifferent_access[:conid].to_s == conid.to_s
end&.with_indifferent_access&.dig(:symbol)
end
def fx_rate_available?(row)
source_currency = extract_currency(row, fallback: nil)
return false if source_currency.blank?

View File

@@ -9,6 +9,9 @@ module IbkrAccount::DataHelpers
normalized = value.is_a?(String) ? value.delete(",").strip : value.to_s
return nil if normalized.blank? || normalized == "-"
# Convert accounting parentheses notation: "(1234.56)" → "-1234.56"
normalized = "-#{normalized[1..-2]}" if normalized.start_with?("(") && normalized.end_with?(")")
BigDecimal(normalized)
rescue ArgumentError
nil

View File

@@ -18,33 +18,93 @@ class IbkrAccount::HistoricalBalancesSync
end
private
def account
ibkr_account.current_account
end
def account_currency
ibkr_account.currency.to_s.upcase
end
def normalized_rows
@normalized_rows ||= Array(ibkr_account.raw_equity_summary_payload)
.filter_map do |row|
next unless row.is_a?(Hash)
@normalized_rows ||= begin
# Batch-load the materializer's already-computed balances so we can
# preserve its cash split rather than reading cash from the equity summary.
# Real IBKR Flex exports do not reliably include a cash/stock breakdown in
# EquitySummaryByReportDateInBase — only the total is consistently present.
existing_balances = account.balances
.where(currency: account.currency)
.index_by(&:date)
data = row.with_indifferent_access
currency = data[:currency].presence&.upcase
account_currency = ibkr_account.currency.to_s.upcase
next if currency.present? && currency != account_currency
trading_day_rows = Array(ibkr_account.raw_equity_summary_payload)
.filter_map do |row|
next unless row.is_a?(Hash)
date = parse_date(data[:report_date])
total = parse_decimal(data[:total])
cash = parse_decimal(data[:cash]) || BigDecimal("0")
next unless date && total
data = row.with_indifferent_access
currency = data[:currency].presence&.upcase
{
date: date,
total: total,
cash: cash,
non_cash: total - cash
}
# BASE_SUMMARY rows aggregate across all currencies — not a per-date balance
next if currency == "BASE_SUMMARY"
# Reject rows with an explicit wrong currency; absent currency is accepted
# (some Flex configurations omit it and the row is implicitly in base currency)
next if currency.present? && currency != account_currency
date = parse_date(data[:report_date])
next unless date
total = parse_decimal(data[:total])
if total.nil?
Rails.logger.warn(
"IbkrAccount::HistoricalBalancesSync - Skipping equity summary row with missing or unparseable total " \
"for date=#{data[:report_date].inspect} account=#{account.id}"
)
next
end
# Use the materializer's cash_balance as ground truth for the cash split.
# This is consistent with how the reverse calculator handles present-day
# weekends and holidays — derive cash from holdings, not from IBKR's field.
cash = existing_balances[date]&.cash_balance || BigDecimal("0")
{ date: date, total: total, cash: cash, non_cash: total - cash }
end
.sort_by { |r| r[:date] }
fill_gaps(trading_day_rows, existing_balances)
end
end
# IBKR does not emit rows for weekends and some holidays. The reverse
# calculator fills those dates using only imported holdings — which only
# cover the current snapshot — so it cannot reconstruct the correct
# non-cash value for historical gap dates. We carry the most recent
# IBKR total forward to every missing calendar day and pair it with the
# materializer's already-correct cash for that date.
#
# The range is extended to the account's current anchor date so that days
# after the last equity summary row (e.g. a Saturday sync where the payload
# ends on Friday) are also covered and not left with the materializer's
# stale total=cash value.
def fill_gaps(rows, existing_balances)
return [] if rows.empty?
by_date = rows.index_by { |r| r[:date] }
first_date = rows.first[:date]
anchor_date = [ account.current_anchor_date || Date.current, Date.current ].min
last_date = [ rows.last[:date], anchor_date ].max
last_total = nil
(first_date..last_date).filter_map do |date|
if by_date[date]
last_total = by_date[date][:total]
by_date[date]
else
next unless last_total
cash = existing_balances[date]&.cash_balance || BigDecimal("0")
{ date: date, total: last_total, cash: cash, non_cash: last_total - cash }
end
.sort_by { |row| row[:date] }
end
end
def balance_rows
@@ -52,7 +112,7 @@ class IbkrAccount::HistoricalBalancesSync
normalized_rows.each_with_index.map do |row, index|
previous_row = index.zero? ? nil : normalized_rows[index - 1]
start_cash_balance = previous_row ? previous_row[:cash] : row[:cash]
start_cash_balance = previous_row ? previous_row[:cash] : row[:cash]
start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash]
{

View File

@@ -8,8 +8,8 @@ class IbkrAccount::HoldingsProcessor
def process
return unless account.present?
grouped_positions.each_value do |group|
process_group(group)
grouped_positions.each do |(_, _, report_date), group|
process_group(group, report_date)
end
end
@@ -28,32 +28,32 @@ class IbkrAccount::HoldingsProcessor
data = position.with_indifferent_access
next unless supported_position?(data)
symbol_key = data[:conid].presence || data[:symbol].presence || data[:security_id].presence
# conid is guaranteed present by supported_position?, so no fallbacks needed
currency = extract_currency(data, fallback: @ibkr_account.currency)
report_date = parse_date(data[:report_date]) || @ibkr_account.report_date || Date.current
key = [ symbol_key, currency, report_date ]
key = [ data[:conid], currency, report_date ]
groups[key] ||= []
groups[key] << data
end
end
def process_group(rows)
def process_group(rows, report_date)
sample = rows.first
security = resolve_security(sample)
return unless security
quantity = rows.sum { |row| parse_decimal(row[:position]) || BigDecimal("0") }
return if quantity.zero?
price = parse_decimal(sample[:mark_price])
cost_basis = weighted_cost_basis_for(rows)
return unless price && cost_basis
amount = quantity.abs * price
# quantity and cost_basis are derived from the same set of valid lots so
# they are always consistent — a lot with an unparseable cost_basis_price
# is excluded from both counts rather than inflating qty while shrinking basis.
aggregate = valid_lots(rows)
return unless price && aggregate
quantity = aggregate[:quantity]
cost_basis = aggregate[:cost_basis]
amount = quantity * price
currency = extract_currency(sample, fallback: @ibkr_account.currency)
report_date = parse_date(sample[:report_date]) || @ibkr_account.report_date || Date.current
external_id = [ "ibkr", @ibkr_account.ibkr_account_id, sample[:conid].presence || security.ticker, report_date, currency ].join("_")
external_id = [ "ibkr", @ibkr_account.ibkr_account_id, sample[:conid], report_date, currency ].join("_")
import_adapter.import_holding(
security: security,
@@ -61,7 +61,7 @@ class IbkrAccount::HoldingsProcessor
amount: amount,
currency: currency,
date: report_date,
price: price || BigDecimal("0"),
price: price,
cost_basis: cost_basis,
external_id: external_id,
source: "ibkr",
@@ -70,22 +70,33 @@ class IbkrAccount::HoldingsProcessor
)
end
def weighted_cost_basis_for(rows)
# Aggregates only the lots that have both a parseable position and cost_basis_price.
# Returns { quantity:, cost_basis: } so the caller uses a consistent lot set for
# both values — a lot skipped here is excluded from quantity too, preventing the
# case where qty covers more shares than the cost basis was computed from.
def valid_lots(rows)
total_quantity = BigDecimal("0")
total_cost = BigDecimal("0")
rows.each do |row|
row_quantity = parse_decimal(row[:position])
row_quantity = parse_decimal(row[:position])
row_cost_basis = parse_decimal(row[:cost_basis_price])
return nil unless row_quantity && row_cost_basis
unless row_quantity && row_cost_basis
Rails.logger.warn(
"IbkrAccount::HoldingsProcessor - Skipping lot with missing position or cost_basis_price " \
"for conid=#{row[:conid].inspect}"
)
next
end
total_quantity += row_quantity.abs
total_cost += row_quantity.abs * row_cost_basis
total_cost += row_quantity.abs * row_cost_basis
end
return nil if total_quantity.zero?
total_cost / total_quantity
{ quantity: total_quantity, cost_basis: total_cost / total_quantity }
end
def supported_position?(row)

View File

@@ -6,21 +6,23 @@ class IbkrAccount::Processor
end
def process
return unless ibkr_account.current_account.present?
return unless account.present?
update_account_balance!
IbkrAccount::HoldingsProcessor.new(ibkr_account).process
IbkrAccount::ActivitiesProcessor.new(ibkr_account).process
repair_default_opening_anchor!
ibkr_account.current_account.broadcast_sync_complete
account.broadcast_sync_complete
end
private
def update_account_balance!
account = ibkr_account.current_account
def account
@account ||= ibkr_account.current_account
end
def update_account_balance!
total_balance = ibkr_account.current_balance || ibkr_account.cash_balance || 0
cash_balance = ibkr_account.cash_balance || 0
@@ -34,7 +36,6 @@ class IbkrAccount::Processor
end
def repair_default_opening_anchor!
account = ibkr_account.current_account
return unless account&.linked_to?("IbkrAccount")
return unless account.has_opening_anchor?
@@ -51,6 +52,12 @@ class IbkrAccount::Processor
date: opening_anchor_entry.date
)
raise result.error if result.error
# Don't raise — broadcast_sync_complete must still run after a repair failure.
if result.error
Rails.logger.error(
"IbkrAccount::Processor - Failed to repair opening anchor for account #{account.id}: #{result.error}"
)
Sentry.capture_message(result.error)
end
end
end

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: sure
description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis.
type: application
version: 0.7.1-alpha.9
appVersion: "0.7.1-alpha.9"
version: 0.7.1-alpha.10
appVersion: "0.7.1-alpha.10"
kubeVersion: ">=1.25.0-0"

View File

@@ -0,0 +1,57 @@
# frozen_string_literal: true
require "test_helper"
class IbkrAccount::DataHelpersTest < ActiveSupport::TestCase
class TestHelper
include IbkrAccount::DataHelpers
public :parse_decimal
end
setup do
@helper = TestHelper.new
end
test "parse_decimal returns nil for nil input" do
assert_nil @helper.parse_decimal(nil)
end
test "parse_decimal returns nil for blank string" do
assert_nil @helper.parse_decimal("")
assert_nil @helper.parse_decimal(" ")
end
test "parse_decimal returns nil for dash placeholder" do
assert_nil @helper.parse_decimal("-")
end
test "parse_decimal converts parentheses notation to negative" do
assert_equal BigDecimal("-1234.56"), @helper.parse_decimal("(1234.56)")
end
test "parse_decimal converts parentheses notation with comma-separated number" do
assert_equal BigDecimal("-1234.56"), @helper.parse_decimal("(1,234.56)")
end
test "parse_decimal strips commas from positive numbers" do
assert_equal BigDecimal("1234.56"), @helper.parse_decimal("1,234.56")
end
test "parse_decimal parses plain decimal string" do
assert_equal BigDecimal("3351.00"), @helper.parse_decimal("3351.00")
end
test "parse_decimal returns nil for empty parentheses" do
assert_nil @helper.parse_decimal("()")
end
test "parse_decimal returns nil for unclosed parenthesis" do
assert_nil @helper.parse_decimal("(123")
end
test "parse_decimal returns nil for non-numeric string" do
assert_nil @helper.parse_decimal("N/A")
assert_nil @helper.parse_decimal("not_a_number")
end
end

View File

@@ -21,33 +21,23 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase
current_balance: 3351,
cash_balance: 1000.5,
raw_equity_summary_payload: [
{
currency: "CHF",
report_date: "2026-05-07",
cash: "900.50",
stock: "2300.50",
total: "3201.00"
},
{
currency: "CHF",
report_date: "2026-05-08",
cash: "1000.50",
stock: "2350.50",
total: "3351.00"
}
{ report_date: "2026-05-07", total: "3201.00" },
{ report_date: "2026-05-08", total: "3351.00" }
]
)
@ibkr_account.ensure_account_provider!(@account)
end
test "upserts historical balances without creating activity entries" do
# Seed an existing balance row as if the materializer already ran.
def seed_balance(date:, balance:, cash_balance:)
non_cash = balance - cash_balance
@account.balances.create!(
date: Date.new(2026, 5, 7),
balance: 0,
cash_balance: 0,
date: date,
balance: balance,
cash_balance: cash_balance,
currency: "CHF",
start_cash_balance: 0,
start_non_cash_balance: 0,
start_cash_balance: cash_balance,
start_non_cash_balance: non_cash,
cash_inflows: 0,
cash_outflows: 0,
non_cash_inflows: 0,
@@ -57,60 +47,183 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase
non_cash_adjustments: 0,
flows_factor: 1
)
end
test "overrides total from IBKR equity summary while preserving materializer cash split" do
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 900.50)
seed_balance(date: Date.new(2026, 5, 8), balance: 3100.00, cash_balance: 1000.50)
assert_no_difference "@account.entries.count" do
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
end
first_balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
second_balance = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
first = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
second = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("3201.0"), first_balance.end_balance
assert_equal BigDecimal("900.5"), first_balance.end_cash_balance
assert_equal BigDecimal("2300.5"), first_balance.end_non_cash_balance
# Total overridden with IBKR's reported figure
assert_equal BigDecimal("3201.00"), first.end_balance
assert_equal BigDecimal("3351.00"), second.end_balance
assert_equal BigDecimal("3351.0"), second_balance.end_balance
assert_equal BigDecimal("1000.5"), second_balance.end_cash_balance
assert_equal BigDecimal("2350.5"), second_balance.end_non_cash_balance
assert_equal BigDecimal("900.5"), second_balance.start_cash_balance
assert_equal BigDecimal("2300.5"), second_balance.start_non_cash_balance
# Cash preserved from the materializer, not read from equity summary
assert_equal BigDecimal("900.50"), first.end_cash_balance
assert_equal BigDecimal("1000.50"), second.end_cash_balance
# Non-cash = IBKR total - materializer cash
assert_equal BigDecimal("2300.50"), first.end_non_cash_balance
assert_equal BigDecimal("2350.50"), second.end_non_cash_balance
end
test "accepts equity summary rows when stored account currency casing differs" do
@ibkr_account.update!(currency: "chf")
test "uses zero cash when no prior materializer balance exists for a date" do
# No existing balance rows — first-ever sync
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3201.00"), balance.end_balance
assert_equal BigDecimal("0"), balance.end_cash_balance
assert_equal BigDecimal("3201.00"), balance.end_non_cash_balance
end
test "accepts rows without a currency field (Flex configs that omit the attribute)" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3201.00" } # no currency key
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 900.50)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
first_balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
second_balance = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("3201.0"), first_balance.end_balance
assert_equal BigDecimal("3351.0"), second_balance.end_balance
balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3201.00"), balance.end_balance
assert_equal BigDecimal("900.50"), balance.end_cash_balance
end
test "skips malformed equity summary rows and still imports valid rows" do
test "accepts rows when account currency casing differs from payload" do
@ibkr_account.update!(currency: "chf")
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 900.50)
@ibkr_account.update!(
raw_equity_summary_payload: [
{ currency: "CHF", report_date: "2026-05-07", total: "3201.00" }
]
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3201.00"), balance.end_balance
end
test "skips BASE_SUMMARY rows" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ currency: "BASE_SUMMARY", report_date: "2026-05-07", total: "9999.00" },
{ currency: "CHF", report_date: "2026-05-07", total: "3201.00" }
]
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3201.00"), balance.end_balance
end
test "skips rows with a mismatched explicit currency" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ currency: "USD", report_date: "2026-05-07", total: "9999.00" },
{ currency: "CHF", report_date: "2026-05-07", total: "3201.00" }
]
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3201.00"), balance.end_balance
end
test "skips malformed rows and still imports valid ones" do
@ibkr_account.update!(
raw_equity_summary_payload: [
nil,
"bad-row",
[],
{
currency: "CHF",
report_date: "2026-05-09",
cash: "1100.50",
total: "3400.00"
}
{ report_date: "2026-05-11", total: "3400.00" }
]
)
seed_balance(date: Date.new(2026, 5, 11), balance: 3300.00, cash_balance: 1100.50)
assert_nothing_raised do
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
end
balance = @account.balances.find_by!(date: Date.new(2026, 5, 9), currency: "CHF")
balance = @account.balances.find_by!(date: Date.new(2026, 5, 11), currency: "CHF")
assert_equal BigDecimal("3400.00"), balance.end_balance
assert_equal BigDecimal("1100.50"), balance.end_cash_balance
end
assert_equal BigDecimal("3400.0"), balance.end_balance
assert_equal BigDecimal("1100.5"), balance.end_cash_balance
assert_equal BigDecimal("2299.5"), balance.end_non_cash_balance
test "fills weekend and holiday gaps by carrying forward the last IBKR total with materializer cash" do
# Simulate the real-world situation: IBKR has no weekend rows, and historical
# holdings only cover the current snapshot so the materializer writes total=cash
# for gap dates. HistoricalBalancesSync must write the correct total for those days.
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-08", total: "3351.00" }, # Friday
# Saturday May 9 and Sunday May 10 absent — IBKR never sends them
{ report_date: "2026-05-11", total: "3400.00" } # Monday
]
)
# Materializer computed correct cash for all dates; wrong total for the weekend
seed_balance(date: Date.new(2026, 5, 8), balance: 3351.00, cash_balance: 900.50)
seed_balance(date: Date.new(2026, 5, 9), balance: 900.50, cash_balance: 900.50) # wrong total
seed_balance(date: Date.new(2026, 5, 10), balance: 900.50, cash_balance: 900.50) # wrong total
seed_balance(date: Date.new(2026, 5, 11), balance: 3400.00, cash_balance: 910.00)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
saturday = @account.balances.find_by!(date: Date.new(2026, 5, 9), currency: "CHF")
sunday = @account.balances.find_by!(date: Date.new(2026, 5, 10), currency: "CHF")
# Total corrected to Friday's IBKR total; cash preserved from materializer
assert_equal BigDecimal("3351.00"), saturday.end_balance
assert_equal BigDecimal("900.50"), saturday.end_cash_balance
assert_equal BigDecimal("2450.50"), saturday.end_non_cash_balance
assert_equal BigDecimal("3351.00"), sunday.end_balance
assert_equal BigDecimal("900.50"), sunday.end_cash_balance
assert_equal BigDecimal("2450.50"), sunday.end_non_cash_balance
end
test "skips rows with missing or unparseable total" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-06", total: "N/A" }, # unparseable string — before first valid date
{ report_date: "2026-05-07", total: nil }, # nil total
{ report_date: "2026-05-08", total: "3351.00" } # valid
]
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
# Gap-fill starts at the first valid trading day (May 8), so pre-range
# dates with bad totals must not produce any balance row.
assert_nil @account.balances.find_by(date: Date.new(2026, 5, 6), currency: "CHF")
assert_nil @account.balances.find_by(date: Date.new(2026, 5, 7), currency: "CHF")
assert_not_nil @account.balances.find_by(date: Date.new(2026, 5, 8), currency: "CHF")
end
test "writes balance row with zero total for fully liquidated dates" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "0" },
{ report_date: "2026-05-08", total: "3351.00" }
]
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
balance = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("0"), balance.end_balance
end
end

View File

@@ -189,7 +189,7 @@ class IbkrAccountProcessorTest < ActiveSupport::TestCase
assert_not_nil holding
assert_equal BigDecimal("30"), holding.qty
assert_equal BigDecimal("123.1667"), holding.cost_basis
assert_in_delta BigDecimal("123.1667"), holding.cost_basis, BigDecimal("0.0001")
end
test "processor repairs default opening anchor after importing activity entries" do