Skip to content

feat: materialized lots table for holdings tracking#792

Open
triantos wants to merge 22 commits intoafadil:mainfrom
triantos:data-model-refactor
Open

feat: materialized lots table for holdings tracking#792
triantos wants to merge 22 commits intoafadil:mainfrom
triantos:data-model-refactor

Conversation

@triantos
Copy link
Copy Markdown
Contributor

Summary

  • Adds a lots table that tracks individual tax lots (acquisitions) as first-class database rows, replacing the JSON-serialized snapshot approach for position tracking
  • Lots are computed from activities using FIFO disposal, written alongside existing snapshots for validation, then used as the authoritative source for valuations, net worth, and holdings
  • Supports TRANSACTIONS-mode accounts (lots derived from activity replay) and HOLDINGS-mode accounts (lots derived from manual snapshots)

Motivation

The existing holdings system stores positions as serialized JSON inside snapshot blobs. This makes it impossible to query individual lots for tax reporting, forces full-history replay on every quote update, and prevents incremental position updates. The lots table solves all three: lots are queryable rows, valuations become lots JOIN quotes, and activity changes update only affected lots.

Changes

  1. Schema & modellots table with migration, LotRecord Rust model, LotRepositoryTrait
  2. Shadow-write — after each recalc, lot rows are written in parallel with JSON snapshots; quantity mismatches logged at ERROR
  3. Wiring & backfill — lot repository injected into services; on first launch, existing accounts are backfilled from current snapshots
  4. Tests — unit, integration, and service tests for lot computation, FIFO closing, splits, transfers
  5. Read switchoverget_net_worth, calculate_valuation_history, holdings_from_snapshot, and build_live_holdings all read from the lots table instead of deserializing JSON snapshots
  6. Incremental maintenance — activity changes trigger targeted lot updates (insert/close) rather than full-account replay
  7. HOLDINGS-mode — manual snapshot imports create lot records so both tracking modes use the same valuation path
  8. TOTAL pseudo-account fix — TOTAL no longer gets lot rows written; its values come from query-time aggregation across real accounts

Test plan

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes (core, server, ai, storage)
  • cargo test --workspace — 1,316 tests pass, 0 failures
  • pnpm format:check passes
  • Verified on live portfolio data (multi-currency, options, bonds, metals, liabilities)

@triantos triantos force-pushed the data-model-refactor branch from 683d35c to 0e380d6 Compare April 3, 2026 18:08
triantos added 12 commits April 3, 2026 11:10
Introduces the `lots` table — the persistent, relational form of tax lots.
Each row represents one acquisition with its own cost basis, open date, and
disposal tracking. Initially empty; the holdings calculator will begin
shadow-writing lot rows alongside existing JSON snapshots once the lot
repository is wired in.

Changes:
- Migration: CREATE TABLE lots with indexes (account_id/asset_id, asset/open,
  account/open, open_activity_id)
- schema.rs: add lots table definition and joinables to accounts/assets
- crates/core/src/lots/: new LotRecord, DisposalMethod, HoldingPeriod types

No behavioral changes. The existing JSON snapshot path is untouched.
After each holdings recalculation the snapshot service writes a row to
the `lots` table for every open lot in every position, in parallel with
the existing JSON snapshot path. The JSON snapshots remain authoritative;
the lot rows exist so both representations can be compared.

Any quantity mismatch between the two is logged at ERROR severity.
The lot repository is opt-in via SnapshotService::with_lot_repository(),
so existing callers are unaffected until they wire it in.

open_activity_id is left NULL for now — transferred sub-lots use
composite IDs that don't correspond to rows in the activities table.
Both apps (Tauri desktop and server) now pass a LotsRepository to
SnapshotService, so lot rows are written on every holdings recalculation.

For existing users, the lots table starts empty. On desktop startup,
backfill_lots_if_needed() checks the row count and triggers a full
holdings recalculation if the table is empty, ensuring lot rows are
populated without any manual action.

Adds count_open_lots() to LotRepositoryTrait to support the check.
…rite

Unit tests (core::lots):
- extract_lot_records: AAPL (3 lots), LQD bond ETF (2 lots), AAPL Jun 2026
  \$200 call option (1 lot), mixed portfolio totalling all three
- check_lot_quantity_consistency: passing case and mismatch detection

Integration tests (storage-sqlite::lots):
- replace_lots_for_account: inserts, replaces, and clears correctly
- replace_only_affects_target_account: other accounts' rows are untouched

