Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)

* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts

* [BUGFIX] Fix CoinStats PR regressions

* [BUGFIX] Fix CoinStats PR review findings

* [BUGFIX] Address follow-up CoinStats PR feedback

* [REFACTO] Extract CoinStats exchange account helpers

* [BUGFIX] Batch linked CoinStats chart normalization

* [BUGFIX] Fix CoinStats processor lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Anas Limouri
2026-04-01 20:25:06 +02:00
committed by GitHub
parent f63630c0fa
commit a90f9b7317
44 changed files with 2857 additions and 225 deletions

View File

@@ -9,6 +9,9 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
tailwind_build = Rails.root.join("app/assets/builds/tailwind.css")
FileUtils.mkdir_p(tailwind_build.dirname)
File.write(tailwind_build, "/* test */") unless tailwind_build.exist?
end
# Helper to wrap data in Provider::Response
@@ -175,4 +178,38 @@ class CoinstatsItemsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
assert_match(/No tokens found/, response.body)
end
test "link_exchange filters unexpected connection fields" do
Provider::Coinstats.any_instance.expects(:get_exchanges).returns(success_response([
{
connectionId: "bitvavo",
name: "Bitvavo",
connectionFields: [
{ key: "apiKey", name: "API Key" },
{ key: "apiSecret", name: "API Secret" }
]
}
])).once
linker_result = CoinstatsItem::ExchangeLinker::Result.new(success?: true, created_count: 0, errors: [])
CoinstatsItem::ExchangeLinker.expects(:new).with(
@coinstats_item,
connection_id: "bitvavo",
connection_fields: { "apiKey" => "key", "apiSecret" => "secret" },
name: "Bitvavo"
).returns(stub(link: linker_result))
post link_exchange_coinstats_items_url, params: {
coinstats_item_id: @coinstats_item.id,
exchange_connection_id: "bitvavo",
exchange_connection_name: "Bitvavo",
connection_fields: {
apiKey: " key ",
apiSecret: " secret ",
unexpected: "should_not_be_forwarded"
}
}
assert_redirected_to accounts_path
end
end

View File

