Repository navigation
fix(strategies): make backtest cash interest step-invariant by carrying its sub-stroop remainder - #1207
Conversation
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>
|
@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. |
|
@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! 🚀 |
collinsezedike
left a comment
There was a problem hiding this comment.
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.
|
@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. |
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
runBacktestpromised in itsAccrualConfigdocs 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 throughaccruePortfolioTick, which carries the fractional-stroop remainder between ticks (accrual.ts).Cash interest did not. The old
cashInteresthelper recomputedfrom 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):150e9 / 1e10 = 1575e9 / 1e10 = 7×2A 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.tschecked 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.tscashInterest?: CashInterestAccrualonAccruePortfolioTickInput({ ratePerSecond }, signed like a funding rate — a positive rate credits idle cash, a negative one charges it).accruePortfolioTickaccrues it through the same privateaccruehelper as borrow and funding, i.e.cash × rate × elapsed / (STROOPS_PER_UNIT × 1000), with the truncated fraction carried inaccrualRemaindersunder the keycashand re-applied on the next tick.PortfolioTransaction = { type: "cash-interest"; amount }. It flows throughapplyPortfolioTransactionas a cash/equity credit, keepspreservesAccountingIdentitytrue, and can be negative when cash is negative (interest paid on a deficit).MILLISECONDS_PER_FUNDING_PERIODrenamed toMILLISECONDS_PER_SECONDsince both per-second legs share it.packages/strategies/src/backtest/runner.tscashInterest(cash, accrual, elapsed)function is gone. The runner now passescashInterest: cashInterest(config.accrual)intoaccruePortfolioTickand sums the resultingcash-interesttransactions alongsidefunding-paymentones.accrualRemaindersmap between ticks, the cash remainder is carried for free — thecashkey lives next tofunding:<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
150e9 / 1e10 = 15, r=0Behavior changes
fundingRateare bit-for-bit unchanged — the funding path only moved comment text; its arithmetic, keys and ordering are identical.cashRatePerSecondnow accrue the (at most one stroop per tick more) interest that was previously truncated away;totalInterestand 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.AccruePortfolioTickInput.cashInterestis optional,CashInterestAccrualis a new export, and the newcash-interesttransaction variant only appears for callers that opt in. TheexactOptionalPropertyTypes-friendly| undefinedis declared explicitly, matching the repo's strict tsconfig.Tests
packages/strategies/src/backtest/runner.test.tsmakes 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, rate0.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 accrue0.0000015(15 stroops) of interest and end with identical cash — before this fix the split run returned 14 stroops.packages/strategies/src/accrual.test.tscarries the sub-stroop cash remainder so two step sizes agree— engine-level: the first 1.5 s tick emits acash-interesttransaction and stores a strictly positivecashremainder; the second tick consumes it, both ticks together sum to exactly 15 stroops, matching the single 3 s tick, and the resulting cash satisfiespreservesAccountingIdentity.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:pnpm format:checkpnpm lint(all JS/TS pkgs)pnpm typecheck(12 tasks)pnpm --filter @meridian/strategies testpnpm --filter @meridian/strategies typecheck/lint@meridian/contractsclippycargois 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
pnpm format:check,pnpm lint,pnpm typecheck, strategies test suite — all green locallyCloses #1045