mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
Merge branch 'feat/goals-v2-architecture' of https://github.com/we-promise/sure into feat/goals-v2-architecture
This commit is contained in:
@@ -1 +1 @@
|
||||
0.7.1-alpha.9
|
||||
0.7.1-alpha.10
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
57
test/models/ibkr_account/data_helpers_test.rb
Normal file
57
test/models/ibkr_account/data_helpers_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user