@@ -43,4 +43,106 @@ class Account::ChartableTest < ActiveSupport::TestCase
memoized_series2_cash_view = account.balance_series(period: Period.last_90_days, view: :cash_balance)
memoized_series2_holdings_view = account.balance_series(period: Period.last_90_days, view: :holdings_balance)
end
test "trims placeholder history for linked investment accounts without trades" do
account = accounts(:investment)
account.entries.destroy_all
account.holdings.destroy_all
coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
account.account_providers.create!(provider: coinstats_account)
account.holdings.create!(
security: securities(:aapl),
date: 5.days.ago.to_date,
qty: 1,
price: 100,
amount: 100,
currency: "USD",
account_provider: account.account_providers.last
)
raw_series = Series.new(
start_date: 10.days.ago.to_date,
end_date: Date.current,
interval: "1 day",
values: [
Series::Value.new(date: 10.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")),
Series::Value.new(date: 9.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")),
Series::Value.new(date: 8.days.ago.to_date, date_formatted: "", value: Money.new(0, "USD")),
Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")),
Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(110, "USD"))
],
favorable_direction: account.favorable_direction
)
builder = mock
Balance::ChartSeriesBuilder.expects(:new).returns(builder)
builder.expects(:balance_series).returns(raw_series)
series = account.balance_series
assert_equal 5.days.ago.to_date, series.start_date
assert_equal [ 5.days.ago.to_date, Date.current ], series.values.map(&:date)
end
test "trims unstable provider snapshot history for linked investment accounts without trades" do
account = accounts(:investment)
account.entries.destroy_all
account.holdings.destroy_all
coinstats_item = account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
account.account_providers.create!(provider: coinstats_account)
account.holdings.create!(
security: securities(:aapl),
date: 5.days.ago.to_date,
qty: 1,
price: 100,
amount: 100,
currency: "USD",
account_provider: account.account_providers.last
)
account.holdings.create!(
security: securities(:aapl),
date: 4.days.ago.to_date,
qty: 1,
price: 100,
amount: 100,
currency: "USD",
account_provider: account.account_providers.last
)
account.holdings.create!(
security: securities(:msft),
date: Date.current,
qty: 1,
price: 120,
amount: 120,
currency: "USD",
account_provider: account.account_providers.last
)
raw_series = Series.new(
start_date: 5.days.ago.to_date,
end_date: Date.current,
interval: "1 day",
values: [
Series::Value.new(date: 5.days.ago.to_date, date_formatted: "", value: Money.new(100, "USD")),
Series::Value.new(date: 4.days.ago.to_date, date_formatted: "", value: Money.new(101, "USD")),
Series::Value.new(date: Date.current, date_formatted: "", value: Money.new(120, "USD"))
],
favorable_direction: account.favorable_direction
)
builder = mock
Balance::ChartSeriesBuilder.expects(:new).returns(builder)
builder.expects(:balance_series).returns(raw_series)
series = account.balance_series
assert_equal Date.current, series.start_date
assert_equal [ Date.current ], series.values.map(&:date)
end
end

View File

@@ -167,6 +167,58 @@ class Account::MarketDataImporterTest < ActiveSupport::TestCase
assert_equal 0, Security::Price.where(security: security, date: trade_date).count
end
test "syncs security prices for provider-held securities without trades" do
family = Family.create!(name: "Smith", currency: "USD")
account = family.accounts.create!(
name: "Brokerage",
currency: "USD",
balance: 0,
accountable: Investment.new
)
security = Security.create!(ticker: "PE500", exchange_operating_mic: "XPAR")
coinstats_item = family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
account_provider = AccountProvider.create!(account: account, provider: coinstats_account)
account.holdings.create!(
security: security,
date: Date.current,
qty: 1,
price: 100,
amount: 100,
currency: "USD",
account_provider: account_provider
)
expected_start_date = account.start_date - SECURITY_PRICE_BUFFER
end_date = Date.current.in_time_zone("America/New_York").to_date
@provider.expects(:fetch_security_prices)
.with(symbol: security.ticker,
exchange_operating_mic: security.exchange_operating_mic,
start_date: expected_start_date,
end_date: end_date)
.returns(provider_success_response([
OpenStruct.new(security: security,
date: account.start_date,
price: 100,
currency: "USD")
]))
@provider.stubs(:fetch_security_info)
.with(symbol: security.ticker, exchange_operating_mic: security.exchange_operating_mic)
.returns(provider_success_response(OpenStruct.new(name: "PE500", logo_url: "logo")))
@provider.stubs(:fetch_exchange_rates).returns(provider_success_response([]))
Account::MarketDataImporter.new(account).import_all
assert_equal 1, Security::Price.where(security: security, date: account.start_date).count
end
test "handles provider error response gracefully for exchange rates" do
family = Family.create!(name: "Smith", currency: "USD")

View File

@@ -47,7 +47,7 @@ class CoinstatsAccount::ProcessorTest < ActiveSupport::TestCase
@account.reload
assert_equal BigDecimal("5000.50"), @account.balance
assert_equal BigDecimal("5000.50"), @account.cash_balance
assert_equal BigDecimal("0"), @account.cash_balance
end
test "updates account currency from coinstats account" do

View File

@@ -290,4 +290,46 @@ class CoinstatsAccountTest < ActiveSupport::TestCase
# Verify wallet A no longer exists
assert_nil CoinstatsAccount.find_by(id: wallet_a.id)
end
test "portfolio exchange account derives total and cash balances from embedded coins" do
@family.update!(currency: "EUR")
portfolio_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "EUR",
account_id: "exchange_portfolio:test",
wallet_address: "portfolio-test",
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio-test",
exchange_name: "Bitvavo",
coins: [
{
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
count: "0.00335845",
price: { EUR: "57950.0491" }
},
{
coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" },
count: "0.05580825",
price: { EUR: "1728.952252246" }
},
{
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
count: "2.58",
price: { EUR: "1" }
}
]
}
)
assert portfolio_account.exchange_portfolio_account?
refute portfolio_account.fiat_asset?
assert_equal "EUR", portfolio_account.inferred_currency
assert_in_delta 293.69214193130284, portfolio_account.inferred_current_balance.to_f, 0.0001
assert_in_delta 2.58, portfolio_account.inferred_cash_balance.to_f, 0.0001
assert_equal 2, portfolio_account.portfolio_non_fiat_coins.size
assert_equal 1, portfolio_account.portfolio_fiat_coins.size
end
end

View File

