zfin/TODO.md

53 KiB
Raw Blame History

Future Work

No work here is blocking — we're in a good state. Items below are ordered roughly by priority within each section. Priority labels (HIGH / MEDIUM / LOW) mark items that deserve explicit ranking; unlabeled items are "someday, if the mood strikes."

Projections: future enhancements

  • Configurable return cap per position — priority MEDIUM. Default: none; cap outliers like NVDA. Should route through projections.srf cleanly.
  • Accumulation-mode SWR rate column is misleading — priority LOW. When retirement_age/retirement_at is configured, the "Safe Withdrawal" table's % column divides the SWR amount by the CURRENT portfolio value, not the post-accumulation portfolio value. The dollar amount is correct (it's the safe spending in retirement, given the projected accumulation), but the % rate comes out absurdly high (e.g., 22% of today's portfolio). The Accumulation phase block already shows the median portfolio at retirement, so the user can compute the real rate themselves — but the SWR table's rate column should ideally divide by the median post-accumulation value, or be suppressed when accumulation is active. Decide which.
  • Chart vertical line at retirement boundary — priority LOW. The accumulation-phase spec called this "mandatory" but it was explicitly deferred during implementation. The chart currently shows the full accumulation_years + horizon span without a visual marker for where accumulation ends and distribution begins. Easier to add to the kitty-graphics chart than the braille one.
  • Goal-seek over distribution horizon for W1 — priority LOW. Today the W1 ("set spending, find date") workflow reports the earliest retirement at each user-configured (horizon, confidence) cell. The philosophically correct version asks "when have I accumulated enough wealth that the projection shows a 95% probability of success withdrawing X per year from retirement until age-of-death?" — i.e. goal-seek across both accumulation_years AND distribution_years simultaneously, anchored to a configured age-of-death. NP-shaped search; not worth optimizing until someone wants it.
  • Per-person retirement_age — priority LOW. V1 of the accumulation-phase spec chose Option A: a single household retirement boundary derived from the oldest configured birthdate. Households where one earner retires significantly earlier than the other would benefit from per-person retirement_age fields on each type::birthdate record, with contributions stopped per-person.
  • Configurable max_accumulation_years — priority LOW. Hardcoded at 50 years. Route through projections.srf if anyone hits the cap.
  • Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
  • Multiple spending models: flat (current), decreasing (1-2% real annual decrease, Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
  • Unclassified position handling in allocation split (warn user)
  • Historical projection overlay follow-ups. The base --overlay-actuals overlay shipped (CLI tip + TUI primary surface). Open enhancements:
    • Historical metadata.srf / projections.srf for back-dated runs. Today the overlay re-runs against current classifications and assumptions; for historically faithful what-the-model-said-then output we'd check out the git-tracked versions of those files at the as-of commit and load those instead. Edge case until classifications materially drift.
    • Contribution-attribution overlay. Today's actuals line includes contributions implicitly; the bands assume modeled contributions that may or may not match reality. A "decompose actuals into market return vs contributions" annotation would clarify how much of the trajectory was the model being right vs new money arriving on schedule.
    • Mosaic mode: overlay multiple as-of starting points on one chart ("show me 1Y, 3Y, 5Y, 10Y projections all at once") so the user can see how the projection envelope tightened as data came in.
    • Better composition basis for imported-only as-of. Today the imported-only path uses today's allocations scaled by imported_liquid / today_total_liquid. That's the simplest thing that could work, but it's "today's mix back-dated" — it ignores everything we know about the historical context. Specifically: imported_values.srf already carries an expected_return field per row that the user captured at that date in their source spreadsheet. We could:
      • Use the imported expected_return as a sanity check against the simulation's per-position weighted return (warn or clamp if they diverge wildly — the spreadsheet's number reflects what the user actually saw at the time).
      • Use the imported expected_return to bias the stock/bond split inference: a higher expected return implies a higher historical equity weighting than today's mix probably reflects.
      • Reach further: derive a synthetic stock/bond split from the imported expected_return directly, treating it as a weighted average of SPY and AGG returns at that date and solving for the weights. That gives a per-imported- row composition that's locally faithful instead of one-mix-fits-all. None of these are urgent — the current "today's mix scaled" approximation is documented as such and the bands still render meaningfully — but each would tighten the historical faithfulness one notch. Pick whichever has the highest payoff vs. complexity when this gets revisited.

--export-chart follow-ups — priority LOW

V1 of --export-chart <PATH> shipped for quote and projections (default bands mode only). Several adjacent surfaces still don't have PNG export and were deferred:

  • history --export-chart. The history command renders a single-series braille chart of portfolio value over time (synthesized into Candle records and fed to format.computeBrailleChart). It doesn't share the z2d pipeline that quote (tui/chart.zig) and projections (tui/projection_chart.zig) use. To export, options:
    • A. Pipe the synthesized candles through tui/chart.zig's renderChart — but that draws Bollinger Bands and an RSI panel, both meaningless on a portfolio- value series.
    • B. Add a minimal "single-series line chart" z2d renderer (a slimmed-down projection_chart.zig without bands). ~150 lines. Same renderToSurface shape so PNG export is trivial after.
    • C. Skip it permanently; the braille chart is fine for what history is. Document as "not exportable". B is the right answer if PNG export of the history chart is ever requested.
  • projections --convergence / --return-backtest. Both render forecast-evaluation charts via tui/forecast_chart.zig. Not refactored to expose a renderToSurface seam yet — parser rejects --export-chart in those modes today. Low effort to add (mirror the tui/chart.zig pattern).
  • projections --vs <DATE>. No chart at all in this mode (text-only delta table); --export-chart rejected at parse time. Could grow a side-by-side bands comparison chart, but that's a feature of its own — not just an export plumbing job.
  • Theme overrides at export time. Today the export always uses theme.default_theme. A --theme <PATH> flag at export time would let users render with their configured theme or a presentation-friendly one. Out of scope for V1; gate when someone asks for it.
  • File format alternatives. SVG / PDF / WebP — z2d only exports PNG natively today; would need an external dependency or a pixel-buffer-to-format conversion.

zfin doctor health-check command — priority LOW

Front-door command for the file constellation and environment. Answers "is my zfin setup sane?" without making any changes.

The configuration surface has grown organically and only the README explains the file layout:

  • portfolio.srf — lots
  • metadata.srf — classifications
  • accounts.srf — tax types
  • projections.srf — retirement config
  • transaction_log.srf — internal transfers
  • imported_values.srf — back-history
  • history/*-portfolio.srf — snapshots
  • ~/.config/zfin/keys.srf — keybinds
  • ~/.config/zfin/theme.srf — colors

Plus 5 API keys, a cache directory, and an optional ZFIN_SERVER.

v1 scope (full health check)

  • File inventory + parse check. For each of the files above: does it exist, where was it resolved from (cwd vs ZFIN_HOME vs ~/.config/zfin), and does it parse cleanly. No fixes.
  • Cross-reference checks. Every account in portfolio.srf has an accounts.srf entry. Every held symbol has a metadata.srf entry (or is opted out). Every snapshot file parses as a portfolio. transaction_log.srf records reference real account names.
  • Environment audit. Which API keys are set (presence only, never the value). Cache size + symbol count. ZFIN_SERVER reachability if set (HEAD/OPTIONS, low timeout). Staleness of hand-maintained data files (T-bill rates, Shiller ie_data.csv) — same registry as data/staleness.zig.
  • Output shape. Sectioned, color-coded. Every check is OK / WARN / FAIL with a one-line context. Exit code 0 on all-OK or warnings only; non-zero only on FAIL. Suitable for CI / cron / pre-commit.

Driver

The file constellation has grown to nine files in three locations, plus five API keys, plus the cache, plus the optional server. Today only the README explains the structure. A doctor command surfaces it, catches regressions, and is the obvious place to point new users (or to point future-self after a long break).

Open question for when this is picked up

Does this replace the portfolio-hygiene portion of audit, or live alongside it? Probably alongside — audit is reconciliation against external broker exports, doctor is internal-state validation. But worth confirming the boundary before implementing to avoid duplicated checks.

Split audit.zig into per-broker reconcilers — priority LOW

src/commands/audit.zig is now 2856 lines (was 3438) after the brokerage parsers moved to per-broker files under src/brokerage/. It still bundles three logically distinct responsibilities:

  • Portfolio hygiene check (no-flag mode)
  • Fidelity positions CSV reconciler (--fidelity)
  • Schwab per-account positions CSV reconciler (--schwab) and Schwab account-summary stdin reconciler (--schwab-summary)

The brokerage parsers themselves are split per broker: src/brokerage/types.zig (shared BrokeragePosition + parseDollarAmount), src/brokerage/fidelity.zig (Fidelity CSV + option-symbol matcher), src/brokerage/schwab.zig (per-account CSV + summary paste). Adding a new broker is a one-file add next to those. What's left is splitting the reconciler (compare-portfolio-vs-brokerage) and display code in audit.zig into per-broker files that consume those parsers.

Sketch

src/commands/audit/
  mod.zig          ← thin dispatcher; current public `run()` lives here
  hygiene.zig      ← portfolio hygiene check (no-flag mode)
  fidelity.zig     ← --fidelity reconciler (uses brokerage/fidelity.zig)
  schwab.zig       ← --schwab + --schwab-summary reconcilers
  common.zig       ← shared types (Discrepancy, ReconcileResult), formatters

The hygiene check can be referenced from zfin doctor (above) without pulling in reconciler baggage.

Driver

Maintenance friction. The split makes the audit-bug investigations already in this TODO file (phantom discrepancy on freshly-added lots) easier to localize, and lets a zfin doctor command reuse hygiene without inheriting the reconciliation surface.

Pure internal refactor; no user-visible change.

Refactor: trim src/format.zig once Money / Date have absorbed their helpers — priority LOW

src/format.zig is still a ~1700-line grab-bag, but the money- and date-shaped helpers that used to live there have been moved out: money formatting now lives in src/Money.zig (with {f} / whole() / trim() / signed() / padRight(N) / padLeft(N)), and date formatting lives in src/Date.zig (with {f} / padRight(N) / padLeft(N)). What's left in format.zig is the genuinely-format-domain stuff: braille charts, return formatters, allocation notes, signed-percent rendering.

If the file ever grows enough to be annoying again, consider renaming to src/render.zig to better describe what's left, or splitting the braille chart out (it's ~600 lines on its own). Not blocking — file it as cleanup if and when it bites.

projections --vs <date> doesn't support imported-only as-of dates — priority MEDIUM

The crash that used to happen when --vs <date> resolved to an imported-only date is fixed: loadAsOfContext now branches on resolution.source and emits a graceful "no snapshot at that date" error instead of panicking with FileNotFound. But the feature itself is still missing - back-dating a --vs comparison to a date that's only covered by imported_values.srf (no real snapshot) is rejected outright.

The runBands path (projections --as-of <imported_date>) handles the imported-only case by loading today's portfolio composition + scaling to the imported liquid total, then calling view.loadProjectionContextFromImported. loadAsOfContext needs the same plumbing - but as outparams, since the caller (computeKeyComparison) needs to own live_loaded and live_pf_data for the lifetime of the returned context.

Two implementation shapes:

A. Add outparams to loadAsOfContext. New live_loaded_out: *?cli.LoadedPortfolio and live_pf_data_out: *?cli.PortfolioData parameters. Caller declares them and defers their deinit. ~30 lines, but duplicates the imported-only loading code (already lives in runBands's else branch around line 392-429 of src/commands/projections.zig).

B. Extract a shared helper. Pull the snapshot-vs-imported branching from both runBands and loadAsOfContext into one loadProjectionContextForResolution that returns a discriminated union (snapshot ctx with snap_bundle owned, or imported ctx with live_loaded + live_pf_data owned). Both call sites use it. ~60 lines but eliminates the duplication that AGENTS.md warns about (the two paths drifting causes compare --projections to disagree with standalone projections --as-of).

Recommendation: B. The duplication risk is real - the computeKeyComparison doc-block already calls out that "if you change inputs to either of these loaders, change them in BOTH places." Adding a third copy of the imported-only loader code makes that worse.

Audit: manual-check accounts mechanism — priority HIGH

Some accounts/positions can't be reconciled from broker CSVs and need a human-in-the-loop reminder at the audit step. Two recurring shapes:

  • No-CSV-export accounts (e.g. some insurance / annuity products) where values only live in periodic statements. Git can't detect a "change" because nothing changes locally; the user has to log in to see the new value.
  • Payroll-deduction-then-purchase accounts (e.g. ESPP) where payroll-deducted cash doesn't appear in the broker positions CSV until the purchase date hits (typically every 6 months). Between purchases the cash is a real contribution that zfin audit can't see.

The existing update_cadence::weekly|monthly|quarterly|none field already sort-of covers this, but has two gaps:

  1. It fires off the last git-detected change, not the last human review. For statement-only accounts, the value sometimes hasn't changed in months — so git never fires, cadence never trips.
  2. Payroll-deduction accounts need weekly-ish attention while accumulating cash between purchases, but the accrued balance is invisible to the CSV audit.

Drift symptom seen in practice: several accounts on update_cadence::weekly in accounts.srf weren't flagged as overdue despite no changes in two weeks, because the cadence reads git-detected change time rather than human-review time. The cadence values themselves may also be wrong for these accounts — revisit whether weekly is the right cadence vs. monthly/quarterly given how rarely they actually change.

Options

A. New update_cadence::manual variant — always fires every audit run until silenced. Blunt but zero design work.

B. last_refreshed::YYYY-MM-DD field on accounts.srf — explicit human-review timestamp, decoupled from git-detected changes. Audit compares today - last_refreshed against the cadence. User bumps the field when they check the statement. Probably the most correct fit for statement-only accounts.

C. Sticky TODO list — a todos.srf or todo:: field on accounts that audit always surfaces until cleared. General-purpose; also covers "remember to rebalance on 5/15".

ESPP-style accrual follow-through

Payroll-deduction accounts are also a contribution-attribution blind spot. If a paystub deducts $X/week but the cash lot doesn't reach portfolio.srf until the purchase date, the attribution math is under-counting contributions and over-counting the purchase-week gain. Possible fixes are discussed in the "Contributions diff" TODO below — option C there (per-account cash_is_contribution) would make manually-entered ESPP-style cash additions count correctly.

Audit: stale manual prices section is incorrect — priority HIGH

The Stale manual prices section in zfin audit (in src/commands/audit.zig around line 1333) isn't computing the right thing. The current logic walks portfolio.lots, filters to lots with both price and price_date set, and flags any whose as_of.days - price_date.days > stale_days. In practice this either over-flags (counting lots that aren't really manually-priced), under-flags (missing lots that ARE manually priced but lack price_date), or both — needs investigation against a real portfolio to determine which.

Things to check:

  • Are we using the right field to identify "manually priced"? The Lot.price field is set for any non-API price (manual override, illiquid valuation, CD face, etc.); some of those shouldn't be in a "stale prices" check (e.g. CDs with a fixed face value aren't stale by age).
  • Should the staleness comparison use Allocation.is_manual_price (computed at the position level after the price-resolution cascade) instead of the per-lot field? That captures "the price this position is currently displaying came from a manual source," which is what the user actually cares about.
  • price_date falsely-null lots: if a lot has price set but no price_date, we silently skip it instead of flagging it. That's almost certainly wrong — a manually-priced lot with no recorded date is the most stale case, not the least.
  • Per-symbol vs per-lot: if the same symbol appears in multiple lots with the same manual price, we currently emit one line per lot. Probably wants to be one line per symbol with a count, or at least dedup by (symbol, price, price_date).

Fix should land with regression tests against a fixture portfolio that exhibits each of the above shapes.

Investigate: detailed 401(k) contributions data source

Found a more detailed contributions screen on at least one employer-sponsored 401(k) provider portal — distinct from the standard positions/holdings view we already pull from. Worth investigating whether this unlocks better attribution than what we get from the positions CSV alone, and whether other 401(k) providers expose similar screens.

Open questions to answer when picking this up:

  • Which screen specifically (path / URL within the portal)? Is there an export option, or is it view-only / scrape territory?
  • What fields does it expose (employee pre-tax, employer match, after-tax / mega-backdoor, by-pay-period dates, per-fund allocations)?
  • Refresh cadence — per-paycheck, daily, on-demand?
  • Can it be auto-discovered like the existing audit CSVs, or is it manual-entry territory?

If the export is structured and recurring, this could feed a 401(k)-specific contributions classifier that bypasses the lot-diff heuristic for that account, similar to how cash_is_contribution opts ESPP/HSA accounts into cash-based attribution.

Related: ESPP-style accrual blind spot in the "Audit: manual-check accounts mechanism" section above.

In-kind transfer support (type::in_kind) — priority MEDIUM

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: 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.

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.

Torn SRF files from server sync (root cause unknown)

Status: Root cause still unidentified. We have mitigations and diagnostics in place that keep torn responses from corrupting the cache, but we don't yet know why responses arrive torn. Until we have a root cause, this is not resolved — it's mitigated.

Mitigations landed so far:

  • syncFromServer (src/service.zig) validates responses via cache.Store.looksCompleteSrf before writeRaw. Torn HTTP bodies (empty, missing #!srfv1 header, or no trailing newline) are rejected with a warn-level log and NOT written to cache.
  • HTTP responses are checked for an ETag sha256 header; on mismatch we retry the request once before giving up and falling back to the provider.
  • Read-path self-heal: on SRF parse failure during read, the cache entry is invalidated so a subsequent refresh can repair without user intervention.
  • Diagnostics: richer error capture around the sync path. So far, HTTP transit is the dominant source of torn responses — but that's an observation, not a root cause.

Remaining work:

  • Identify root cause. Candidates to investigate: proxy/load-balancer behavior, HTTP keepalive reuse, partial reads on the server side, client-side buffer handling. The etag retry tells us whether the problem is per-request or persistent; dig into the diagnostics output when the next occurrence is captured.
  • Once root cause is known, decide whether the current mitigations are sufficient or whether a targeted fix is needed. The mitigations may end up being the whole answer, but we can't conclude that without understanding the underlying cause.

(Content-Length validation was considered and rejected: once the server starts compressing response bodies, Content-Length reflects the compressed byte count, not the decoded payload, so it's not a reliable integrity check.)

Market-aware cache TTL for daily candles

Daily candle TTL is currently 23h45m, but candle data only becomes meaningful after the market close. Investigate keying the cache freshness to ~4:30 PM Eastern rather than a rolling window. This would avoid unnecessary refetches during the trading day and ensure a fetch shortly after close gets fresh data. Probably alleviated by the cron job approach.

Cache TTL semantics on merge writes — priority LOW

The writeMerged primitive in cache/store.zig rewrites dividends.srf / splits.srf with expires = now + ttl whenever it adds a new record or upgrades fields on an existing one. This is conceptually wrong: TTL should reflect "when do we expect new information from the primary provider?", which is a property of the conversation with that provider — not of the file's last-modification time. Adding a 25-year-old historical dividend that Tiingo just supplied tells us nothing about Polygon's freshness; we shouldn't bump the file's expiry as a side effect.

The cleaner design:

  • Cache file's #!expires= reflects "when did Polygon (the primary) last say here's everything I have?"
  • Tiingo merge writes preserve the existing expires, only rewriting records.
  • Only fetchCached's post-Polygon-fetch write bumps expires.

In practice the current behavior caused exactly one observable problem: a one-time TTL herd on 2026-06-04 when the new merge code's first run added pre-2010 Tiingo backfill across 23+ symbols in a single overnight burst, and they all inherited that day's clock for expires = now + 14d. We manually re-staggered (stagger_cache_ttls.py) and moved on.

Steady-state risk: minimal. The merge primitive's "skip if nothing changed" branch means no-op refreshes don't bump expires. New entries from genuinely new dividends are spread across the calendar by the dividends themselves (quarterly cadence varies per ticker). Field upgrades stop firing once Polygon's metadata is in place.

When this could matter again:

  • Adding a third source for div/splits (TTL semantics get murkier).
  • Wiping and rebuilding the server cache (one-time herd recurs).
  • A long pause in nightly refreshes followed by a backlog of merge writes.

Fix would be small: thread ?expires_override into writeMerged and have the merge path call serializeWithMeta with the existing expires (from the read) when source_hint isn't the primary.

On-demand server-side fetch for new symbols

Currently the server's SRF endpoints (/candles, /dividends, etc.) are pure cache reads — they 404 if the data isn't already on disk. New symbols only get populated when added to the portfolio and picked up by the next cron refresh.

Consider: on a cache miss, instead of blocking the HTTP response with a multi-second provider fetch, kick off an async background fetch (or just auto-add the symbol to the portfolio) and return 404 as usual. The next request — or the next cron run — would then have the data. This gives "instant-ish gratification" for new symbols without the downsides of synchronous fetch-on-miss (latency, rate limit contention, unbounded cache growth from arbitrary tickers).

Note that this process doesn't do anything to eliminate all the API keys that are necessary for a fully functioning system. A more aggressive view would be to treat ZFIN_SERVER as a 100% source of record, but that would introduce some opacity to the process as we wait for candles (for example) to populate. This could be solved on the server by spawning a thread to fetch the data, then returning 202 Accepted, which could then be polled client side. Maybe this is a better long term approach?

Audit: em-dash sentinel usage across all tables — priority LOW

The codebase uses (em-dash) as the canonical "no data" sentinel in several table cells, but the rendering rules and alignment choices are inconsistent. AGENTS.md now warns against em-dash overuse generally; this audit is the second half — pick a consistent treatment and apply it everywhere.

Known em-dash sites:

  • src/views/projections.zig (back-test): hard-coded dash_cell literal in 10-col cells — pre-shaped at compile time so no helper is involved. Numeric cells use Zig's {s:>10} byte- padding (safe since they're pure ASCII).
  • src/commands/history.zig / src/tui/history_tab.zig: centered via fmt.centerDash in 31-col cells (illiquid totals on imported-only history rows).
  • src/commands/milestones.zig: right-padded via fmt.padRightToCols in the "days since prev" cell. Mixes with ASCII cells like "42 days".
  • src/commands/perf.zig / src/tui/performance_tab.zig: emitted via {s:>13} byte-padding — under-padded by 2 cols per em-dash. Either hard-code a dash_cell literal (cell width is static) or migrate to fmt.centerDash / fmt.padRightToCols.

Decisions to make:

  1. Centered vs right-aligned in numeric columns. Back-test centers; perf right-aligns (or would, if it weren't broken). Centering reads as a more deliberate sentinel; right-aligning keeps the visual right-edge of the column smooth. Pick one.
  2. Should some tables drop the em-dash entirely in favor of ASCII -? Rule of thumb: if the column header makes the meaning unambiguous AND no rows contain bare - for other reasons (signed values use -2.21% which is multi-char, so a lone - is unambiguous), - is fine. If the column also carries dates or strings where a stray - could read as part of the value, keep .
  3. Helper vs literal. When the cell width is fixed and the dash position is static, a hard-coded literal const string (like back-test's dash_cell) is simpler than calling a helper at runtime. Use helpers when width or position varies.

Once decisions are made, sweep all four sites + add a regression alignment test per table that mixes a fully-populated row with an em-dash-heavy row and verifies displayCols matches.

TUI: numeric keypad input not handled — priority LOW

Numeric-keypad keys (Num0-Num9, decimal point, minus) don't reach the modal text-input handlers. Reproduced on the projections tab as-of date prompt (d): typing the date with the numpad produces no input, while the digit keys on the main keyboard row work fine. Affects every modal that takes numeric input — symbol-input is unaffected because it's letters.

Likely cause: the modal handlers route through vaxis.Key.codepoint matching, but vaxis emits keypad keys with a distinct keycode (kitty keyboard protocol) rather than the codepoint of the equivalent ASCII digit. The fix is in the modal key paths (handleDateInputKey in src/tui/projections_tab.zig and the symbol-input handler in src/tui.zig — though that one is letters-only in practice) and possibly the shared input_buffer.zig if that's where character gathering lives. Worth surveying both files plus any other tab that will grow numeric input (the CLI options command's near-the-money strike count would be a candidate if migrated to a modal).

Verification: open the TUI, press d on projections, try to type a date with the keypad. Then try the keyboard row. Both should commit identical input.

TUI: memory leaks somewhere — priority MEDIUM

User reported leaks while doing a detailed TUI walkthrough; no specific tab or interaction yet identified. The TUI uses a mix of arena allocators (frame-scoped) and persistent tab state, so likely culprits:

  • Per-tab State structs that hold []const u8 slices duped from a long-lived allocator but not freed when the tab reloads or the symbol changes.
  • Cached service-fetch results stored in tab state that aren't result.deinit()-ed before being replaced.
  • ArrayList accumulators that get appended to across multiple draw cycles without an intervening clear.
  • Vaxis event/dialog closures that capture strings into arena-allocated lambdas but escape the arena's lifetime.

Investigation plan

  1. Run the TUI under std.testing.allocator (a debug allocator that panics on leak). The current binary uses a gpa, which silently tolerates leaks. A test-mode TUI run with a leak-detecting allocator would surface the offending alloc sites with file/line info.
  2. Walk each tab's State.deinit (and tab.deactivate / tab.reload hooks) against the State field list — every owned field needs a free path on every state-change boundary.
  3. Pay specific attention to classification_map and any per-symbol caches (option chains, candle snapshots) — those are the biggest fixed-size strings.

No reproducer yet. When the user has a more specific lead (which tab, which interaction), this entry should narrow.

CLI dispatch / arg-parsing bugs (found May 2026)

Found during a post-framework-refactor sanity check of all 20 commands plus interactive. The framework dispatch itself is working correctly; these are gaps in command-level behavior or in the global --refresh-data flag's coverage.

zfin interactive --default-keys/--default-theme swallow trailing flags — priority LOW

zfin interactive --default-keys --bogus-flag is silently accepted: --default-keys prints its output and returns from tui.run before the rest of the args are parsed. The flag parser itself now rejects unknown flags (error.InvalidArgs

  • exit 1), but only when control reaches the parsing loop — which it doesn't if --default-keys or --default-theme short-circuits first.

Fix: validate the entire arg list before honoring the print-and-exit flags, or restructure so the parser runs first and the print-and-exit flags fire from inside the loop after all args have been validated.

etf <SYMBOL> warns failed to serialize ETF profile: WriteFailed — priority LOW

Every zfin etf VTI invocation prints warning(cache): VTI: failed to serialize ETF profile: WriteFailed to stderr before the foreground output. The ETF profile renders correctly; just the cache write fails.

src/cache/store.zig:209 calls serializeEtfProfile which fails on the aw.writer.print(...) call inside it (line 1007). Likely a Zig 0.16 stdlib quirk in the SRF writer path or a missing flush() somewhere in the writer chain.

Investigate by replacing the print with a manual print + flush to see if it's a buffer-not-flushed issue, or by serializing a known-good fixture in isolation.

Analysis: dividend equity / income-shaped equity — think about it

Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.) bucket as Equity in analysis.bucketSector. That's correct for risk-exposure analysis — they drop with the market in a 2008-style crash, regardless of the dividend stream — but it loses the income-vs-growth distinction that retirement-planning tools care about.

Open question: is there a useful second dimension to add? Possibilities:

  • Yield-weighted breakdown. Aggregate current_yield per position, weight by market value, report a portfolio-level yield. Doesn't change the asset-class taxonomy; adds a new metric.
  • Income coverage of expenses. "My dividends + bond coupons cover X% of projected retirement spending." Closer to what the income-side framing actually wants — answers the question rather than redefining the buckets.
  • Income-equity sub-bucket within Equity. A sub-row in the Asset Category breakdown, not a 5th top-level bucket. Would need a way to mark funds as "income-shaped" — probably a per-symbol opt-in in metadata.srf.

Not a bug. Not blocking anything. Could end up being a feature. This is a note to revisit after using the 4-bucket view for a while and seeing whether the missing dimension actually matters in practice.

Resist the temptation to:

  • Add a 5th top-level bucket ("Income Equity" / "Dividend Equity"). The 4-bucket view is already the right answer for "how much equity exposure do I have?". A 5th bucket fragments the headline number.
  • Override SCHD to Fixed Income. Wrong on risk grounds. SCHD will lose 35-45% in an equity crash; treating it as FI makes the user think they have downside protection they don't.
  • Add per-symbol "intent" metadata (held_for_income::true). Smell of putting framing into data. Intent is a property of the holder's strategy, not the security.

If a fix lands, it's probably a separate analysis section (yield breakdown, income coverage) — not a change to the asset-class taxonomy.

Enrich: title-keyword classification inference for ETFs — priority MEDIUM

When Wikidata returns no entry for a fund symbol and we fall through to the EDGAR ticker-map fallback, the auto-emitted metadata line carries a generic sector::Equity / Corporate, geo::US,asset_class::Fund triple. That's mechanically correct (NPORT-P really does say "this fund holds equity in corporate issuers, US-domiciled fund") but loses information the user actually cares about: sector-themed ETFs (XLV → Healthcare), geo-themed ETFs (FRDM → Emerging Markets, IDMO/HFXI/IVLU → International Developed).

The fund's title often carries the answer unambiguously. We already plumb series_name (NPORT-P <seriesName>, falling back to the company-tickers title) through to emitMissingClassification. Add a keyword-inference pass that overrides the default sector and geo when the title contains unambiguous keywords.

Sector inference

Trigger: when the lookup is .managed_fund or .company_or_uit AND the fund has a single dominant Equity / Corporate sector (>95% of holdings), AND the title carries one of the keywords below. Emit a single GICS-tagged line in place of the NPORT-P breakdown.

Conservative keyword set (matches one GICS sector unambiguously):

  • "Health Care" / "Healthcare" → Healthcare
  • "Semiconductor" → Technology (not "Technology" alone — too generic)
  • "Software" → Technology
  • "Financial" → Financial Services (careful: "Financial Select Sector SPDR")
  • "Energy" → Energy
  • "Oil & Gas" / "Oil and Gas" → Energy
  • "Real Estate" / "REIT" → Real Estate
  • "Utilities" → Utilities
  • "Consumer Discretionary" → Consumer Cyclical
  • "Consumer Staples" → Consumer Defensive
  • "Industrial" → Industrials (careful: "Industrial Materials" — match whole phrase)
  • "Materials" → Basic Materials
  • "Communication" / "Telecom" → Communication Services

Reuse Wikidata.canonicalizeSector's sector constants so the two taxonomies don't drift.

The "single dominant Equity / Corporate" guard prevents the inference from misclassifying multi-asset funds (FAGIX-shape: Debt + Equity + Loan), pure-debt funds (VBTLX), or sector-fund edge cases like a hypothetical "Vanguard Healthcare Income Fund" (if the breakdown is multi-sleeve, leave the NPORT-P decomposition alone).

Geo inference

Trigger: when the lookup is .managed_fund or .company_or_uit AND the title carries an unambiguous geo keyword. Override the default geo::US to the inferred bucket.

This one is more important than sector inference because the default is factually wrong for international/emerging funds, not just imprecise. FRDM holds Taiwanese, South Korean, Chilean, Polish equities; tagging it geo::US overstates US exposure and understates EM exposure proportionally to the fund's weight in the portfolio.

Conservative keyword set:

  • "Emerging Markets" / "Emerging Market" → Emerging Markets
  • "Frontier Markets" → Emerging Markets (or own bucket if added)
  • "International Developed" → International Developed
  • "International" / "Intl" / "Intl." → International Developed (careful: only when not paired with "+ US" or similar mixing modifier)
  • (Skip country-specific keywords for now — "China" / "Japan" / "Europe" are unambiguous but we'd be designing per-country buckets that don't exist in the current taxonomy)

False-positive risk for "International": fund names like "Vanguard Total International + US Equity Index" would mis-tag as International. Audit your portfolio's titles before locking in the keyword. The conservative version may need to be "International" only when the title contains no US-related keyword, or might need explicit phrase matching.

Tests

  • inferSectorFromTitle("State Street(R) Health Care Select Sector SPDR(R) ETF") → "Healthcare"
  • inferSectorFromTitle("iShares Semiconductor ETF") → "Technology"
  • inferSectorFromTitle("Schwab U.S. Dividend Equity ETF") → null (broad-market, no sector word)
  • inferSectorFromTitle("Vanguard Total Bond Market Index Fund") → null
  • inferGeoFromTitle("Freedom 100 Emerging Markets ETF") → "Emerging Markets"
  • inferGeoFromTitle("iShares MSCI Intl Value Factor ETF") → "International Developed"
  • inferGeoFromTitle("Schwab U.S. Dividend Equity ETF") → null
  • Plus integration tests against emitMissingClassification confirming the override only fires when the dominant-sector / single-geo guards are satisfied.

User's portfolio coverage

After this work, the funds in the user's metadata.srf that currently need hand-editing for sector/geo would be auto-tagged:

  • Sector: XLV (Healthcare), SOXX (Technology), QTUM (Technology — "Quantum" is borderline; might require explicit add)
  • Geo: FRDM (Emerging Markets), IDMO (International Developed), HFXI (International Developed), IVLU (International Developed)

Analysis: collapse fine-grained NPORT-P sector strings at display time — priority MEDIUM

The Sector (Equities) section in analysis output currently shows raw NPORT-P sector strings. For a portfolio with multi-asset funds (FAGIX, VBTLX, PTY) this means six different "Debt / *" rows (Debt / Corporate, Debt / US Treasury, Debt / Municipal, Debt / Non-US Sovereign, Debt / US Gov Agency, Debt / US GSE), three "Asset-Backed / *" rows, three "Derivative / *" rows, etc. — too granular to scan.

The user's framing: "sometimes I'd be interested in 'roll up all my debt investments to a single bucket', sometimes I'd want to see split between federal government, munis and corporate." That argues for multiple display granularity levels with a TUI hot-key to toggle, not a one-time collapse decision.

Design

Three display granularity tiers:

  1. Coarse (4 buckets): Equity / Fixed Income / Cash / Other. Already implemented as the Asset Category section. Could be a granularity option for the Sector section too.
  2. Mid (~12-16 buckets): collapses NPORT-P sub-flavors but keeps GICS sectors distinct. Roughly:
    • "Bonds" (collapses all Debt / * + Loan / *)
    • "Asset-Backed Securities" (collapses all Asset-Backed / *)
    • "Cash & Equivalents" (collapses STIV variants + Repurchase Agreement)
    • "Equity / Corporate" (the dominant equity bucket)
    • "Equity / Other" (small equity sleeves)
    • "Derivatives & Other" (collapses Derivative / Derivative-FX / Direct Real Property / etc.)
    • The 11 GICS sectors (Technology, Healthcare, etc.) for stock-level entries
  3. Fine (current behavior): raw NPORT-P strings — Debt / US Treasury vs Debt / Municipal vs Debt / Non-US Sovereign, etc.

User toggles between tiers. Default: probably Mid.

Implementation

Build a pure mapping function collapseSector(sector, granularity) []const u8 parallel to bucketSector. Display layer chooses granularity. Aggregation can either:

  • (a) Run all three aggregations every time and pick at display. Memory cost ~3x for the sector breakdown but the data is small (dozens of rows).
  • (b) Re-aggregate when granularity changes. Cheaper memory, costs a single pass over the classifications on toggle. TUI toggle latency is fine — it's a hashmap rebuild over <50 rows.

Option (b) is probably right for the TUI. CLI can pick one granularity at command-line time (default Mid; --sector-detail fine|mid|coarse to override).

Dependency

Lands AFTER the title-keyword inference work above, so the collapse logic is designed against the post-inference content shape (where XLV is Healthcare rather than Equity / Corporate, FRDM is Equity / Corporate + geo::Emerging Markets, etc.) rather than today's pre-inference shape.

TUI integration

Hot-key cycles between coarse / mid / fine on the analysis tab. Status bar shows current granularity. State persists across re-renders within a session; no need to persist across sessions.

Tests

  • collapseSector("Debt / US Treasury", .mid) → "Bonds"
  • collapseSector("Debt / US Treasury", .fine) → "Debt / US Treasury"
  • collapseSector("Debt / US Treasury", .coarse) → "Fixed Income" (delegates to bucketSector)
  • collapseSector("Technology", .mid) → "Technology" (GICS sectors stay distinct at mid)
  • collapseSector("Technology", .coarse) → "Equity"
  • TUI hot-key cycles through three granularities and updates display.

Analysis: umbrella-insurance exposure indicator — priority MEDIUM

In the analysis command and TUI tab, surface how much of the liquid portfolio is exposed to lawsuit / creditor risk and therefore should be covered by umbrella insurance. The number the user actually wants is "how much could be lost if I'm sued and lose" — which is liquid assets minus the legally-shielded buckets.

Shielding rules (US, broad strokes)

  • 401(k) and other ERISA-qualified employer plans: shielded by ERISA, federal-law-level protection. Effectively untouchable in lawsuits. Always exclude from the umbrella-coverage figure.
  • IRAs (traditional + Roth): protection is state-by-state.
    • Federal bankruptcy protection up to ~$1.5M (BAPCPA, indexed) applies in bankruptcy court only.
    • Outside bankruptcy (i.e., civil judgments), it's state law. Some states give full protection (e.g., TX, FL); some give none; many give partial / "reasonably necessary for support."
  • Sample Inherited IRAs: a separate category. Less protected than contributory IRAs in many states post-Clark v. Rameker (2014). Ideally tracked separately if relevant.
  • HSAs: typically protected if held in an HSA-qualified account; varies by state.
  • Pensions / annuities: usually protected, varies.
  • Trusts: depends on trust type; generally outside the scope of this TODO unless we get clean trust metadata.
  • Brokerage / taxable: never shielded. Always counts as exposed.
  • Cash in personal bank accounts: never shielded.

Implementation options

Two extremes:

  1. Fully automatic — infer shielding from account_type in accounts.srf (e.g., 401k, roth_ira, traditional_ira) plus a static state-default table. Risk: state-by-state IRA law is genuinely complex, and a wrong default could produce a misleading number. The user would need to KNOW the table's assumptions.
  2. Fully manual — add a shielded boolean (or shielded_fraction: 0.01.0) to each account in accounts.srf. User decides per account. Most accurate, more upfront work.
  • Add an account_type field if not already present (the metadata for tax-treatment is likely already there for contributions tracking). Map types to a default shielding:
    • 401k, 403b, 457, pensionshielded = true
    • roth_401k, roth_403bshielded = true
    • traditional_ira, roth_ira, sep_ira, simple_irashielded = depends_on_state (unknown without state info)
    • hsashielded = true (with caveat)
    • brokerage, bank, joint, trustshielded = false
  • Add an optional per-account shielded override in accounts.srf (e.g. shielded::true / shielded::false / shielded::partial) that wins over the default. This is the escape hatch for "my state of residence treats IRAs as shielded" or "this trust isn't protected."
  • Add a state field at the user level (perhaps metadata.srf or a config), with a built-in lookup table of state IRA protection (full, partial, none) that populates the default for IRA-type accounts when no override is set.

Output

In the analysis tab / command, add a section like:

Umbrella exposure
  Total liquid value:                    $X,XXX,XXX
  Shielded (401k/ERISA):                 $XXX,XXX
  Shielded (IRA, NY default = partial):  $XXX,XXX (per state default)
  Shielded (override):                   $XXX,XXX
  Net exposure:                          $X,XXX,XXX  ← umbrella target

Show it both as an absolute dollar amount and as a percent of liquid total. Include the assumption text (state, default for that state) inline so the user knows what the number means and when it might be wrong.

Tests

  • Default shielding for each account_type.
  • Override wins over default.
  • State table consistency (no missing states).
  • "I don't know my state" path should refuse to guess for IRAs and instead report the IRA-shielded portion as unknown, forcing the user to choose between "treat as shielded" / "treat as exposed" via override.
  • Sample Inherited IRAs flagged separately if we track that.

Open questions

  • Where should the state field live — metadata.srf (per portfolio file) or a global user config (one user, one state)?
  • Should we surface a "needed umbrella amount = net_exposure + [home equity, vehicles]" figure, or strictly stay liquid? The user asked about liquid assets specifically, so default to that but leave a note.
  • How to handle joint accounts where one spouse's lawsuit could reach the other's share. State-specific (community property vs separate property states). Probably out of scope for v1.

The following items are acknowledged but not prioritized. Listed here so they don't get lost; pick up opportunistically.

UX

  • CLI options command UX. The options command auto-expands only the nearest monthly expiration and lists others collapsed. Reconsider the interaction model — e.g. allow specifying an expiration date, showing all monthlies expanded by default, or filtering by strategy (covered calls, spreads).
  • TUI: toggle to last symbol keybind. A single-key toggle that flips between the current symbol and the previously selected one (like cd - in bash or Ctrl+^ in vim). Store last_symbol on App; on symbol change, stash the previous. Useful for eyeball-comparing performance/risk data between two symbols.

Data quality

  • Fix enrich command for international funds. deriveMetadata in src/commands/enrich.zig misclassifies international ETFs:

    1. geo uses Alpha Vantage's Country field, which is the fund issuer's domicile (USA for all US-listed ETFs), not the fund's investment geography. Every US-domiciled international fund gets geo::US.
    2. asset_class short-circuits to "ETF" when asset_type == "ETF", or falls through to a US-market-cap heuristic that always produces "US Large Cap" / "US Mid Cap" / "US Small Cap".

    Known misclassified tickers (all came back as geo::US, asset_class::US Large Cap):

    • FRDM — Freedom 100 Emerging Markets ETF → should be geo::Emerging Markets, asset_class::Emerging Markets
    • HFXI — NYLI FTSE International Equity Currency Neutral ETF → should be geo::International Developed, asset_class::International Developed
    • IDMO — Invesco S&P International Developed Momentum ETF → should be geo::International Developed, asset_class::International Developed
    • IVLU — iShares MSCI International Developed Value Factor ETF → should be geo::International Developed, asset_class::International Developed

    The Alpha Vantage OVERVIEW endpoint doesn't provide fund geography data. Options: use the ETF_PROFILE holdings/country data to infer geography, parse the fund name for keywords ("International", "Emerging", "ex-US"), or accept that enrich is a scaffold and emit a # TODO comment for ETFs instead of silently misclassifying.

Options / valuation

  • Per-account covered call adjustment. adjustForCoveredCalls in valuation.zig operates on portfolio-wide aggregated allocations. It matches sold calls against total underlying shares across all accounts. This is wrong — calls in one account can only cover shares in that same account. Fixing means restructuring portfolioSummary, since Allocation is currently account-agnostic. Low priority — naked calls are rare, and calls are typically in the same account as the underlying.
  • Covered call adjustment O(N*M) loop. adjustForCoveredCalls has a nested loop — for each allocation, it iterates all lots to find matching option contracts. Fine for personal portfolios (<1000 lots). Pre-indexing options by underlying would help if someone had a very large options-heavy portfolio.

Analysis / correctness

  • Analysis account/asset-class total mismatch. The "By Account" and "By Tax Type" sections in the analysis command sum to slightly more than "Asset Class" (~0.6% error). Likely a discrepancy between how the lot-level account loop values cash, CDs, or options vs how the asset-class section computes them via portfolio.totalCash() / totalCdFaceValue(). Per-account values themselves are correct after the price_ratio fix.

Audit

  • Audit large-lot threshold tuning. src/commands/audit.zig uses audit_large_lot_threshold: f64 = 10_000.0 as the cutoff for "surface this new lot for confirmation." Revisit if $10k proves too aggressive (ESPP accruals spam the report) or too permissive (large DRIP confirmations slip past). If runtime tuning becomes necessary, a --large-lot <amount> flag or a global audit_large_lot_threshold field on accounts.srf would be reasonable extensions.

Infra / performance

  • HTTP connection pooling. Parallel server sync in loadAllPrices spawns up to 8 threads, each with its own HTTP connection. Could reuse connections to reduce TCP handshake overhead. Only matters with very large portfolios (100+ symbols) hitting ZFIN_SERVER.
  • Streaming cache deserialization. Cache store reads entire files into memory (readFileAlloc with 50MB limit). For portfolios with 10+ years of daily candles, this could use significant memory. Keep current approach unless memory becomes a real problem.