Snapshot service test:
- MockLotRepository records calls; verifies that after a full recalculation
  with AAPL (3 buys → 100 shares), LQD (2 buys → 150 shares), and one
  options position, the lot repository receives the correct quantities
…lots table

Security positions now sourced from lots JOIN assets instead of snapshot
JSON. contract_multiplier from asset.contract_multiplier(), currency
from asset.quote_ccy. Cash balances still come from snapshot.

Verified on 18-account test DB: 35 securities, quantities, cost bases,
and market values all match pre-switch baseline exactly.
Replace replace_lots_for_account (wipe+reinsert) with sync_lots_for_account
(upsert open, mark closed) so lot rows are never deleted. Fully consumed lots
are stamped with is_closed=1, close_date, and close_activity_id sourced from
the disposing activity's date and ID.

- FifoReductionResult gains fully_consumed_lot_ids to surface which lots
  drained to zero during a reduction
- HoldingsCalculator records LotClosure entries for all three reduction sites:
  handle_sell, handle_transfer_out, handle_adjustment (OPTION_EXPIRY)
- snapshot_service clears the disposed_lots cache before each run and passes
  closures to sync_lots_for_account alongside the open lots
- storage-sqlite implements sync_lots_for_account: row-by-row upsert (SQLite
  limitation) + UPDATE for each closure
- New test: sync_lots_records_closures_for_full_sell verifies a fully sold lot
  appears in closures with the correct date/activity, not in open lots
Adds two new LotRepositoryTrait methods:
- get_lots_as_of_date(account_ids, date): lots active on a given date
- get_all_lots_for_account(account_id): all lots (open + closed) for memory-side date filtering

Switches NetWorthService.get_net_worth to read security positions from
the lots table instead of snapshot.positions. Cash balances still come
from snapshots (extra state, will be removed once cash is tracked
independently of snapshots).
… lots

ValuationService.calculate_valuation_history now fetches all lots for
the account once via get_all_lots_for_account and filters per date in
memory, replacing iteration over snapshot.positions for securities.

Currency, is_alternative, and contract_multiplier are still read from
the snapshot's position entries (extra state carried until ValuationService
gains direct asset-repo access). Cash positions are unaffected.
Changes the holdings_from_snapshot signature from (&snapshot, base_currency)
to (account_id, date, base_currency). Security positions now come from
get_lots_as_of_date; cash balances still fetched from the snapshot for
that date (extra state carried until cash is tracked independently).

Also includes individual lot detail in the returned holdings (display_lots),
which was absent in the snapshot-based version.
Mirrors the Tauri backfill_lots_if_needed logic: on startup, count open
lots and if zero, run a full holdings recalculation to populate the table.
Runs non-blocking in a tokio::spawn so it doesn't delay server readiness.

Adds lots_repository to AppState so the scheduler can check the count.
Two callsites called per-account lot queries with account_id="TOTAL",
which has no rows in the lots table, causing:
  - build_live_holdings_from_snapshot: returned 0 securities, leaving
    only the aggregate cash balance (-$14.8M) as live holdings
  - calculate_valuation_history: investment_market_value=0, making the
    TOTAL daily valuation equal to the (negative) cash balance

Add get_all_open_lots() and get_all_lots() to LotRepositoryTrait and
use them in holdings_service and valuation_service respectively when
account_id == "TOTAL", so both paths aggregate lots across all accounts.
When save_manual_snapshot is called for the latest snapshot on an
account, synthesize one LotRecord per position (using snapshot date,
quantity, average cost, and total cost basis from the position) and
call replace_lots_for_account. This makes HOLDINGS-mode accounts
visible in lot-backed holdings and valuation queries.
@triantos triantos force-pushed the data-model-refactor branch from 0e380d6 to 3d15027 Compare April 3, 2026 18:15
@afadil
Copy link
Copy Markdown
Owner

afadil commented Apr 3, 2026

