transaction documentation
This commit is contained in:
parent
88de7a9882
commit
a26470f46a
3 changed files with 98 additions and 47 deletions
15
AGENTS.md
15
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
|
||||
|
|
|
|||
79
TODO.md
79
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
|
||||
<amount>` 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue