14 KiB
Future Work
Projections: future enhancements
-
Configurable return cap per position (default: none; cap outliers like NVDA)
-
Configurable MIN period selection (currently 3Y/5Y/10Y, exclude 1Y)
-
"Not Retired Yet" mode: accumulation phase with contributions before retirement. Separate from life events — the simulation has two distinct phases: accumulation (base spending = 0, contributions applied) and distribution (searched-for withdrawal
- spending model). Config:
retirement_age::60orretirement_in::10, plusannual_contribution::100000. Safe withdrawal search only applies to the distribution phase. Chart shows portfolio growing during accumulation, peaking at retirement, then drawing down.
- spending model). Config:
-
Multiple spending models: flat (current), decreasing (1-2% real annual decrease, Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
-
Configurable benchmark symbols (currently hardcoded SPY + AGG)
-
Unclassified position handling in allocation split (warn user)
-
Historical projection comparison: re-run projections from any past snapshot date, overlay actual portfolio trajectory from subsequent snapshots onto the projected percentile bands. Shows how reality tracked against the model. Data is already available in history/*.srf snapshots — just need to load a historical portfolio value and re-run
computePercentileBandswith that starting point, then plot actual values from later snapshots as a line overlaid on the bands.zfin projections --as-of <DATE>already reruns the simulation against a past snapshot (the prerequisite for this overlay). What's missing is the overlay itself — loading multiple downstream snapshots and plotting their net-worth trajectory on the same chart.Deferred to ~2027. Needs a practical volume of real snapshots (currently building up; meaningful backtest requires 12+ months). Backfilling from git history is not viable — the lot-level state on portfolio.srf at a past commit is insufficient to reconstruct the full transaction+contribution picture. Revisit once there are 12+ months of continuous snapshot data.
Also consider:
metadata.srfandprojections.srfclassifications / assumptions drift over time. For back-dated runs we currently use the current versions of both; historical git-tracked versions could be checked out and loaded instead. Edge case for now.
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(). Low priority — the per-account
values themselves are correct after the price_ratio fix.
Upgrade to 0.16.0
Pending dependencies:
- SRF: complete (use 0.15.2 tag if needed)
- VAxis: work seems to be in this PR: https://github.com/rockorager/libvaxis/pull/316
- z2d: 0.16.0 branch exists, but not sure if this is fully compatible with last commit in March
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
Add 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. The toggle
key swaps current and last. Works on any tab — particularly useful for
eyeball-comparing performance/risk data between two symbols.
Fix enrich command for international funds
deriveMetadata in src/commands/enrich.zig misclassifies international ETFs:
-
geouses Alpha Vantage'sCountryfield, which is the fund issuer's domicile (USA for all US-listed ETFs), not the fund's investment geography. Every US-domiciled international fund getsgeo::US. -
asset_classshort-circuits to"ETF"whenasset_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.
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?
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. If NVDA calls are sold in Emil IRA, they shouldn't cap NVDA
shares held in Joint trust.
Fixing this means restructuring portfolioSummary, since Allocation is
currently account-agnostic. Approach: compute per-account reductions using
positionsForAccount + account-filtered option lots, then sum into
portfolio-wide reductions. Each account's reduction capped by that account's
shares, not the global total.
Low priority — naked calls are rare, and calls are typically in the same account as the underlying.
Covered call adjustment optimization
adjustForCoveredCalls has a nested loop — for each allocation, it iterates
all lots to find matching option contracts. O(N*M) is fine for personal
portfolios (<1000 lots). Pre-indexing options by underlying would help if
someone had a very large options-heavy portfolio.
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.
8 concurrent connections is fine for now.
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.
Audit: manual-check accounts mechanism (NYL, Kelly's ESPP, etc.)
Some accounts/positions can't be reconciled from broker CSVs and need a human-in-the-loop reminder at the audit step. Examples:
- NY Life — no CSV export at all. Values only live in periodic statements.
- Kelly's ESPP — accrued payroll-deduction cash doesn't appear in the
Fidelity positions CSV until the purchase date hits (typically every
6 months). Between purchases the cash is a real contribution that
zfin auditcan't see. - Future: treat as an open category.
The existing update_cadence::weekly|monthly|quarterly|none field already
sort-of covers this, but has two gaps:
- It fires off the last git-detected change, not the last human review. For NYL, the value sometimes hasn't changed in months — so git never fires, cadence never trips.
- ESPP needs weekly-ish attention while accumulating cash between purchases, but the accrued balance is invisible to the CSV audit.
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 NYL.
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-specific follow-through
ESPP is also a contribution-attribution blind spot. If Kelly's 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
cash additions count correctly.
Audit: tax-loss account persistent discrepancy (~-$897.59)
Status (2026-05-02): Resolved via the direct_indexing::true
flag in accounts.srf. When set on an account:
- Audit now emits a suggested
price_ratioadjustment even for lots with ratio == 1.0 (previously skipped). The Schwab-summary path picks up direct-indexing accounts with a single stock lot and computes the suggested ratio from the account-level value delta. Copy the suggestion intoportfolio.srfand next audit is clean. - Contributions swallows sub-1% share drift on lots in the account
as tracking-error noise instead of emitting
rollup_delta/drip_negative. Real contributions (>1% of account value) still surface.
See src/analytics/analysis.zig AccountTaxEntry.direct_indexing
and src/commands/audit.zig displaySchwabSummaryRatioSuggestions.
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.
Audit large-lot threshold tuning — priority LOW
src/commands/audit.zig uses audit_large_lot_threshold: f64 = 10_000.0 as the cutoff for "surface this new lot for confirmation."
The value is a judgment call; revisit if:
- $10k proves too aggressive (weekly payroll ESPP accruals near the limit spam the report),
- $10k proves too permissive (large DRIP confirmations or CD maturities slip past uncalled),
- the user wants per-account thresholds (e.g. $25k for high-turnover brokerage, $5k for IRAs).
If runtime tuning becomes necessary, either a --large-lot <amount> flag on zfin audit or a global audit_large_lot_threshold
field on accounts.srf-the-file would be reasonable extensions.
Until then the constant is the single knob.
Torn SRF files from server sync (recurring bug)
Status: Multi-layer defense in place.
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 to pinpoint where torn responses originate (HTTP transit has been the dominant source so far).
Remaining work:
- Continue monitoring — if torn responses persist despite the etag retry, investigate lower-level transport issues (proxy, keepalive, partial reads on the server side).
(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.)