@@ -264,4 +264,202 @@ class CoinstatsEntry::ProcessorTest < ActiveSupport::TestCase
processor.process
end
end
test "restores legacy transaction entry if trade import fails" do
exchange_crypto = Crypto.create!
exchange_account_record = @family.accounts.create!(
accountable: exchange_crypto,
name: "Bitvavo",
balance: 1000,
currency: "USD"
)
exchange_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "USD",
account_id: "exchange_portfolio:portfolio_123",
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio_123",
coins: []
}
)
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
legacy_entry = exchange_account_record.entries.create!(
entryable: Transaction.new,
external_id: "coinstats_trade_legacy",
source: "coinstats",
amount: 100,
currency: "USD",
date: Date.new(2025, 1, 15),
name: "Trade BTC"
)
transaction_data = {
type: "Trade",
date: "2025-01-15T10:00:00.000Z",
hash: { id: "trade_legacy" },
transactions: [
{
items: [
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.1", totalWorth: "100" },
{ coin: { id: "ethereum", symbol: "ETH" }, count: "1.5", totalWorth: "100" }
]
}
]
}
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
Account::ProviderImportAdapter.any_instance.expects(:import_trade).raises(StandardError, "boom")
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
assert_raises(StandardError) { processor.process }
assert exchange_account_record.entries.exists?(id: legacy_entry.id)
end
test "exchange trades prefer the disposed asset leg" do
exchange_crypto = Crypto.create!
exchange_account_record = @family.accounts.create!(
accountable: exchange_crypto,
name: "Bitvavo",
balance: 1000,
currency: "USD"
)
exchange_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "USD",
account_id: "exchange_portfolio:portfolio_123",
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio_123",
coins: []
}
)
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
transaction_data = {
type: "Trade",
date: "2025-01-15T10:00:00.000Z",
hash: { id: "trade_disposed_asset" },
transactions: [
{
items: [
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" },
{ coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" }
]
}
]
}
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
processor.process
entry = exchange_account_record.entries.order(created_at: :desc).first
assert_equal "Trade BTC", entry.name
assert_equal "Sell", entry.trade.investment_activity_label
end
test "portfolio exchange fallback keeps disposed asset sign when trade import is skipped" do
exchange_crypto = Crypto.create!
exchange_account_record = @family.accounts.create!(
accountable: exchange_crypto,
name: "Bitvavo",
balance: 1000,
currency: "USD"
)
exchange_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "USD",
account_id: "exchange_portfolio:portfolio_123",
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio_123",
coins: []
}
)
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
transaction_data = {
type: "Trade",
date: "2025-01-15T10:00:00.000Z",
hash: { id: "trade_fallback_sign" },
transactions: [
{
items: [
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" },
{ coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" }
]
}
]
}
Security::Resolver.any_instance.stubs(:resolve).returns(nil)
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
processor.process
entry = exchange_account_record.entries.order(created_at: :desc).first
assert_equal BigDecimal("100"), entry.amount
assert_equal "Trade BTC", entry.name
end
test "preserves protected legacy transaction when migrating exchange trade" do
exchange_crypto = Crypto.create!
exchange_account_record = @family.accounts.create!(
accountable: exchange_crypto,
name: "Bitvavo",
balance: 1000,
currency: "USD"
)
exchange_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "USD",
account_id: "exchange_portfolio:portfolio_123",
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio_123",
coins: []
}
)
AccountProvider.create!(account: exchange_account_record, provider: exchange_account)
legacy_entry = exchange_account_record.entries.create!(
entryable: Transaction.new,
external_id: "coinstats_trade_protected",
source: "coinstats",
amount: 100,
currency: "USD",
date: Date.new(2025, 1, 15),
name: "Trade BTC"
)
legacy_entry.mark_user_modified!
transaction_data = {
type: "Trade",
date: "2025-01-15T10:00:00.000Z",
hash: { id: "trade_protected" },
transactions: [
{
items: [
{ coin: { id: "bitcoin", symbol: "BTC" }, count: "-0.00335845", totalWorth: "100" },
{ coin: { id: "ethereum", symbol: "ETH" }, count: "0.05580825", totalWorth: "100" }
]
}
]
}
Security::Resolver.any_instance.stubs(:resolve).returns(securities(:aapl))
processor = CoinstatsEntry::Processor.new(transaction_data, coinstats_account: exchange_account)
assert_no_difference("Trade.count") { assert_equal legacy_entry, processor.process }
assert_equal "Transaction", legacy_entry.reload.entryable_type
end
end

View File

@@ -0,0 +1,125 @@
require "test_helper"
class CoinstatsItem::ExchangeLinkerTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@family.update!(currency: "EUR")
@coinstats_item = CoinstatsItem.create!(
family: @family,
name: "Test CoinStats Connection",
api_key: "test_api_key_123"
)
end
def success_response(data)
Provider::Response.new(success?: true, data: data, error: nil)
end
test "link creates one exchange portfolio account with embedded coins" do
Provider::Coinstats.any_instance.expects(:exchange_options).returns([
{
connection_id: "bitvavo",
name: "Bitvavo",
icon: "https://example.com/bitvavo.png",
connection_fields: [
{ key: "apiKey", name: "API Key" },
{ key: "apiSecret", name: "API Secret" }
]
}
])
Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange)
.with(
connection_id: "bitvavo",
connection_fields: { "apiKey" => "key", "apiSecret" => "secret" },
name: "Bitvavo Portfolio"
)
.returns(success_response({ portfolioId: "portfolio_123" }))
Provider::Coinstats.any_instance.expects(:list_portfolio_coins)
.with(portfolio_id: "portfolio_123")
.returns([
{
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
count: "0.00335845",
price: { EUR: "57950.0491" }
},
{
coin: { identifier: "ethereum", symbol: "ETH", name: "Ethereum" },
count: "0.05580825",
price: { EUR: "1728.952252246" }
},
{
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
count: "2.58",
price: { EUR: "1" }
}
])
@coinstats_item.expects(:sync_later).once
assert_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ], 1 do
result = CoinstatsItem::ExchangeLinker.new(
@coinstats_item,
connection_id: "bitvavo",
connection_fields: { "apiKey" => "key", "apiSecret" => "secret" }
).link
assert result.success?
assert_equal 1, result.created_count
end
@coinstats_item.reload
assert_equal "portfolio_123", @coinstats_item.exchange_portfolio_id
coinstats_account = @coinstats_item.coinstats_accounts.last
assert coinstats_account.exchange_portfolio_account?
assert_equal "Bitvavo", coinstats_account.name
assert_equal "exchange_portfolio:portfolio_123", coinstats_account.account_id
assert_equal 3, coinstats_account.raw_payload["coins"].size
account = coinstats_account.account
assert_equal "Bitvavo", account.name
assert_equal "EUR", account.currency
assert_in_delta 293.69214193130284, account.balance.to_f, 0.0001
assert_in_delta 2.58, account.cash_balance.to_f, 0.0001
end
test "link defers local account creation when initial portfolio coin fetch is missing" do
Provider::Coinstats.any_instance.expects(:exchange_options).returns([
{
connection_id: "bitvavo",
name: "Bitvavo",
icon: "https://example.com/bitvavo.png",
connection_fields: [
{ key: "apiKey", name: "API Key" }
]
}
])
Provider::Coinstats.any_instance.expects(:connect_portfolio_exchange)
.returns(success_response({ portfolioId: "portfolio_456" }))
Provider::Coinstats.any_instance.expects(:list_portfolio_coins)
.with(portfolio_id: "portfolio_456")
.returns(nil)
@coinstats_item.expects(:sync_later).once
assert_no_difference [ "CoinstatsAccount.count", "Account.count", "AccountProvider.count" ] do
result = CoinstatsItem::ExchangeLinker.new(
@coinstats_item,
connection_id: "bitvavo",
connection_fields: { "apiKey" => "key" }
).link
assert result.success?
assert_equal 0, result.created_count
end
@coinstats_item.reload
assert_equal "portfolio_456", @coinstats_item.exchange_portfolio_id
assert_equal "bitvavo", @coinstats_item.exchange_connection_id
assert_empty @coinstats_item.coinstats_accounts
end
end

View File

