Skip to content

fix(strategies): make backtest cash interest step-invariant by carrying its sub-stroop remainder - #1207

Merged
collinsezedike merged 1 commit into
drydocs:mainfrom
graceanya796-maker:fix/backtest-cash-interest-step-invariance
Oct 20, 2026
Merged

collinsezedike merged 1 commit into
drydocs:mainfrom
graceanya796-maker:fix/backtest-cash-interest-step-invariance

Conversation

@graceanya796-maker

Copy link
Copy Markdown
Contributor

Fixes #1045

What this PR does

Routes backtest cash interest through the same remainder-carrying accrual engine the funding path already uses, so the runner's documented step-invariance promise ("two step sizes agree over the same window", runner.ts) now holds for cash interest as well as funding. Previously cash interest truncated away its sub-stroop fraction on every tick.

The bug

runBacktest promised in its AccrualConfig docs that accrual "prorates to the clock step rather than to a tick count so two step sizes agree over the same window", and the funding path honored that by going through accruePortfolioTick, which carries the fractional-stroop remainder between ticks (accrual.ts).

Cash interest did not. The old cashInterest helper recomputed

cash.toStroops() * rate.toStroops() * elapsedMs / (STROOPS_PER_UNIT * 1000n)

from scratch on every tick, so each tick truncated independently and the dust was lost. From the issue's repro (cash rate 0.0000001/s, 5 units of idle cash, a 3-second window):

Run Per-tick accrual Total
one 3 s tick 150e9 / 1e10 = 15 15 stroops
two 1.5 s ticks 75e9 / 1e10 = 7 ×2 14 stroops

A one-stroop (0.0000001 unit) discrepancy between two identical windows with different step sizes — and the loss grows with the number of ticks, so long backtests with fine steps systematically understate interest.

runner.test.ts checked the step-invariance property for funding only, so this went unnoticed.

The fix

Cash interest now flows through the accrual engine, mirroring the funding leg exactly:

packages/strategies/src/accrual.ts

  • New optional input leg cashInterest?: CashInterestAccrual on AccruePortfolioTickInput ({ ratePerSecond }, signed like a funding rate — a positive rate credits idle cash, a negative one charges it).
  • accruePortfolioTick accrues it through the same private accrue helper as borrow and funding, i.e. cash × rate × elapsed / (STROOPS_PER_UNIT × 1000), with the truncated fraction carried in accrualRemainders under the key cash and re-applied on the next tick.
  • Interest is charged on the opening cash of the tick, so its amount does not depend on where the block sits relative to the borrow/funding loops (this preserves existing behavior: funding + interest are both settled on the tick's opening balances).
  • New transaction type PortfolioTransaction = { type: "cash-interest"; amount }. It flows through applyPortfolioTransaction as a cash/equity credit, keeps preservesAccountingIdentity true, and can be negative when cash is negative (interest paid on a deficit).
  • MILLISECONDS_PER_FUNDING_PERIOD renamed to MILLISECONDS_PER_SECOND since both per-second legs share it.

packages/strategies/src/backtest/runner.ts

  • The local truncating cashInterest(cash, accrual, elapsed) function is gone. The runner now passes cashInterest: cashInterest(config.accrual) into accruePortfolioTick and sums the resulting cash-interest transactions alongside funding-payment ones.
  • Because the runner already persists the engine's accrualRemainders map between ticks, the cash remainder is carried for free — the cash key lives next to funding:<id> entries and is dropped by the engine when the rate is no longer configured (same lifecycle as a closed position's remainder).

Step-invariance after the fix

Run Ticks Remainder carry Total
one 3 s tick 150e9 / 1e10 = 15, r=0 — 15 stroops
two 1.5 s ticks 7, r=5e9 → 8, r=0 0.5-stroop fraction carried across ticks 15 stroops

Behavior changes

  • Backtests that only configure fundingRate are bit-for-bit unchanged — the funding path only moved comment text; its arithmetic, keys and ordering are identical.
  • Backtests with a cashRatePerSecond now accrue the (at most one stroop per tick more) interest that was previously truncated away; totalInterest and cash balances can differ from previous runs by exactly the recovered dust, and two step sizes over the same window now agree. This is the fix requested in [Bug] cashInterest truncates each interval without carrying a remainder #1045.
  • Public API is extended additively and backward-compatibly: AccruePortfolioTickInput.cashInterest is optional, CashInterestAccrual is a new export, and the new cash-interest transaction variant only appears for callers that opt in. The exactOptionalPropertyTypes-friendly | undefined is declared explicitly, matching the repo's strict tsconfig.

