Visitar URL original
feat(returns): time-weighted return (TWR) in engine and report by robcohen · Pull Request #1830 · rustledger/rustledger · GitHub
Skip to content

feat(returns): time-weighted return (TWR) in engine and report - #1830

Merged
robcohen merged 4 commits into
mainfrom
feat/returns-twr
Jul 20, 2026
Merged

robcohen merged 4 commits into
mainfrom
feat/returns-twr

Conversation

@robcohen

Copy link
Copy Markdown
Member

What

Adds the time-weighted return (TWR) alongside the money-weighted return, closing #1819 (phase-1 of #1814). The two answer different questions:

  • MWR (XIRR) — "what did I earn, given when I moved money" (the personal-performance metric, already shipped).
  • TWR — "how did the investments perform, independent of my contribution timing" (the GIPS / manager-comparison metric).

Report both, MWR as the headline.

Engine (rustledger-returns)

twr(directives, scope, currency, oracle, end_date) -> Result<Option<f64>, ExtractError> — the annualized time-weighted return via the unit-value (mutual-fund NAV) method: value the portfolio at every external cash-flow date, chain the sub-period returns geometrically, annualize (actual/365, matching xirr). Each contribution/withdrawal is netted out at its date, so only market movement — and income the holdings generate (reinvested dividends, internal reshuffling, which aren't external flows) — drives the result. None when undefined (no flows, a single-day span, or a non-positive portfolio value where unit accounting breaks down — a documented MVP limitation).

Refactored terminal_value's realize-and-value loop into a shared investment_value_at primitive that both terminal_value (value at the end date) and twr (value at each flow date) use.

CLI (rledger report returns)

A Time-weighted return line in text and a time_weighted_return_pct field in json/csv, next to the money-weighted return.

The divergence, in the tests

A ledger that sits flat for the first half-year, then takes a large contribution right before a +20% second half:

Money-weighted return   27.13%
Time-weighted return    20.00%

TWR is exactly 20% — the investments returned 0% then +20%, chained, regardless of timing. MWR is higher because most of the money was present only for the winning half. That gap is the point of having both.

Tests

  • 4 engine tests: single-flow = holding-period return (10%); the timing-neutralization case above (TWR 20% while MWR is materially higher); None for no-flows and single-day spans.
  • 1 CLI integration test asserting TWR = 20.00% and MWR > 25% on the divergence fixture.

45 engine tests + 7 returns integration tests; clippy/doc/fmt clean.

Scope

True TWR via the NAV method. Still open per #1814: dividend / ex-dividend breakout, and per-group breakdown (#1820).

Closes #1819. Part of #1814.

🤖 Generated with Claude Code

Adds the time-weighted return alongside the money-weighted return (#1819,
phase-1 of #1814). MWR (XIRR) answers "what did I earn given when I moved
money"; TWR answers "how did the investments perform, independent of my
contribution timing" -- the GIPS / manager-comparison metric.

Engine (rustledger-returns):
- twr(directives, scope, currency, oracle, end_date) -> Option<f64>, the
  annualized time-weighted return via the unit-value (mutual-fund NAV)
  method: value the portfolio at every external cash-flow date, chain the
  sub-period returns geometrically, annualize (actual/365, matching xirr).
  Each contribution/withdrawal is netted out at its date, so only market
  movement -- and income the holdings generate (reinvested dividends,
  internal reshuffling) -- drives the result. Returns None when undefined
  (no flows, single-day span, or a non-positive portfolio value where unit
  accounting breaks down).
- Factored terminal_value's realize-and-value loop into a shared
  investment_value_at(directives, scope, currency, oracle, date) primitive
  that both terminal_value and twr use.

CLI (rledger report returns):
- A "Time-weighted return" line in text and a time_weighted_return_pct field
  in json/csv, next to the money-weighted return (MWR stays the headline).

Tests: 4 engine tests (single-flow = holding-period return; timing
neutralization where TWR diverges from MWR; None for no-flows and single-day)
and a CLI integration test showing TWR 20% vs a higher MWR when a large
contribution lands right before a rally.

Follow-ups still open: dividend/ex-dividend breakout, per-group breakdown
(#1820).
@robcohen
robcohen requested a review from a team as a code owner July 20, 2026 16:52
Copilot AI review requested due to automatic review settings July 20, 2026 16:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds time-weighted return (TWR) to the rustledger-returns engine and surfaces it in rledger report returns, complementing the existing money-weighted return (XIRR/MWR) so users can compare investment performance independent of contribution timing.

Changes:

  • Introduces twr(...) -> Result<Option<f64>, ExtractError> using the unit-value (NAV) method and annualizes it to match XIRR’s day-count convention.
  • Refactors end-date valuation into a shared investment_value_at(...) primitive reused by both terminal_value and twr.
  • Extends the returns report output (text/json/csv) and integration tests to include time_weighted_return_pct / “Time-weighted return”.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
crates/rustledger-returns/src/extract.rs Adds twr, factors out investment_value_at, and reworks terminal_value to use it.
crates/rustledger-returns/src/lib.rs Updates crate-level docs and re-exports twr.
crates/rustledger-returns/README.md Marks TWR as implemented and documents it in the crate feature list.
crates/rustledger/src/cmd/report_cmd/returns.rs Adds TWR computation and outputs it in text/JSON/CSV formats.
crates/rustledger/tests/report_returns_test.rs Adds a timing-divergence fixture/test and asserts JSON includes the new field.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

github-actions Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Tool Time Memory
rustledger 32.7ms ± 1.4ms 23.9 MB
beancount 904.7ms ± 15.5ms 50.4 MB
Comparison 27.7x faster 2.1x less

vs main branch (2026-07-20)

Metric main this PR Change
Time 31.6ms 32.7ms +3.5% ✅
10K transactions · 5 runs, 2 warmup · hyperfine
What do these numbers mean?
  • Time: Wall-clock time to parse and validate the ledger (± std dev)
  • Memory: Peak resident set size (RSS)
  • vs main: Compared to latest nightly benchmark on main branch

@github-actions

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 100%
Full AST Match 100%
Total Files 812
What's tested
  • Check: Exit codes and error counts match bean-check
  • BQL: Query results match bean-query
  • Files: ~800 test files downloaded from 10+ beancount repositories

Full results available in workflow artifacts

@codecov

codecov Bot commented Jul 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/rustledger/src/cmd/report_cmd/returns.rs 66.66% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Deep review of the TWR PR found two correctness regressions plus doc/test
gaps.

- Report abort (CLI): twr's MissingPrice was propagated with `?`, so a price
  gap at any intermediate cash-flow date failed the ENTIRE report, killing
  the already-computed money-weighted output too. TWR needs a price at every
  flow date (more than MWR); a missing one now degrades TWR to n/a instead of
  aborting.

- Closed-out position (engine): a position fully liquidated at the last flow
  left v_prev == 0, and the final-segment match returned None — discarding a
  well-defined holding-period TWR for the very common "what did my sold-out
  position return" case. The final segment now skips (contributes no return)
  when the position was closed, so the whole-period figure in `cumulative` is
  reported. Regression test added (buy then sell-all on the report date →
  ~10%, which also exercises the withdrawal/outflow sign path that had no
  coverage).

- Removed a `.expect("non-empty")` panic path: first_date is captured during
  the ordered loop instead of re-derived.

- Docs: the annualization now warns that sub-year spans extrapolate (a +10%
  month reads as ~+214%/yr); added a # Performance note that valuation is
  O(flow_dates x directives) with a linear single-pass left as a follow-up;
  the mid-stream-only None limitation clarified; the investment_value_at
  drift-guard comment now names the exact guarding test.

Engine 46 tests, returns integration 7 tests.
@robcohen

Copy link
Copy Markdown
Member Author

Deep review — round 1

Workflow-backed review (4 finder angles + adversarial verify) on the new TWR math. It found two genuine correctness regressions plus doc/test gaps; two findings refuted; one false positive I verified and corrected. All addressed in 6a4fabc0.

1. TWR error aborted the whole report (CONFIRMED — fixed)

TWR values the portfolio at every cash-flow date, so it needs more prices than MWR (which only needs them at transaction dates + the end). twr's MissingPrice was propagated with ?, so a price gap at any intermediate flow date failed the entire rledger report returns — killing the already-computed money-weighted output too. Fixed: a missing price now degrades TWR to n/a; the headline MWR still renders.

2. A closed-out position reported n/a instead of its return (CONFIRMED — fixed)

Buy then sell-all on the report date: the final segment saw v_prev == 0 and returned None, discarding a well-defined ~10% TWR for the very common "what did my sold-out position return" case. Fixed: the final segment now skips (adds no return) when the position was closed, so the whole-period figure already accumulated is reported. Verified end-to-end — the closed-out fixture now shows Time-weighted return 10.03% (was n/a). The regression test also exercises the withdrawal/outflow sign path, which finding 5 flagged as untested.

Refined / documented

  • Sub-year annualization (CONFIRMED) — the doc now warns that a short span extrapolates (a +10% month → ~+214%/yr; standard, but surprising).
  • Quadratic realization (CONFIRMED, perf) — labeled correctness but it's purely performance (O(flow_dates × directives); no wrong answer). Added a # Performance note and filed returns: single-pass realization for twr + shared per-group extraction (currently ~O(groups × flows × directives)) #1832 for the single-pass linear rewrite (needs a small BookingEngine borrowing iterator). Deferred rather than rush a risky rewrite of new math into this PR.
  • .expect() panic path — removed (first_date captured in the loop).

False positive (verified)

The "no drift-guard test exists" finding was incorrect: terminal_value_matches_account_balances_realization (added in #1818, on main) values account_balances' inventories and compares to terminal_value, which now delegates to investment_value_at — so the shared realization is guarded end-to-end. The finder missed it (it's a unit test in returns.rs, not the tests/ dir). Comment wording made precise.

Also refuted (agreed)

to_f64().unwrap_or(0.0) — unreachable for portfolio-value decimals.

46 engine tests + 7 returns integration tests; clippy/doc/fmt clean.

…iew)

Round-2 review found the round-1 closed-out fix was too broad: the final
segment's `Some(_) => {}` skip matched a NEGATIVE (net-short) v_prev the same
as a fully-liquidated zero, so a held short position annualized cumulative=1.0
into a fabricated 0.00% TWR instead of the documented None -- asymmetric with
the in-loop guard that returns None for a mid-stream vp <= 0.

Final segment now distinguishes: net short (vp < 0) -> None (undefined);
exactly zero (fully liquidated at the last flow) -> skip, keep cumulative.
Doc clarified to match.

Tests (48 engine): net-short single flow -> None (fails on the old skip), and
a dividend-to-bank sub-period -> 15.5% pinning the marquee TWR path (a cash
dividend paid out is credited once as return: (V + div)/V_prev), which had no
coverage.

Deferred: the CLI recomputes extract_flows + the end-date valuation once for
MWR and again inside twr; folded into the twr efficiency rework (#1832).
@robcohen

Copy link
Copy Markdown
Member Author

Deep review — round 2

Second workflow pass, auditing the round-1 fixes and going deeper. One real correctness bug (which I'd flagged as a risk when launching this round), its test gap, plus two cleanups. No re-litigation of round 1. All addressed in c994faf7.

Net-short reported 0.00% instead of n/a (CONFIRMED — fixed)

The round-1 closed-out fix was too broad: the final segment's Some(_) => {} skip matched a negative (net-short) v_prev the same as a fully-liquidated zero. A held short position therefore annualized cumulative = 1.0 into a fabricated 0.00% TWR instead of the documented None — asymmetric with the in-loop guard that returns None for a mid-stream vp <= 0.

Fixed: the final segment now distinguishes vp < 0 (net short → None, undefined) from vp == 0 (fully liquidated → skip, keep the accumulated return). Doc aligned. Test added: a single net-short flow → None (fails on the old skip).

Dividend-to-bank sub-period untested (CONFIRMED — test added)

The marquee TWR path — crediting income the investment paid out — had no test. Added one that pins the two-sub-period case: 10 AAPL flat but a $50 dividend paid out → sub-period (1000+50)/1000 = 1.05, then +10% → chained 15.5%. Confirms the dividend is credited exactly once (no double-count / mis-scale). The code was already correct; now it's guarded.

Deferred (cleanup)

The CLI computes extract_flows + the end-date valuation once for MWR and again inside twr. Distinct from the per-flow-date quadratic, but same family — folded into the twr efficiency rework (#1832, noted there).

48 engine tests + 7 returns integration tests; clippy/doc/fmt clean.

…ent 0

Copilot review: to_f64().unwrap_or(0.0) at the three TWR valuation sites
would fabricate a wrong rate (or None) on a Decimal that cannot be
represented as f64. Propagate such a conversion failure as Ok(None) (the
return is undefined) instead. Behavior is identical for real portfolio
decimals; this only hardens the impossible-conversion path against a silent
zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gJH7uBBF9Xw9et9QFRcsU
@robcohen

Copy link
Copy Markdown
Member Author

Thanks — addressed in b35a169f. The three to_f64().unwrap_or(0.0) sites (v_i, contribution, v_end) now use let Some(..) = ..to_f64() else { return Ok(None) }, so an unrepresentable valuation makes TWR n/a rather than a silently-fabricated 0. Behavior is identical for real portfolio decimals; this just hardens the impossible-conversion path. The O(flow_dates × transactions) perf note is tracked in #1832 (single-pass rewrite + CLI dedup). Resolving these threads.

@robcohen
robcohen merged commit ed06fd0 into main Jul 20, 2026
43 checks passed
@robcohen
robcohen deleted the feat/returns-twr branch July 20, 2026 20:56
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.

returns: add time-weighted return (TWR) to the engine and report

2 participants