Blocking issues

  1. [P0] Startup lot backfill is unsafe/incomplete for existing HOLDINGS accounts
    Files: apps/server/src/scheduler.rs, apps/tauri/src/scheduler.rs, crates/core/src/portfolio/snapshot/snapshot_service.rs:268-276, crates/core/src/portfolio/snapshot/snapshot_service.rs:432-470

    Both startup backfills call recalculate_holdings_snapshots(None, SnapshotRecalcMode::Full) when the lots table is empty.

    There are two bad outcomes here:

    • For manual-only/HOLDINGS-only users, Full recalc with all_activities.is_empty() deletes snapshots for every account and returns. On first launch after this migration, those users have an empty lots table and no activities, so the backfill can erase their saved manual snapshot history instead of populating lots from it.
    • For mixed portfolios, the full recalc explicitly skips HOLDINGS-mode accounts, so only transaction accounts get lots. After that, the lots table is no longer empty, so future startup backfills stop running. Existing HOLDINGS accounts remain permanently unbackfilled unless each snapshot is manually re-saved.
  2. [P1] Historical as-of queries use the latest lot state, not the state at the requested date
    Files: crates/storage-sqlite/src/lots.rs:168-188, crates/storage-sqlite/src/lots.rs:211-256
    Affected readers: crates/core/src/portfolio/holdings/holdings_service.rs, crates/core/src/portfolio/net_worth/net_worth_service.rs, crates/core/src/portfolio/valuation/valuation_service.rs

    get_lots_as_of_date reconstructs history using only open_date and close_date, but sync_lots_for_account mutates each row in place to the latest remaining_quantity and total_cost_basis, and fully closed lots are zeroed out.

    Example: buy 10 shares on Jan 1, sell 4 on Feb 1. Querying Jan 15 should still show 10 shares, but after the sell the stored row now has remaining_quantity = 6, so the historical query returns 6 instead of 10. After a full close, dates before the close can return 0. This makes the new lots-backed historical holdings/net-worth/valuation reads incorrect.

  3. [P1] HOLDINGS-mode history keeps only the latest snapshot in the lots table
    File: crates/core/src/portfolio/snapshot/snapshot_service.rs:1774-1820

    Manual/CSV/broker snapshots only write lot rows when the saved snapshot is the latest snapshot for the account, and they do so via replace_lots_for_account, which replaces the account’s entire lot set.

    Because historical holdings/net worth now read security positions from the lots table, older HOLDINGS snapshots stop being representable as soon as a newer snapshot is saved. The same problem appears after deleting the latest snapshot: the lots table is refreshed from the new latest snapshot, so earlier snapshot dates lose their original security positions and instead show the latest remaining lot set (or zero).

  4. [P1] Incremental lot sync cannot remove deleted lots or reopen previously closed lots
    Files: crates/storage-sqlite/src/lots.rs:211-256, crates/core/src/portfolio/snapshot/snapshot_service.rs:392-405

    sync_lots_for_account only:

    • upserts the currently open lots
    • marks the listed closures as closed

    It never removes open rows that disappeared from the recalculated state, and its ON CONFLICT DO UPDATE path does not reset is_closed, close_date, or close_activity_id.

    That causes two correctness problems during SinceDate / incremental recalculation:

    • If a buy activity is deleted and a lot should disappear entirely, the stale open row is left behind.
    • If a closing sell is deleted/edited and a previously closed lot should become open again, the upsert updates quantity/cost basis but leaves is_closed = 1, so the lot remains invisible to open-lot queries.

Additional issues

  1. [P2] Web get_snapshot_by_date no longer matches desktop / previous behavior
    Files: apps/server/src/api/holdings/handlers.rs:186-199, apps/tauri/src/commands/portfolio.rs:925-947

    The previous web behavior returned an error when no snapshot existed for the requested date. The new web handler now returns 200 with an empty holdings list, while the Tauri command still returns "No snapshot found for date ...".

    This is a silent API contract change and also creates inconsistent web vs desktop behavior for the same frontend call.

  2. [P3] count_open_lots counts all rows, not open rows
    File: crates/storage-sqlite/src/lots.rs:269-278

    The method name says “open lots”, but the implementation does count_star() over the entire table with no is_closed = 0 filter.

    Current callers only use it as “is the table empty?”, so this is not immediately breaking, but the name is misleading and will cause mistakes later.

  3. [P3] Decimal parse failures are silently converted to zero in lots-backed readers
    Files: crates/core/src/portfolio/holdings/holdings_service.rs:672-681, crates/core/src/portfolio/net_worth/net_worth_service.rs:297-301, crates/core/src/portfolio/valuation/valuation_service.rs:326-327

    Multiple readers parse lot numeric fields with unwrap_or_default(). If the DB ever contains malformed decimal text, the app will silently undervalue positions instead of surfacing a corruption/error signal.

    For lots, parse failure should be treated as a data integrity problem and at minimum logged loudly rather than quietly coerced to zero.

triantos added 9 commits April 3, 2026 21:24
Add `original_quantity` field to the snapshot Lot struct, set at creation
time in add_lot, add_lot_values, and add_transferred_lots. Never modified
by reduce_lots_fifo or apply_split, so it preserves the as-acquired
quantity through all subsequent mutations.

extract_lot_records now writes original_quantity (the acquisition amount)
and remaining_quantity (the current amount) as distinct values to the lots
table. Old snapshots that predate this field deserialize original_quantity
as zero via serde(default); extract_lot_records falls back to
remaining_quantity in that case.

This is the prerequisite for historical as-of queries that replay
activities forward from each lot's original_quantity anchor.
The startup lot backfill calls recalculate_holdings_snapshots(None, Full)
which only processes TRANSACTIONS-mode accounts. HOLDINGS-mode accounts
were permanently skipped because the lots table became non-empty after
the first backfill, suppressing future runs.

Add backfill_lots_for_holdings_accounts() to SnapshotServiceTrait. It
iterates HOLDINGS-mode accounts and synthesizes lots from each account's
latest snapshot via the existing refresh_lots_from_latest_snapshot method.

Both server and Tauri schedulers now call this after the activity-replay
backfill, ensuring all account types have lot rows on first launch.
… delete

sync_lots_for_account now deletes lots for the account that weren't
produced by the current recalculation. Orphans arise when activities
are deleted (FK SET NULL) and rebuilds create new lots with new IDs.

Also adds migration to change open_activity_id FK from ON DELETE SET NULL
to ON DELETE CASCADE, so activity deletion immediately removes lots.
The migration cleans up existing orphans during the table rebuild.
When a sell activity is deleted and lots are recalculated, the lot is
re-emitted as open. The ON CONFLICT DO UPDATE clause was only updating
remaining_quantity and total_cost_basis, leaving is_closed=1 and
close_date set from the prior state. The lot remained invisible to
open-lot queries.

Add is_closed, close_date, and close_activity_id to the upsert SET
clause so that reopened lots are correctly marked as open.
The lots table stores current state (remaining_quantity is mutated in
place by sells). Historical as-of queries were returning current
quantities for past dates — buy 10, sell 4, query before the sell
returned 6 instead of 10.

Add replay_lots_to_date() in lots/mod.rs: resets each lot to
original_quantity, then replays Sell/TransferOut/Adjustment/Split
activities in chronological FIFO order up to the requested date.
7 unit tests cover partial sell, full sell, FIFO across lots, splits,
split+sell, and cross-account isolation.

Inject activity_repository into HoldingsService and NetWorthService.
Both get_lots_as_of_date call sites now fetch activities and replay.

Also fixes the SQL bug in the storage method: .assume_not_null() on
nullable close_date was dropping open lots (NULL > 'x' is NULL in SQL).
Replaced with is_not_null().and(close_date.gt(date)).
The is_latest check in save_manual_snapshot used
get_latest_snapshot_before_date(account_id, snapshot_date + 1) which
returned the just-saved snapshot itself, making the condition always
true. Historical HOLDINGS snapshots would overwrite lots from the
latest snapshot.

Replace with a check for any snapshots after the saved date. If none
exist, this is the latest and lots should be written.
…ntly coercing to zero

Multiple lot readers used unwrap_or_default() or filter_map with .ok()
to parse Decimal fields from TEXT columns. Malformed values silently
produced zero quantities or dropped lots from display with no signal.

Replace with unwrap_or_else + log::error! at all lot parse sites in
holdings_service, net_worth_service, and valuation_service. The
filter_map in lot_records_to_display_lots now uses inspect_err to log
before dropping.
The method counts all rows in the lots table (open and closed), not
just open lots. The name was misleading after is_closed was introduced.
Callers only use it to check "is the table empty" for the startup
backfill guard.
The handler was rewritten to read security positions from the lots
table, which silently returned an empty Vec when no data exists at
the requested date. Restore the original behavior of erroring on
missing data, returning 404 Not Found via ApiError::NotFound.
@triantos
Copy link
Copy Markdown
Contributor Author

triantos commented Apr 7, 2026

I've addressed all of the issues you raised. Note there were some corner case bugs in previous handling of lots which are now fixed.

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.

2 participants