zfin/TODO.md

398 lines
20 KiB
Markdown

# 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
- **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.
- Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
- **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.
## 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.
## 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.
## 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.
## 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.fetchQuotes` returns an array whose order is NOT guaranteed to
match the request order, so key results by the returned `ticker`
field, 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 heavy `r` use 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-`r` entirely.
## Precise "as of <clock time>" 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](https://github.com/rockorager/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.
## 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.
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).
### 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.
### 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.