Skip to content

feat: investment returns report - #306

Merged
robcohen merged 1 commit into
mainfrom
feat/returns-report
Aug 23, 2026
Merged

feat: investment returns report#306
robcohen merged 1 commit into
mainfrom
feat/returns-report

Conversation

@robcohen

Copy link
Copy Markdown
Member

Closes #263 — the remaining checklist items. The component binding landed in #301; this is the view and the API layer between them.

Server

QueryShell.returns reuses the held-session cache, so a scope on a filter state the query shell has already visited costs no directive marshaling. /api/returns takes comma-separated account-name prefixes for the investment and income scopes, an optional reporting currency, and a required as-of date — the component has no clock, so the host supplies both the horizon and the terminal valuation date.

It runs against entries_with_all_prices, not the filtered entries: the terminal valuation needs every price the ledger has, not only those surviving the active filter.

Error handling is the interesting part

The engine is deliberately strict — an unpriceable flow, an unresolvable reporting currency or a booking error makes it refuse rather than report a number it can't stand behind, and its message names the ledger problem. That message is surfaced verbatim and rendered as its own state, because "no price to convert IRAUSD to the reporting currency on 2014-01-02" is the useful output. A blank table or a zero would be worse than useless.

View

Scope/currency/as-of form driving URL parameters, plus a table of invested, distributions, current value, cash flows and both rates. The decimals arrive as raw full-precision strings by design and are formatted host-side for the locale. A null rate renders "n/a", not zero — undefined is not the same as nothing.

The form inputs are deliberately not initialised from props. The route re-renders the same component instance with new props on navigation, so seeding from params would capture only the first value and leave the form showing a previous scope after back/forward; an effect syncs them instead. svelte-check flagged exactly this, which is how it was caught.

Verified

  • GET /long-example/returns/ renders 200; /api/returns answers 200 with real figures (13 cash flows, current value 8341.47, MWR −3.3%)
  • Four API tests: both metrics present; prefix lists stripped of blanks (a padded scope must equal the plain one, or a stray comma silently widens it); a bad end-date refused; an unpriceable scope refused
  • 679 passed, coverage gate 100%, mypy clean
  • All seven frontend gates: tsc, svelte-check, eslint, oxlint, stylelint, build, test

Incidental fixes

Pre-existing lint failures that blocked committing the files they were in: a TC002 in json_api.py (I verified ruff's fix is safe — the module does have from __future__ import annotations, on line 7, which my first check missed by only reading the first five lines), three over-length comments in test_json_api.py, and three in application.py. The ruff hook also auto-fixed datetime.UTC, a docstring capital and a line wrap in application.py.

One caveat

Committed with --no-verify. The prettier and stylelint hooks run bunx from the repo root, but prettier-plugin-svelte and the stylelint config live in frontend/node_modules and there's no root package.json — so both abort with "Cannot find package" on any .svelte file rather than reporting a formatting problem. Verified by hand from frontend/, where they resolve: prettier reports both .svelte files unchanged and stylelint exits 0. Worth fixing separately, since it makes every Svelte contribution need --no-verify.

Closes #263 — the remaining checklist items. The component binding landed
in #301; this is the view and the API layer between them.

## Server

`QueryShell.returns` reuses the held-session cache, so a scope on a
filter state the query shell has already visited costs no directive
marshaling. `/api/returns` takes comma-separated account-name prefixes
for the investment and income scopes, an optional reporting currency,
and a required as-of date — the component has no clock, so the host
supplies both the horizon and the terminal valuation date.

It runs against `entries_with_all_prices`, not the filtered entries: the
terminal valuation needs every price the ledger has, not only those
surviving the active filter.

## Error handling is the interesting part

The engine is deliberately strict — an unpriceable flow, an unresolvable
reporting currency or a booking error makes it refuse rather than report
a number it cannot stand behind, and its message names the ledger
problem. That message is surfaced verbatim through `RustfavaAPIError`
and rendered as its own state in the view, because "no price to convert
IRAUSD to the reporting currency on 2014-01-02" is the useful output;
a blank table or a zero would be worse than useless.

## View

Scope/currency/as-of form driving URL parameters, and a table of
invested, distributions, current value, cash flows and the two rates.
The decimal fields arrive as raw full-precision strings by design and
are formatted host-side for the locale. A null rate renders "n/a", not
zero — the metric being undefined is not the same as it being nothing.

The form inputs are deliberately NOT initialised from props. The route
re-renders the same component instance with new props on navigation, so
seeding from `params` would capture only the first value and leave the
form showing a previous scope after back/forward; an effect syncs them
instead. svelte-check flagged exactly this, which is how it was caught.

## Verified

- `GET /long-example/returns/` renders 200; `/api/returns` answers 200
  with real figures (13 cash flows, current value 8341.47, MWR -3.3%).
- Four API tests: both metrics present, prefix lists stripped of blanks
  (a padded scope must equal the plain one, or a stray comma silently
  widens the scope), a bad end-date refused, an unpriceable scope
  refused.
- 679 passed, coverage gate 100%, mypy clean.
- All seven frontend gates: tsc, svelte-check, eslint, oxlint,
  stylelint, build, test.

Two pre-existing lint failures fixed along the way, because they blocked
committing the files they were in: a TC002 in json_api.py (verified
ruff's fix is safe — the module does have `from __future__ import
annotations`, on line 7) and three over-length comments in
test_json_api.py.

The ruff hook also auto-fixed three pre-existing issues in
application.py when the file was touched (datetime.UTC over
timezone.utc, a docstring capital, one line wrap) — equivalent
transformations, included because the hook will not let the file be
committed without them.

Committed with --no-verify: the prettier and stylelint hooks run
`bunx` from the repo root, but prettier-plugin-svelte and the stylelint
config live in frontend/node_modules and there is no root package.json,
so both abort with "Cannot find package" on any .svelte file rather
than reporting a formatting problem. Verified by hand from frontend/,
where they resolve: prettier reports both .svelte files unchanged and
stylelint exits 0. ruff-check, ruff-format and every other hook pass
normally.
@robcohen
robcohen merged commit eda4988 into main Aug 23, 2026
33 checks passed
@robcohen
robcohen deleted the feat/returns-report branch August 23, 2026 04:08
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.

Investment returns view — consume session.returns (WIT 3.9.0)

1 participant