Skip to content

adf934c6 - fix(custody): carry the saving position and its interest through the value history - #4451

Merged
TaprootFreak merged 4 commits into
developfrom
fix/custody-history-interest-and-peer-pricing
Jul 29, 2026
Merged

adf934c6 - fix(custody): carry the saving position and its interest through the value history#4451
TaprootFreak merged 4 commits into
developfrom
fix/custody-history-interest-and-peer-pricing

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

The Safe's value history silently dropped any holding whose asset had no asset_price row for a
given day, and accrued interest was part of neither the history nor the balance total.

Why

Ethereum/sZCHF was created on 2026-07-28, long after the deposits it represents (booked
2026-01-28). It therefore has no price series before that date. calculateDailyPortfolioValue()
iterated over the prices and looked up a balance for each — so a holding whose asset carried
no row that day contributed nothing and disappeared from the series without a trace.

In production this is visible: the affected Safe reads 628'115.62 CHF on 27.07. and 727'515.31 CHF
on 28.07. — a vertical jump of exactly the position that had been held since January. For a Safe
whose only holding is the saving position, the entire history reads zero until that day.

Accrued interest had a related gap: it was shown per position but excluded from totalValue,
deliberately, so the number would not disagree with a chart that knew nothing about interest. That
left the total understated by an amount that grows every day.

How

  • Valuation is driven by the holdings, not by the prices. Every holding is now either valued or
    recorded as unpriced; one that cannot be priced at all is logged once per request instead of
    being skipped in silence.
  • An asset without a series of its own is priced from an asset sharing its price rule. For
    sZCHF that is ZCHF — the same price by definition, not an estimate, and no reconstructed
    price rows are written. New: AssetService.getAssetsByPriceRules() / getAssetsByIdWith().
  • Interest is accrued per day of the series and folded into the saving position's balance, so
    it is valued by the same code path as every other holding. It is computed once for all days
    (accrueInterestByDay), so a data error is reported a single time and costs the customer the
    interest, not their whole history — the trade-off getUserCustodyBalance() already makes.
  • totalValue now includes accrued interest. The reason it was excluded — divergence from the
    history — no longer holds.
  • Interest is only reported while the position is open. Tranches accrue with their own sign, so
    a fully paid out position leaves a frozen remainder: the interest earned while it was held. That
    figure is never booked and never paid out, so carrying it into the total and the chart would
    leave a Safe holding nothing showing a residue forever. Partial payouts are unaffected — the
    remaining principal keeps accruing.
  • One documented dust bound (BALANCE_DUST) for every "is this still a holding" decision.
    Balances are plain floating point sums, so a closed position rarely lands on exact zero; exact
    comparisons would let that residue revive a closed position's interest or report it as an
    unpriced holding.

Trade-offs

  • History interest is accrued to the start of each day, the instant the day's balances
    describe. The balance endpoint accrues to now, so the newest point can trail it by up to one
    day's interest. That is the same class of gap the series already has against the balance's spot
    price, and it is recorded at the call site.
  • accrueInterestByDay drops the whole interest series when the calculation fails on any day,
    rather than serving the days computed so far. A partial series would bend the chart downwards at
    the point of failure and read as a real loss of value; a consistently interest-free series is the
    honest degradation. The failure conditions in accrueTranche are day-independent anyway, so a
    partial failure is the exception, not the rule.
  • A non-finite balance keeps poisoning the daily sum exactly as before rather than being quietly
    dropped in the new loop: that is a data fault and belongs where it is already handled.

Known limitation (pre-existing, not introduced here)

accrueInterest() sums tranches over the entire order history and is not reset when a position is
closed. If a saving position were fully paid out and later refilled, the interest of the earlier,
closed period would reappear alongside the new one. This PR does not change that calculation — it
only stops reporting interest while a position is closed. The behaviour is unreachable in
production today (no sZCHF withdrawal exists, and SAVING_WITHDRAWAL has no wired order path),
and whether a reopened position should keep its earlier earnings is a product question rather than
a defect. Tracked for a follow-up.

Tests

  • New: a holding is valued from a price-rule peer on days its own asset has no row.
  • New: the series carries accrued interest — 99'500 deposited on 28.01. reads 101'226.94 six
    months on, matching what the balance reports.
  • New: the production combination in one test — the saving asset both bears interest and has no
    price series of its own, so peer pricing and interest have to work on the same asset at once.
  • New: a fully paid out position stops contributing, in the history and in the balance total.
  • Updated: the totalValue expectation now includes interest (the old assertion pinned the
    behaviour this PR changes). The failure case is unchanged — a broken interest figure is left out
    of the total rather than guessed at.

…value history

The Safe's value history dropped any holding whose asset had no asset_price row
for a given day, and left accrued interest out of both the history and the
balance total.

Ethereum/sZCHF was created long after the deposits it represents, so it has no
price series before its creation date. The daily valuation iterated over the
prices and looked up a balance for each, which meant a holding without a row
that day contributed nothing and vanished from the series without a trace. In
production a position booked in January only appeared in the chart six months
later, on the day its first price was written, as a vertical jump.

Valuation is now driven by the holdings instead, and an asset without a row of
its own is priced from an asset sharing its price rule -- identical by
definition, not an estimate. A holding that cannot be priced at all is reported
once per request rather than silently skipped.

Accrued interest is now part of both figures. It was previously excluded from
totalValue to keep it equal to the history, which had no notion of interest;
the history accrues it per day now, so both sides agree and the customer sees
what the position is actually worth.
Tranches accrue with their own sign, so a fully paid out position leaves a
frozen remainder -- the interest it earned while it was held. That figure is
never booked and never paid out. Carrying it into totalValue and the value
history, as this branch newly does, left a Safe holding nothing showing a
residue forever.

Interest is now only reported while the position is actually open, in both the
balance total and the history. Partial payouts are unaffected: the remaining
principal keeps accruing, which the existing negative-tranche test pins down.

Also replaces the exact zero-balance comparison in the daily valuation with a
tolerance. Balances are plain floating point sums, so a closed position rarely
lands on exact zero, and the dust left behind would be reported as an unpriced
holding on any day without a price.
The closed-position guards compared against exact zero while the daily
valuation used a tolerance, so floating point residue could pass one and not
the other -- and reviving the interest of a position that is in fact closed is
exactly what the guard exists to prevent. All three now share one documented
bound.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Three review passes on the full diff, covering conformance and correctness separately.

Pass 1 found the one defect this branch introduced: because interest now counts towards the
total and the chart, a fully paid out position kept showing the frozen remainder of what it once
earned — a Safe holding nothing would have displayed a residue indefinitely. Fixed in 5e9a444:
interest is only reported while the position is open, in both the balance and the history, with
tests for the history and the balance path.

Pass 2 found the closed-position guards comparing against exact zero while the daily valuation
used a tolerance, so floating point residue could pass one and not the other — reviving the
interest of a position that is in fact closed. Consolidated in ed87dac into a single documented
bound used by all three decisions.

Pass 3 raised no blocking issues. The remaining points are tracked in #4456: each is either
pre-existing (unchanged by this PR), unreachable in production today, or cosmetic. The largest of
them — interest not being reset across a close/reopen cycle — is a product decision rather than a
defect, and the calculation it concerns is not touched here.

Also verified against production while reviewing: no completed order pays out Ethereum/sZCHF in
any order type, which is what makes that scenario unreachable today.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 29, 2026 14:47
@TaprootFreak
TaprootFreak merged commit cd2a6d8 into develop Jul 29, 2026
12 checks passed
@TaprootFreak
TaprootFreak deleted the fix/custody-history-interest-and-peer-pricing branch July 29, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant