Files
sure/test/models/binance_item
buzzromainandClaude Opus 5 843e91c257 fix(binance): let go of an asset the wallet no longer holds (#3234)
* fix(binance): let go of an asset the wallet no longer holds

Reported: a coin that had been sold stayed in the crypto account, showing up
alongside the ones still held. Two separate causes, both of which had to go.

The holdings processor only ever wrote what Binance returned. Nothing removed
what it stopped returning, and the account page reads one day's rows — so a
coin sold between two syncs kept its place for the rest of the day. Of the nine
provider holdings processors, only CoinstatsAccount's removes anything; this
follows it.

The removal is keyed on what the payload contains rather than on what was
successfully imported. An asset whose price cannot be fetched is skipped, and
deleting on that basis would turn a price outage into a vanished holding.

The importer made it permanent. Every sub-importer swallows its own error and
answers with an empty asset list, so a total outage reached the upsert looking
exactly like an emptied wallet — and the upsert was skipped, leaving the
previous payload in place for the holdings processor to re-import as today's
holdings. An asset already sold came back on every sync, indefinitely. A
complete outage now raises, so the sync fails instead of reporting a successful
import of nothing, and an emptied wallet is written down rather than skipped
whenever there is an account to correct.

A blank payload and a missing one are no longer the same thing: a wallet
nothing has reported on yet keeps its holdings, since removing them would be
deleting on the strength of missing information.

The import failure is also recorded through DebugLogEntry now, so support can
see it against the connection rather than only in the application log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): do not remove what an unavailable source never reported

Review on #3234, two ways the new cleanup could delete live positions.

A source that fails tells us nothing about what it holds, but the importer
wrote only the sources that answered — and the holdings processor removes what
is missing from that list. A transient margin error, or a key without margin
permission, would therefore have deleted every margin position. Their last
known assets are carried instead, and only while the source stays silent: once
it answers, what it says is what stands, including an asset it has stopped
reporting.

Worse, the guard against a total outage could not fire. EarnImporter's two
sub-requests each rescue to nil, so a double failure returned an empty asset
list with no error at all — indistinguishable from an account holding no Earn
positions. With spot, margin and futures all failing, `results.all?` was still
false, the importer wrote an empty wallet, and the cleanup emptied the
portfolio. Earn reports the double failure now, carrying both messages.

Also from review: the account_provider lookup added for the debug entry walked
a lazy has_one per account. It eager-loads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): keep the Earn side that went silent

Review on #3234. The carry-over works per source, and Earn is one source made
of two calls — so a single failing call slipped through it. With flexible
answering and locked failing, the result carried no error, nothing was carried,
and the cleanup removed every locked position. A key without locked permission
would have deleted them on the first sync.

Reporting the whole source as failed would have been wrong the other way: it
would discard the side that did answer, and freeze Earn entirely for anyone
whose key can only read one of the two.

Every asset already keeps its flexible and locked amounts apart, so the side
that went silent is refilled from what it last reported while the working side
stays fresh. Three tests: the silent side keeps its position, an asset held
only on the silent side survives, and a single failure is still not reported as
an outage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record a partly-read wallet where support can see it

Review on #3234. A source failing on its own returns normally, so the import
never reaches the rescue in BinanceItem#import_latest_binance_data that writes
the debug entry. The sync reported success, and the only trace that part of the
wallet went unread was an application log line — and none at all when there was
nothing to carry.

It goes through DebugLogEntry now, with the sources that were unavailable and
what each of them said, per the repo's provider-sync guidance. The warning
stays, since it names how many assets were carried, which the entry does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(binance): record an Earn endpoint that stopped answering

Review on #3234. One Earn side failing does not fail the import, so the parent
never reaches record_partial_failure — and /settings/debug showed nothing about
an endpoint that had stopped answering, while positions were being carried
precisely because of it.

Same shape as the parent's entry: a provider_sync warning naming the endpoints
that went silent and what each of them said. The per-endpoint errors were
already collected for the double-failure message; they are keyed by endpoint
now so the entry can say which one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:14:42 +02:00
..