Tests

packages/strategies/src/backtest/runner.test.ts

  • makes cash interest step-invariant by carrying the sub-stroop remainder — the exact repro from [Bug] cashInterest truncates each interval without carrying a remainder #1045: 5 units of cash, rate 0.0000001/s, a 3-second window, run once as two 1.5 s ticks and once as a single 3 s tick. Asserts both runs accrue 0.0000015 (15 stroops) of interest and end with identical cash — before this fix the split run returned 14 stroops.

packages/strategies/src/accrual.test.ts

  • carries the sub-stroop cash remainder so two step sizes agree — engine-level: the first 1.5 s tick emits a cash-interest transaction and stores a strictly positive cash remainder; the second tick consumes it, both ticks together sum to exactly 15 stroops, matching the single 3 s tick, and the resulting cash satisfies preservesAccountingIdentity.

All pre-existing tests are untouched and still pass — no expected values needed adjusting, confirming the funding path and the non-truncating cases are unchanged.

Validation (CI run locally)

Run against current main (115c0c0) in this environment:

Check Result
pnpm format:check ✅ pass
pnpm lint (all JS/TS pkgs) ✅ pass
pnpm typecheck (12 tasks) ✅ pass
pnpm --filter @meridian/strategies test ✅ 35 files, 718 tests pass
pnpm --filter @meridian/strategies typecheck / lint ✅ pass
@meridian/contracts clippy ⚠️ pre-existing local failure — cargo is not installed in this sandbox; unrelated to this change (Rust sources untouched)

The e2e/Playwright and contract-coverage jobs are also unaffected: this change touches only TypeScript in packages/strategies.

Issue checklist

  • Reproduced the 14-vs-15 stroop discrepancy from [Bug] cashInterest truncates each interval without carrying a remainder #1045
  • Routed cash interest through the remainder-carrying accrual (the fix suggested in the issue's Possible Cause / Fix)
  • Added the step-invariance test for cash the issue asked for (the existing test covered only funding)
  • pnpm format:check, pnpm lint, pnpm typecheck, strategies test suite — all green locally

Closes #1045

Cash interest was recomputed from scratch on every tick and truncated
independently, so two step sizes over the same window disagreed: 14
stroops across two 1.5 s ticks versus 15 across one 3 s tick, which
breaks the runner's documented step-invariance promise.

Route cash interest through the same remainder-carrying accrual the
funding path uses. The accrual engine accepts an optional cashInterest
leg, accrues it under a cash remainder key on the tick's opening cash,
and reports it as a cash-interest transaction; the runner sums that
transaction alongside funding instead of doing its own truncating
division.

Adds the step-invariance test for cash that the issue asked for, plus
an engine-level test that the sub-stroop cash remainder survives a tick
too short to accrue a whole stroop.

Closes drydocs#1045

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
@vercel

vercel Bot commented Oct 20, 2026

Copy link
Copy Markdown

@graceanya796-maker is attempting to deploy a commit to the Collins' projects Team on Vercel.

A member of the Team first needs to authorize it.

@drips-wave

drips-wave Bot commented Oct 20, 2026

Copy link
Copy Markdown

@graceanya796-maker Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@collinsezedike collinsezedike left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix, @graceanya796-maker. Carrying the sub-stroop remainder through the accrual engine is the right shape, and the step-invariance tests cover it well. If you'd like to pick up another open issue, there are several available in the tracker.

@collinsezedike
collinsezedike merged commit 7ffde78 into drydocs:main Oct 20, 2026
10 of 11 checks passed
@collinsezedike

Copy link
Copy Markdown
Collaborator

@graceanya796-maker thank you again for getting this over the line. If you are enjoying working on Meridian, starring the repository helps more contributors find the project and is much appreciated.

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.

[Bug] cashInterest truncates each interval without carrying a remainder

2 participants