Files
sure/test/models/binance_account/processor_test.rb
T
UnamedRusandClaude Opus 4.8 8c52e20906 fix(binance): correct base_url for futures api endpoint, start_time param (#2839)
* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update binance.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Fix parameter naming for get_spot_trades and get_futures_trades

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* Update processor_test.rb

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>

* reset startTime after fromId

* Fix Binance trade sync param conflict with windowed initial fetch

Binance rejects fromId combined with startTime/endTime, and an unbounded
startTime (default sync ~1yr) exceeds the window cap (spot 24h, futures 7d),
so the initial sync could fail or miss trades.

Split fetch_new_trades into two non-mixing paths:
- incremental (cached trades): fromId-only pagination
- initial sync: walk forward in fixed windows (24h spot / 7d futures),
  clamped to the 6-month futures lookback

Add endTime param to get_spot_trades/get_futures_trades. Add tests covering
multi-window initial sync and multi-page fromId pagination.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: UnamedRus <UnamedRus@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 23:55:18 +02:00

274 lines
10 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
class BinanceAccount::ProcessorTest < ActiveSupport::TestCase
setup do
@family = families(:dylan_family)
@family.update!(currency: "EUR")
@item = BinanceItem.create!(
family: @family, name: "Binance", api_key: "k", api_secret: "s"
)
@ba = @item.binance_accounts.create!(
name: "Binance", account_type: "combined", currency: "USD", current_balance: 1000
)
@account = Account.create!(
family: @family,
name: "Binance",
balance: 0,
currency: "EUR",
accountable: Crypto.create!(subtype: "exchange")
)
AccountProvider.create!(account: @account, provider: @ba)
BinanceAccount::HoldingsProcessor.any_instance.stubs(:process).returns(nil)
@ba.stubs(:binance_item).returns(
stub(binance_provider: nil, family: @family)
)
end
test "converts USD balance to family currency when exact rate exists" do
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current, rate: 0.92)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_equal "EUR", @account.currency
assert_in_delta 920.0, @account.balance, 0.01
assert_equal false, @ba.extra.dig("binance", "stale_rate")
end
test "uses nearest rate and sets stale flag when exact rate missing" do
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current - 3, rate: 0.90)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_equal "EUR", @account.currency
assert_in_delta 900.0, @account.balance, 0.01
assert_equal true, @ba.extra.dig("binance", "stale_rate")
end
test "falls back to USD amount and sets stale flag when no rate available" do
ExchangeRate.expects(:find_or_fetch_rate).returns(nil)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_in_delta 1000.0, @account.balance, 0.01
assert_equal true, @ba.extra.dig("binance", "stale_rate")
end
test "clears stale flag on subsequent sync when exact rate found" do
@ba.update!(extra: { "binance" => { "stale_rate" => true } })
ExchangeRate.create!(from_currency: "USD", to_currency: "EUR",
date: Date.current, rate: 0.92)
BinanceAccount::Processor.new(@ba).process
@account.reload
@ba.reload
assert_equal false, @ba.extra.dig("binance", "stale_rate")
end
test "does not convert when family uses USD" do
@family.update!(currency: "USD")
BinanceAccount::Processor.new(@ba).process
@account.reload
assert_equal "USD", @account.currency
assert_in_delta 1000.0, @account.balance, 0.01
end
test "processes futures trades correctly" do
@family.update!(currency: "USD")
@ba.update!(raw_payload: { "assets" => [ { "symbol" => "BTC", "total" => "1.0" } ] })
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_spot_price).returns("50000.0")
provider.stubs(:get_all_p2p_trades).returns([]) # Skip P2P
# No cached trades -> initial sync walks time windows. First window returns
# the trade, every later window/pair returns nothing.
provider.stubs(:get_futures_trades).returns(
[ { "id" => 1, "time" => 1610000000000, "qty" => "0.1", "price" => "40000.0", "quoteQty" => "4000.0", "commission" => "0.0", "commissionAsset" => "USDT", "buyer" => true } ],
[]
)
Security.create!(ticker: "CRYPTO:BTC", name: "Bitcoin", price_provider: "binance_public")
assert_difference "Entry.count", 1 do
BinanceAccount::Processor.new(@ba).process
end
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_1")
end
test "initial futures sync walks multiple 7-day windows from sync_start_date" do
@family.update!(currency: "USD")
@item.update!(sync_start_date: 20.days.ago)
@ba.update!(raw_payload: { "assets" => [ { "symbol" => "BTC", "total" => "1.0" } ] })
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_spot_price).returns("50000.0")
provider.stubs(:get_all_p2p_trades).returns([])
trade = ->(id) {
{ "id" => id, "time" => 1610000000000, "qty" => "0.1", "price" => "40000.0", "quoteQty" => "4000.0", "commission" => "0.0", "commissionAsset" => "USDT", "buyer" => true }
}
# A 20-day span splits into three consecutive 7-day windows; each returns a
# distinct trade, then all remaining windows/pairs return nothing.
provider.stubs(:get_futures_trades).returns([ trade.call(1) ], [ trade.call(2) ], [ trade.call(3) ], [])
Security.create!(ticker: "CRYPTO:BTC", name: "Bitcoin", price_provider: "binance_public")
assert_difference "Entry.count", 3 do
BinanceAccount::Processor.new(@ba).process
end
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_1")
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_2")
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_3")
end
test "incremental sync paginates by fromId across full pages" do
@family.update!(currency: "USD")
@ba.update!(
raw_payload: { "assets" => [ { "symbol" => "BTC", "total" => "1.0" } ] },
raw_transactions_payload: {
"futures" => { "BTCUSDT" => [ { "id" => 100, "time" => 1610000000000 } ] }
}
)
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_spot_price).returns("50000.0")
provider.stubs(:get_all_p2p_trades).returns([])
full_page = (101..1100).map do |id|
{ "id" => id, "time" => 1610000000000, "qty" => "0.1", "price" => "40000.0", "quoteQty" => "4000.0", "commission" => "0.0", "commissionAsset" => "USDT", "buyer" => true }
end
last_page = [ { "id" => 1101, "time" => 1610000000000, "qty" => "0.1", "price" => "40000.0", "quoteQty" => "4000.0", "commission" => "0.0", "commissionAsset" => "USDT", "buyer" => true } ]
# First BTCUSDT futures call returns a full 1000-row page (triggers a second
# fromId request); the follow-up returns the final partial page. Other pairs
# have no cached trades, so their window scans return nothing.
provider.stubs(:get_futures_trades).returns(full_page, last_page, [])
Security.create!(ticker: "CRYPTO:BTC", name: "Bitcoin", price_provider: "binance_public")
assert_difference "Entry.count", 1001 do
BinanceAccount::Processor.new(@ba).process
end
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_101")
assert @account.entries.exists?(external_id: "binance_futures_BTCUSDT_1101")
end
test "processes P2P BUY trades with double-entry logic and exact native fiat" do
@family.update!(currency: "USD")
@account.update!(currency: "USD")
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
# Silence other importers
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_futures_trades).returns([])
# Mock the exact TZS/USDT payload with actual fiat transfer amounts
provider.stubs(:get_all_p2p_trades).returns([
{
"orderNumber" => "22883918231657005056",
"createTime" => 1777736533166,
"tradeType" => "BUY",
"asset" => "USDT",
"fiat" => "TZS",
"totalPrice" => "31500.00",
"unitPrice" => "2746.29",
"amount" => "11.47", # Gross crypto
"takerAmount" => "11.41", # Net crypto
"takerCommission" => "0.06" # Crypto fee
}
])
Security.create!(ticker: "CRYPTO:USDT", name: "Tether", price_provider: "binance_public")
# It MUST create 2 entries: 1 Deposit (Transaction) and 1 Purchase (Trade)
assert_difference "Entry.count", 2 do
BinanceAccount::Processor.new(@ba).process
end
# Verify the Deposit (Transaction) - Should be native fiat
deposit = @account.entries.find_by(external_id: "binance_p2p_22883918231657005056_funding")
assert_not_nil deposit
assert_equal "Transaction", deposit.entryable_type
assert_equal (-31500.00), deposit.amount.to_f # Negative = Fiat Cash INFLOW
assert_equal "TZS", deposit.currency
# Verify the Buy (Trade) - Should reflect the fiat cost basis
trade = @account.entries.find_by(external_id: "binance_p2p_22883918231657005056")
assert_not_nil trade
assert_equal "Trade", trade.entryable_type
assert_equal 31500.00, trade.amount.to_f # Positive = Fiat Cash OUTFLOW
assert_equal "TZS", trade.currency
assert_equal "Buy", trade.entryable.investment_activity_label
# Verify the specific crypto math and fiat fee conversion
assert_equal 11.41, trade.entryable.qty.to_f
# Fiat Fee = Crypto Fee (0.06) * Unit Price (2746.29) = 164.7774 (rounds to 164.78)
assert_equal 164.78, trade.entryable.fee.to_f
end
test "skips processing if P2P external_id already exists" do
@family.update!(currency: "USD")
@account.update!(currency: "USD")
# Pre-create the trade in the database
@account.entries.create!(
date: Date.current,
name: "Existing P2P",
amount: 10,
currency: "USD",
external_id: "binance_p2p_existing_123",
entryable: Transaction.new
)
provider = mock
@item.stubs(:binance_provider).returns(provider)
@ba.stubs(:binance_item).returns(@item)
provider.stubs(:get_spot_trades).returns([])
provider.stubs(:get_futures_trades).returns([])
# Mock a payload with the SAME orderNumber
provider.stubs(:get_all_p2p_trades).returns([
{ "orderNumber" => "existing_123", "tradeType" => "BUY", "asset" => "USDT", "amount" => "10.0" }
])
Security.create!(ticker: "CRYPTO:USDT", name: "Tether", price_provider: "binance_public")
# Assert that NO new entries are created
assert_no_difference "Entry.count" do
BinanceAccount::Processor.new(@ba).process
end
end
end