40 KiB
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.srfcleanly. - Accumulation-mode SWR rate column is misleading — priority LOW.
When
retirement_age/retirement_atis 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 + horizonspan 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 bothaccumulation_yearsANDdistribution_yearssimultaneously, 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_agefields on eachtype::birthdaterecord, with contributions stopped per-person. - Configurable max_accumulation_years — priority LOW.
Hardcoded at 50 years. Route through
projections.srfif 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-actualsoverlay shipped (CLI tip + TUI primary surface). Open enhancements:- Historical
metadata.srf/projections.srffor 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.srfalready carries anexpected_returnfield per row that the user captured at that date in their source spreadsheet. We could:- Use the imported
expected_returnas 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_returnto 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_returndirectly, 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.
- Use the imported
- Historical
--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. Thehistorycommand renders a single-series braille chart of portfolio value over time (synthesized intoCandlerecords and fed toformat.computeBrailleChart). It doesn't share the z2d pipeline thatquote(tui/chart.zig) andprojections(tui/projection_chart.zig) use. To export, options:- A. Pipe the synthesized candles through
tui/chart.zig'srenderChart— 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.zigwithout bands). ~150 lines. Same renderToSurface shape so PNG export is trivial after. - C. Skip it permanently; the braille chart is fine for
what
historyis. Document as "not exportable". B is the right answer if PNG export of the history chart is ever requested.
- A. Pipe the synthesized candles through
projections --convergence/--return-backtest. Both render forecast-evaluation charts viatui/forecast_chart.zig. Not refactored to expose arenderToSurfaceseam yet — parser rejects--export-chartin those modes today. Low effort to add (mirror thetui/chart.zigpattern).projections --vs <DATE>. No chart at all in this mode (text-only delta table);--export-chartrejected 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 —
z2donly exports PNG natively today; would need an external dependency or a pixel-buffer-to-format conversion.
Note-field handling: holistic review (priority LOW)
The lot note:: field is nominally a human annotation, but it leaks
into behavior in at least one place: for CUSIP-like holdings with a
note, valuation.shortLabel(note) becomes the allocation's
display_symbol (src/analytics/valuation.zig, ~line 396), and the
classification engine then matches metadata.srf entries against BOTH
the allocation symbol AND display_symbol (src/analytics/analysis.zig,
~line 611). So a free-text note can silently become a
classification-matching key, which is surprising and fragile (editing
a note could change what classifies).
Surfaced while building zfin doctor: its metadata cross-reference
deliberately checks only lot.priceSymbol() (the ticker alias or raw
symbol), NOT the note-derived display_symbol, because coupling a
diagnostic to free-text note content felt wrong. That asymmetry is the
tell: the cross-ref and the engine now disagree on what counts as
"classified" for a note-bearing CUSIP.
Do a pass over every note consumer and classify each use as
display-only vs behavior-affecting; decide whether note-derived values
should ever be matching keys, document/justify any that stay, and then
reconcile doctor's metadata cross-ref with whatever the engine
settles on. Starting points (grep \.note and note::):
valuation.shortLabel->display_symbol, used as a classification match key inanalysis.zig(the main offender).- Cash / illiquid / CD row rendering (display labels; likely fine).
transaction_logtransfernote(annotation).- audit / contributions matchers (do any key off notes?).
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.
Audit: reconcile accounts present in the portfolio but absent from the export — priority MEDIUM
compareAccounts (and compareSchwabSummary) iterate over the
accounts found in the brokerage export, then look up the matching
portfolio account. The two directions are asymmetric:
- Export row → no portfolio account: handled. The
portfolio_acct_name == nullbranch surfaces it as "unmapped — add account_number to accounts.srf" and flags a discrepancy. - Portfolio account → not in the export: silent gap. An
account that exists in
accounts.srf/ has lots inportfolio.srfbut has no corresponding account in the CSV is never iterated, so the reconciler says nothing. If you forget to include an account in the download, or a brokerage drops it from the export, audit can't tell you "you hold account X that wasn't in this file — stale, or just not exported?"
Fix sketch: after the per-export-account loop, walk the
account_map entries for the institution being reconciled and, for
any whose portfolio account holds open lots as-of but never appeared
in the export, emit a "portfolio account not found in export" notice.
Gate it to the institution under audit (don't flag Schwab accounts
when reconciling a Fidelity export). Decide whether a zero-balance /
fully-closed account should be suppressed.
Found while debugging a BrokerageLink cash reconciliation — that account was in the export, so this gap wasn't the culprit, but the asymmetry is real and worth closing.
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.
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 viacache.Store.looksCompleteSrfbeforewriteRaw. Torn HTTP bodies (empty, missing#!srfv1header, or no trailing newline) are rejected with a warn-level log and NOT written to cache.- HTTP responses are checked for an
ETagsha256 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 sayhere'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?
Configurable live-quote provider (Tiingo IEX) - priority LOW
The TUI refresh key (r) values the portfolio with live intraday
quotes via DataService.loadLiveQuotes, which is Yahoo-only: Yahoo is
keyless, consolidated, and stays off every rate-limit budget, so bursty
refresh traffic costs nothing. The tradeoffs are that Yahoo's
unofficial feed is ~15-minute delayed and "can break without notice."
Tiingo's IEX endpoint (/iex/?tickers=A,B,C) is a strong opt-in
alternative: it's genuinely real-time (IEX last-sale, no 15-min delay),
official/keyed, and bills per HTTP request - one call returns the whole
portfolio (confirmed empirically: a 2-ticker batch decrements the daily
quota by 1, not 2). Fields map cleanly: tngoLast to price, prevClose
to day-change. Caveats: IEX is a single venue (~2-3% of volume), so
tngoLast can sit stale between prints on illiquid names, and IEX
doesn't trade mutual funds, so those fall back to the candle close.
Proposal: a config knob (env var, e.g. ZFIN_LIVE_QUOTE_PROVIDER =
yahoo (default) | tiingo) that switches loadLiveQuotes to a new
Tiingo.fetchQuotes(tickers) batched call. Someone on Tiingo's Power
tier ($30/mo, higher limits) who wants real-time and mashes r a lot
(or once we add streaming) reuses their existing TIINGO_API_KEY and
gets real-time coverage; everyone else keeps the keyless Yahoo default.
Implementation notes:
Tiingo.fetchQuotesreturns an array whose order is NOT guaranteed to match the request order, so key results by the returnedtickerfield, not by position.- Tiingo-sourced live quotes would share Tiingo's 50/hour token bucket
(
RateLimiter.perHour, wired into the provider). A batched quote call is 1 request, but heavyruse plus candle refreshes draw from the same hourly budget, so watch for contention. - Tiingo websocket streaming would be the natural follow-on for true
push-based real-time, replacing poll-on-
rentirely.
Precise "as of " via a datetime/timezone lib (zeit) - priority LOW
The portfolio tab's live-price footer is deliberately vague static
text: "(as of intraday quote today)" after a live refresh, falling
back to "(as of close on YYYY-MM-DD)" otherwise. We can't do better
today because the codebase has no wall-clock-to-local-time machinery -
Date is days-only, and every time display is either a date or a
relative "X ago" (fmt.fmtTimeAgo). There's no way to render an
absolute local clock time like "2:34 PM ET".
Pulling in a datetime/timezone library (e.g. zeit, already by the libvaxis author) would let us:
- Show a precise, honest stamp: "(as of 2:34 PM ET)" / "refreshed 2:34 PM" instead of "today" / "Xs ago".
- Fix the current label's weekend/after-hours imprecision. Right now a refresh when the market is closed flips the footer to "intraday quote today" even though Yahoo returned the last close (which on a Saturday is Friday's). With real clock + market-calendar awareness, the label could say "as of Fri close" or "(market closed, last quote Fri 4:00 PM ET)" instead of implying live intraday data.
- Replace
last_refresh_s"refreshed Xs ago" in the TUI status bar (and the quote/earnings/options "data Xs ago" readouts) with absolute times where that reads better.
Scope is a judgment call: a new dependency for what's currently a cosmetic label. Worth it once we want trustworthy timestamps (e.g. for screenshots, or to stop conflating "live" with "last close"); not before.
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-codeddash_cellliteral 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 viafmt.centerDashin 31-col cells (illiquid totals on imported-only history rows).src/commands/milestones.zig: right-padded viafmt.padRightToColsin 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 adash_cellliteral (cell width is static) or migrate tofmt.centerDash/fmt.padRightToCols.
Decisions to make:
- 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.
- 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—. - 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
Statestructs that hold[]const u8slices 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
- 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. - Walk each tab's
State.deinit(andtab.deactivate/tab.reloadhooks) against theStatefield list — every owned field needs a free path on every state-change boundary. - Pay specific attention to
classification_mapand 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-keysor--default-themeshort-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_yieldper 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.
Analysis: umbrella-insurance exposure — future enhancements — priority LOW
v1 shipped (May 2026): analysis command and TUI tab now show an
"Umbrella exposure" section that splits the liquid portfolio into
Shielded vs Exposed dollars based on per-account tax_type from
accounts.srf, with an optional per-account shielded:bool:false
(or :true) override for cases the tax-type default gets wrong
(e.g. pre-tax deferred-comp accounts that aren't ERISA-protected).
The shielding decision is intentionally simple: tax_type != taxable
defaults to shielded; the shielded field overrides per-account.
That covers the realistic cases (401k vs taxable brokerage; DCP-style
non-ERISA pre-tax accounts) without a state-by-state lookup table.
What's deferred
These were in the original v1 design but skipped to keep the shipping scope tight. Pick up only if real user demand surfaces.
-
State-by-state IRA protection lookup table. Civil-judgment IRA shielding varies by state (TX/FL full, some none, most partial). v1 punts to the manual override; users in weak-state IRA jurisdictions add
shielded:bool:falseon their IRA accounts themselves. A built-in state table would automate this — needs astatefield at the user level (per-portfolio-file or global config) and a maintained taxonomy. Doable but high-friction relative to the manual override. -
account_typedistinction. v1 usestax_type(taxable / roth / traditional / hsa) as the umbrella proxy because that's what exists. A more granularaccount_type(401k,403b,roth_ira,traditional_ira,sep_ira,inherited_ira, ...) would let the default shielding decision be more nuanced (e.g. inherited_ira defaults to NOT shielded post-Clark v. Rameker). Not necessary while the override exists. -
Joint-account / community-property nuance. State-specific. v1 treats each account holistically. Probably never needed.
-
Inherited-IRA flag. Currently the user adds
shielded:bool:falseon inherited IRAs as the workaround. A dedicated flag would let the section call them out by name in the output. Cosmetic.
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
optionscommand 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 orCtrl+^in vim). Storelast_symbolonApp; on symbol change, stash the previous. Useful for eyeball-comparing performance/risk data between two symbols.
Options / valuation
- Per-account covered call adjustment.
adjustForCoveredCallsinvaluation.zigoperates 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 restructuringportfolioSummary, sinceAllocationis 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.
adjustForCoveredCallshas 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.
Audit
- Audit large-lot threshold tuning.
src/commands/audit.zigusesaudit_large_lot_threshold: f64 = 10_000.0as 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 globalaudit_large_lot_thresholdfield onaccounts.srfwould be reasonable extensions.
Infra / performance
- HTTP connection pooling. Parallel server sync in
loadAllPricesspawns 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 (
readFileAllocwith 50MB limit). For portfolios with 10+ years of daily candles, this could use significant memory. Keep current approach unless memory becomes a real problem.