diff --git a/AGENTS.md b/AGENTS.md index d88e1ad..7fc7fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,19 @@ Ask the user instead.** stop. You do not commit. You do not prepare a commit. You hand off the working tree and wait. +### Documentation-file conventions + +- **`TODO.md` holds only what's still open.** Git already tracks what was + done and when. Do NOT add "DONE" markers, completion status, strikethrough, + or "shipped in …" blurbs to TODO entries — just delete the section when + the work is finished. If follow-up work came out of the finished task, + add it as a new top-level section; don't leave the parent entry around + as a historical wrapper. +- **`REPORT.md` is untracked on purpose.** It's a personal workflow doc + living in the repo root only until it moves to `~/finance`. Edit it + freely when asked; don't treat it as part of the repo surface. Don't + mention it in commit messages for unrelated work. + --- ## Commands @@ -281,6 +294,8 @@ command. - **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. If not found in cwd, falls back to `$ZFIN_HOME/portfolio.srf`. `watchlist.srf` and `.env` follow the same cascade. `metadata.srf` and `accounts.srf` are loaded from the same directory as the resolved portfolio file. +- **`transaction_log.srf` is a sibling file.** Optional. Lives next to `portfolio.srf` / `accounts.srf`. Holds user-declared `transfer::` records so the contributions pipeline can tell internal account-to-account movement apart from real external contributions. Only `type::cash` is wired in v1 — `type::in_kind` parses but is rejected downstream. Missing file → matcher is a no-op. See `REPORT.md` §5 "Transfer log" for the user-facing guide. + - **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null. ## Dependencies diff --git a/TODO.md b/TODO.md index f818d96..11312c1 100644 --- a/TODO.md +++ b/TODO.md @@ -264,60 +264,45 @@ flag in `accounts.srf`. When set on an account: See `src/analytics/analysis.zig` `AccountTaxEntry.direct_indexing` and `src/commands/audit.zig` `displaySchwabSummaryRatioSuggestions`. -## Transaction log file (transaction_log.srf) — priority HIGH +## In-kind transfer support (`type::in_kind`) — priority MEDIUM -`portfolio.srf` answers "what do I have" — it's a snapshot of -state. Some events that affect contribution attribution aren't -state; they're things that happened. The biggest current gap: -account transfers get double-counted as contributions because the -receiving side's `new_*` lots count toward attribution and the -sending side's `lot_removed` is silently ignored. +`transaction_log.srf` parses `type::in_kind` records but the +contributions matcher always rejects them with "in-kind transfers +not yet supported in v1." In-kind movements need per-symbol +matching across accounts: an in-kind transfer of 100 VTI shares +from Acct A to Acct B shows up as `lot_removed` on A + `new_stock` +on B (or a `rollup_delta` share increase if B already had a VTI +lot), neither of which can be matched by the current +amount-based cash matcher. -Proposed: new `transaction_log.srf` file alongside `accounts.srf`, -scoped minimally for now: +Proposed: a second pass in `matchTransfers` that iterates +`type::in_kind` records and looks for same-symbol matches across +`lot_removed` on `from` + `new_stock`/`rollup_delta` on `to` +within the window. Gated on share-count and open_price sanity so +a partial transfer doesn't false-positive against an unrelated +edit. -``` -transfer::2026-05-02,amount:num:100000,from::Schwab Brokerage,to::Tax Loss[,note::...] -``` +Driver: when the user starts moving positions between accounts +directly (e.g. Roth conversion of already-held shares, 401k → +rollover IRA in-kind) rather than liquidating and re-buying. -Contribution pipeline reads entries in the current window and nets -them out of both endpoints' classifications so the grand total and -`compare`'s attribution both reflect only external money flow. -Audit can also surface a warning for any large `new_*` lot that -doesn't have a matching transfer record, nudging the user to -confirm real money vs. add a transfer entry. +## Audit large-lot threshold tuning — priority LOW -### Scope for first pass +`src/commands/audit.zig` uses `audit_large_lot_threshold: f64 = +10_000.0` as the cutoff for "surface this new lot for confirmation." +The value is a judgment call; revisit if: -- Only `transfer::` records (no buys/sells, no dividend logs — - those stay inferred from the portfolio diff). -- File resolution: ZFIN_HOME cascade, same as `portfolio.srf` / - `accounts.srf` / `watchlist.srf`. -- Parser + record type in `src/models/`. -- Wire into `src/commands/contributions.zig` `computeReport`: - after classification, subtract matched transfer pairs from - contribution totals (emit a new `transfer_in` / `transfer_out` - kind or similar). -- Audit surfacing of unmatched large `new_*` lots (optional; can - be a follow-up). -- Doc update: `REPORT.md` workflow and `accounts.srf` sibling - file; contributions classification matrix gets a new row. +- $10k proves too aggressive (weekly payroll ESPP accruals near + the limit spam the report), +- $10k proves too permissive (large DRIP confirmations or CD + maturities slip past uncalled), +- the user wants per-account thresholds (e.g. $25k for + high-turnover brokerage, $5k for IRAs). -### Out of scope for first pass - -- Buy/sell transaction records (still inferred from diff). -- Dividend/DRIP logs (still inferred from diff). -- Historical reconstruction (transaction log is forward-only — - existing reviews stay as-is). - -### Driver - -User has transfers expected next month. Without this fix, the -next review cycle's contribution total will be inflated by -whatever gets transferred. Related: the `direct_indexing` flag -(done) handles tracking drift on the proxy lot itself; the -transfer log handles the portfolio-total double-count. Different -problems, different fixes. +If runtime tuning becomes necessary, either a `--large-lot +` flag on `zfin audit` or a global `audit_large_lot_threshold` +field on `accounts.srf`-the-file would be reasonable extensions. +Until then the constant is the single knob. ## Torn SRF files from server sync (recurring bug) diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 0ba2e76..e493eb6 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -101,6 +101,57 @@ //! lots even though their ratio is 1.0, bridging the brokerage-vs- //! portfolio value gap that accumulates from tracking error. //! +//! ### Transfer reclassification (`transaction_log.srf`) +//! +//! Records in `transaction_log.srf` flag internal money movement +//! between accounts the user owns. When present, the matcher runs +//! after Pass 1/Pass 2 and flips destination/source Changes to +//! dedicated transfer kinds that contribute $0 to attribution — +//! fixing the double-count where a transfer's destination lot would +//! otherwise read as a fresh external contribution. +//! +//! Four reclassification kinds emerge from the matcher: +//! +//! - `transfer_in` — destination lot (or cash-dest pool) +//! fully attributed to a transfer. +//! Replaces the base `new_*` kind. +//! - `partial_transfer_in` — destination lot partially attributed. +//! Residual (`value() − transfer_attributed`) +//! still counts toward attribution as +//! "pre-existing cash that funded the +//! rest of the lot." +//! - `transfer_out` — sending-side match (negative +//! `cash_delta` or `lot_removed`). Best- +//! effort: a missing `from` side is +//! silent, not an error (the sending +//! account may not be in portfolio.srf). +//! - `unmatched_transfer` — record in the window that couldn't +//! be matched. Surfaces in the Flagged +//! section with a reason string; the +//! transfer amount stays out of +//! attribution either way. +//! +//! | Scenario | Kind | Section | In Grand Total | In Attribution | +//! |-----------------------------------------------|------------------------|------------------|:--------------:|:--------------:| +//! | Lot fully funded by transfer | `transfer_in` | Transfers | no | no | +//! | Lot partially funded by transfer | `partial_transfer_in` | New contributions (residual) + Transfers | residual | residual | +//! | Sending-side `lot_removed` / `cash_delta` | `transfer_out` | Transfers | no | no | +//! | Record with no match (bad dest, in_kind, …) | `unmatched_transfer` | Flagged | no | no | +//! +//! Cash-destination records don't flip the original `cash_delta` / +//! `new_cash` Change (a single cash delta can be drained by +//! multiple records, which `kind` can't represent). Instead the +//! matcher appends a synthetic `transfer_in` Change for the +//! Transfers section and records the attributed amount in +//! `Report.cash_attributed_by_account`, which the per-account totals +//! and attribution summary subtract from cash-side contributions. +//! +//! The matcher also composes with `direct_indexing::true`: a transfer +//! into a direct-indexing account matches normally on the destination +//! lot; subsequent tracking-error drift on that lot is still swallowed +//! by the direct-indexing tolerance. Different problems, different +//! passes. +//! //! ## Other architecture notes //! //! Relies on: portfolio.srf being tracked in a git repo, and the `git`