@@ -220,6 +220,121 @@ class CoinstatsItem::ImporterTest < ActiveSupport::TestCase
assert_equal 0, result[:transactions_imported]
end
test "preserves exchange portfolio snapshot when portfolio coin fetch is missing" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Bitvavo",
balance: 250,
cash_balance: 10,
currency: "EUR"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "EUR",
account_id: "exchange_portfolio:portfolio_123",
wallet_address: "portfolio_123",
current_balance: 250,
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio_123",
connection_id: "bitvavo",
exchange_name: "Bitvavo",
coins: [
{
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
count: "0.003",
price: { EUR: "80000" }
},
{
coin: { identifier: "FiatCoin:eur", symbol: "EUR", name: "Euro", isFiat: true },
count: "10",
price: { EUR: "1" }
}
]
},
raw_transactions_payload: []
)
AccountProvider.create!(account: account, provider: coinstats_account)
@mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({}))
@mock_provider.expects(:list_exchange_transactions)
.with(portfolio_id: "portfolio_123", currency: "USD", from: nil)
.returns([])
@mock_provider.expects(:list_portfolio_coins)
.with(portfolio_id: "portfolio_123")
.returns(nil)
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
assert_no_changes -> { coinstats_account.reload.current_balance.to_f } do
result = importer.import
assert result[:success]
assert_equal 1, result[:accounts_updated]
assert_equal 0, result[:transactions_imported]
end
reloaded = coinstats_account.reload
assert_equal "portfolio_123", reloaded.raw_payload["portfolio_id"]
assert_equal 2, reloaded.raw_payload["coins"].size
assert_equal 250.0, reloaded.current_balance.to_f
end
test "writes an empty exchange portfolio snapshot when CoinStats returns an empty portfolio" do
crypto = Crypto.create!
account = @family.accounts.create!(
accountable: crypto,
name: "Bitvavo",
balance: 250,
cash_balance: 10,
currency: "EUR"
)
coinstats_account = @coinstats_item.coinstats_accounts.create!(
name: "Bitvavo",
currency: "EUR",
account_id: "exchange_portfolio:portfolio_123",
wallet_address: "portfolio_123",
current_balance: 250,
raw_payload: {
source: "exchange",
portfolio_account: true,
portfolio_id: "portfolio_123",
connection_id: "bitvavo",
exchange_name: "Bitvavo",
coins: [
{
coin: { identifier: "bitcoin", symbol: "BTC", name: "Bitcoin" },
count: "0.003",
price: { EUR: "80000" }
}
]
},
raw_transactions_payload: []
)
AccountProvider.create!(account: account, provider: coinstats_account)
@mock_provider.expects(:sync_exchange).with(portfolio_id: "portfolio_123").returns(success_response({}))
@mock_provider.expects(:list_exchange_transactions)
.with(portfolio_id: "portfolio_123", currency: "USD", from: nil)
.returns([])
@mock_provider.expects(:list_portfolio_coins)
.with(portfolio_id: "portfolio_123")
.returns([])
importer = CoinstatsItem::Importer.new(@coinstats_item, coinstats_provider: @mock_provider)
result = importer.import
assert result[:success]
assert_equal 1, result[:accounts_updated]
reloaded = coinstats_account.reload
assert_equal 0.0, reloaded.current_balance.to_f
assert_equal [], reloaded.raw_payload["coins"]
end
test "calculates balance from matching token only, not all tokens" do
# Create two accounts for different tokens in the same wallet
crypto1 = Crypto.create!

View File

@@ -93,4 +93,34 @@ class Holding::MaterializerTest < ActiveSupport::TestCase
assert_equal "calculated", holding.cost_basis_source
assert_equal BigDecimal("2750.0"), holding.cost_basis
end
test "preserves calculated history for provider-sourced holdings on reverse materialization" do
coinstats_item = @family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
coinstats_account = coinstats_item.coinstats_accounts.create!(
name: "Brokerage",
currency: "USD"
)
account_provider = AccountProvider.create!(account: @account, provider: coinstats_account)
Holding.create!(
account: @account,
security: @aapl,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
date: Date.current,
account_provider: account_provider
)
Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings
today_holding = @account.holdings.find_by!(security: @aapl, date: Date.current, currency: "USD")
yesterday_holding = @account.holdings.find_by!(security: @aapl, date: Date.yesterday, currency: "USD")
assert_equal account_provider.id, today_holding.account_provider_id
assert_nil yesterday_holding.account_provider_id
assert_equal BigDecimal("10"), yesterday_holding.qty
assert_equal yesterday_holding.qty * yesterday_holding.price, yesterday_holding.amount
end
end

View File

@@ -47,4 +47,41 @@ class Holding::PortfolioSnapshotTest < ActiveSupport::TestCase
assert_equal 1, portfolio.size
assert_equal 0, portfolio[@aapl.id]
end
test "prefers the latest provider snapshot over newer calculated holdings" do
@account.holdings.destroy_all
@account.entries.destroy_all
create_trade(@aapl, account: @account, qty: 10, price: 100, date: 5.days.ago)
create_trade(@msft, account: @account, qty: 5, price: 200, date: 5.days.ago)
coinstats_item = @account.family.coinstats_items.create!(name: "CoinStats", api_key: "test-key")
coinstats_account = coinstats_item.coinstats_accounts.create!(name: "Provider", currency: "USD")
account_provider = AccountProvider.create!(account: @account, provider: coinstats_account)
@account.holdings.create!(
security: @aapl,
date: 1.day.ago,
qty: 10,
price: 100,
amount: 1000,
currency: "USD",
account_provider: account_provider
)
@account.holdings.create!(
security: @msft,
date: Date.current,
qty: 5,
price: 200,
amount: 1000,
currency: "USD"
)
portfolio = Holding::PortfolioSnapshot.new(@account).to_h
assert_equal 2, portfolio.size
assert_equal 10, portfolio[@aapl.id]
assert_equal 0, portfolio[@msft.id]
end
end