From 9cea368f2c7d3da826fa14a01d98579188b712c5 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 13 May 2026 13:05:01 -0700 Subject: [PATCH] initial implementation of projections actual (note TODO comments) --- TODO.md | 328 ++++++++++++++++++++++++++++++++--- src/commands/common.zig | 26 +++ src/commands/projections.zig | 222 +++++++++++++++++++----- src/history.zig | 242 ++++++++++++++++++++++++++ src/main.zig | 15 +- src/tui.zig | 37 ++++ src/tui/projection_chart.zig | 157 ++++++++++++++++- src/tui/projections_tab.zig | 187 ++++++++++++++++---- src/tui/theme.zig | 8 + src/views/projections.zig | 309 ++++++++++++++++++++++++++++++++- 10 files changed, 1429 insertions(+), 102 deletions(-) diff --git a/TODO.md b/TODO.md index f62b026..fd94290 100644 --- a/TODO.md +++ b/TODO.md @@ -29,29 +29,317 @@ manual-check accounts mechanism (medium effort, real user value). - 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 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 `computePercentileBands` with that starting point, then plot - actual values from later snapshots as a line overlaid on the bands. +- **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. + - **Chart zoom for short-history overlays.** With a 50-year + projection horizon and only ~10 years of imported actuals, + the actuals line is squashed into the first 20% of the + chart and the comparison-against-bands story is hard to + read. Two design directions: + - **Auto-zoom**: when the overlay is on, the chart's + x-axis defaults to `[as_of, today + N years]` (where + N is small, e.g. 2x the actuals span) instead of + `[as_of, as_of + horizon]`. The bands beyond `today + + N` are still computed but clipped from view. The + tradeoff: the user loses the long-tail terminal-value + context unless they toggle back out. + - **Toggle**: a separate keybind (e.g. `z` for zoom) + flips between full-horizon and zoomed views. Default + off so the bands tell their full story; user opts in + when they want overlay legibility. + Auto-zoom is more invasive (changes the default chart + semantics for everyone running with overlay-on) but better + matches what the user actually wants when they toggle the + overlay. Toggle is safer but requires the user to know the + feature exists. Probably do auto-zoom but expose a toggle + to escape it ("show full horizon"). - `zfin projections --as-of ` 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. +## Export chart as PNG (`--export-chart `) — priority MEDIUM - **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. +z2d already supports PNG export natively. Today the chart-bearing +commands (`quote`, `history`, `projections`, plus the equivalent TUI +tabs) render to braille (CLI) or Kitty graphics (TUI). Adding a +`--export-chart ` flag would land just the chart (not the +surrounding text output) as a PNG file at the given path, at full +fidelity, regardless of which surface invoked it. - Also consider: `metadata.srf` and `projections.srf` classifications / - 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. +Driver: when reviewing a back-dated projection or a notable price +move, capturing the chart as an image (e.g. for a write-up, an email +to the household, or a wiki page) is currently a screenshot-and-crop +chore. PNG export makes it a one-shot CLI invocation. + +Sketch: +- `zfin quote AAPL --export-chart aapl.png` → just the price+ + Bollinger chart as a PNG, no other output. +- `zfin projections --as-of 1Y --overlay-actuals --export-chart projection.png` + → the projection-bands chart plus overlay, no other output. +- The chart code already produces RGB pixel buffers via z2d; replace + the `transmitPreEncodedImage` call (TUI) or the braille text path + (CLI) with a `Surface.write_png` call when the flag is present. + +Plumbing: a thin "chart-only render" entry point in each chart +module (`projection_chart.zig`, `chart.zig` for symbols), called +from the relevant command's `run()` when `--export-chart` is set. +Exits before the rest of the text output renders. + +Out of scope for V1: file-format alternatives (SVG, PDF), themed +color overrides for export (always uses the active terminal theme), +non-chart command output as PNG. + +## 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. + +## Refactor: TUI App struct knows too much about each tab — priority MEDIUM + +`src/tui.zig`'s `App` struct currently has dozens of tab-specific +fields scattered across its top level — `projections_loaded`, +`projections_disabled`, `projections_config`, `projections_ctx`, +`projections_horizon_idx`, `projections_image_id`, +`projections_image_width`, `projections_image_height`, +`projections_chart_dirty`, `projections_chart_visible`, +`projections_events_enabled`, `projections_value_min`, +`projections_value_max`, `projections_as_of`, +`projections_as_of_requested`, `projections_overlay_actuals`, +plus equivalents for portfolio, history, options, earnings, +analysis, perf, and quote tabs. + +This couples `App` to the implementation details of every tab. +Touching a single tab's state shape requires editing the central +struct, which makes refactors noisy and discourages tab modules +from owning their own state cleanly. + +**Proposal: each tab gets exactly ONE field on `App`** — a +struct (or pointer to a struct) defined in that tab's own file. +The tab module owns its state shape; `App` only carries the +top-level reference. + +Sketch: + +```zig +// src/tui/projections_tab.zig +pub const State = struct { + loaded: bool = false, + disabled: bool = false, + config: projections.UserConfig = .{}, + ctx: ?ProjectionContext = null, + horizon_idx: usize = 0, + chart: ChartState = .{}, + as_of: AsOfState = .{}, + overlay_actuals: bool = false, + // ... +}; + +// src/tui.zig +pub const App = struct { + // ... shared cross-tab state (today, allocator, vx_app, ...) ... + portfolio: portfolio_tab.State = .{}, + quote: quote_tab.State = .{}, + perf: perf_tab.State = .{}, + history: history_tab.State = .{}, + projections: projections_tab.State = .{}, + options: options_tab.State = .{}, + earnings: earnings_tab.State = .{}, + analysis: analysis_tab.State = .{}, +}; +``` + +After the migration, `app.projections_overlay_actuals` becomes +`app.projections.overlay_actuals`, etc. Each tab's State struct +documents its own invariants without polluting `App`. + +**Benefits:** + +- Tab modules become genuinely self-contained. Adding new state + to a tab only touches that tab's file. +- `App`'s field count drops from ~80 to ~10 (cross-cutting state + + 8 tab-state fields). +- Clearer ownership: when reading `App`, you can tell at a + glance which fields are shared vs tab-private. +- Easier to reason about lifetimes — each tab's `freeLoaded()` + operates on its own struct rather than reaching into App. +- Onboarding new contributors: "to add a tab feature, define + state in the tab file" instead of "edit two files in lockstep." + +**Migration approach:** + +This is a large mechanical refactor (~80+ field renames across +all tab files plus `tui.zig`). Best done as one focused PR: + +1. Define `State` in each tab file with all current fields. +2. Add the eight new fields to `App`; flip them on as defaults + (so old `app.projections_*` accesses temporarily still work + if we add accessor shims, but cleaner to just rip the + bandaid). +3. Sweep all `app._` references → `app..`. +4. Delete the old top-level fields from `App`. +5. Verify `zig build test` is unchanged. The refactor should be + strictly behavior-preserving. + +Risks: large diff (touches every tab file plus tui.zig), but +mechanical — no logic changes, no tests should move. The pre- +commit hooks catch any miss instantly. + +**While we're at it: action handler bodies should also move.** +The same shape problem shows up in `tui.zig`'s keybind-action +dispatch — `sort_reverse`, `toggle_chart`, `toggle_events`, +`account_filter`, and the per-tab branches inside them are +~100 lines of tab-specific logic living in the central event +loop. Concretely (line numbers as of writing): + +- `sort_reverse` (~line 1328) dual-dispatches by active tab: + portfolio flips sort direction + calls + `sortPortfolioAllocations` / `rebuildPortfolioRows`; + projections toggles overlay-actuals + reloads data + sets + status. None of that body is `App`-level concern; it's two + separate tabs' private state mutations. +- `toggle_chart` (~line 1382) flips + `projections_chart_visible` and resets `scroll_offset`. Pure + projections-tab business. +- `toggle_events` (~line 1390) flips + `projections_events_enabled` and triggers a reload. Same. +- `account_filter` (~line 1366) opens the account picker mode + with portfolio-tab-specific cursor positioning. + +The cleaner shape: each tab module exposes a +`handleAction(app, action) bool` (or similar) that returns +true when it consumed the action. `tui.zig`'s dispatch becomes +a thin "ask the active tab if it wants this action; otherwise +fall through to global handlers." The body of each `case` +shrinks to a one-liner that dispatches to +`tab_module.handleAction(self)`. + +This pairs naturally with the State-struct migration above — +the tab module's `handleAction` operates on its own State +struct rather than reaching into `App`. Some keybinds are +genuinely cross-cutting (quit, refresh, tab navigation, scroll) +and stay in the central handler. The split is "App owns +chrome; tab owns content." + +Driver: every overlay-actuals-shaped feature added to a tab +recently has involved adding 1–2 fields to `App`, and the +struct keeps growing. Eventually it becomes unreviewable. + +## Bug: braille charts use raw `close`, not `adj_close` — cliff at splits + +**Reproduction:** `zfin quote SOXX` (or the TUI quote tab). The +braille chart drops sharply on **2024-03-07**, which is the +iShares Semiconductor ETF's 3-for-1 split date: + + - 2024-03-06 close: $689.60 + - 2024-03-07 close: $237.75 (≈ $689.60 / 2.9) + +The `adj_close` column in `~/.cache/zfin/SOXX/candles_daily.srf` +tracks correctly through the split (~$226 → ~$234), so the +provider data is fine. The bug is purely cosmetic: the chart +renders the *unadjusted* close price. + +**Root cause:** `computeBrailleChart` in `src/format.zig:888` +indexes `data[i].close` instead of `data[i].adj_close`. Lines 901, +902, 904, 905, 935 all use `.close`. + +**Independent confirmation:** `zfin splits SOXX` returns +`2024-03-07 3:1` from Polygon. So the split data exists in the +provider layer (and gets cached as `splits.srf` once requested), +but the charting code path doesn't consult it. + +**Fix candidates:** + +A. **Switch `computeBrailleChart` to consume `adj_close` directly.** + Simplest. Affects every chart caller (quote, history, projections + median band, TUI quote/projections tabs). Cosmetic only — no + computation depends on it. The price-axis labels would render + adjusted prices, which may surprise users used to seeing the + raw last-close. Mitigate with a comment in the chart's right-edge + label region or a header note. + +B. **Pass a flag to `computeBrailleChart` selecting `close` vs + `adj_close`.** Default to adjusted; let the quote tab show raw. + More flexible, marginally more code. + +C. **Add a `chart_close` accessor to `Candle` that returns + `adj_close` if non-zero, else `close`.** Same effect as (A) with a + defensive fallback. + +D. **Apply split adjustments at chart-data prep time using + `splits.srf`.** Walk the candle slice with the split history and + pre-multiply pre-split closes by the cumulative ratio. More + work, but produces a chart-axis dollar value the user expects + ("today's last close was $X, the chart starts at $Y from N + years ago"). This is what most charting libraries do. + Requires plumbing `DataService.getSplits` into the chart-prep + path on every chart caller, OR doing the adjustment once in the + service layer alongside candle fetching. Not all callers have a + `DataService` reference today (e.g., `runProjection`'s synthetic + median-band candles). + +**Recommendation:** Start with (A) or (C) — single-line fix, gets +the cliff out of all charts immediately. (D) is the "correct" fix +but a bigger refactor; file as a follow-up if (A)/(C) lands first. + +**Other affected symbols:** Any held position with a split in the +last 10 years will have the same artifact. Check NVDA (10:1 split +on 2024-06-10) for a louder example. + +**Priority:** LOW. Cosmetic only — analytics already use +`adj_close` correctly via the per-position trailing-returns path. +But it's confusing when scanning a chart and seeing a 50% drop +that isn't real. ## Audit: manual-check accounts mechanism (NYL, Kelly's ESPP, etc.) — priority HIGH diff --git a/src/commands/common.zig b/src/commands/common.zig index 7210047..0777568 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -816,6 +816,32 @@ pub fn resolveSnapshotOrExplain( }; } +/// Resolve an as-of date against either a native snapshot OR an +/// `imported_values.srf` row, with a stderr explanation when neither +/// source has data at-or-before the requested date. +/// +/// Snapshot wins when both are available; imported is the fallback. +/// See `history.resolveAsOfDate` for the resolution rules. +/// +/// Returns `anyerror` to match the underlying resolver — the +/// imported-values reader pulls in the full file-IO error universe. +pub fn resolveAsOfOrExplain( + io: std.Io, + arena: std.mem.Allocator, + hist_dir: []const u8, + requested: zfin.Date, +) anyerror!history.ResolvedAsOf { + return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) { + error.NoDataAtOrBefore => { + const msg = std.fmt.allocPrint(arena, "No snapshot or imported_values entry at or before {f}.\n", .{requested}) catch "No data at or before the requested date.\n"; + stderrPrint(io, msg) catch {}; + stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n") catch {}; + return err; + }, + else => |e| return e, + }; +} + // ── Watchlist loading ──────────────────────────────────────── /// Load a watchlist SRF file containing symbol records. diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 7b9c6bc..11712ed 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -20,6 +20,7 @@ const benchmark = @import("../analytics/benchmark.zig"); const valuation = @import("../analytics/valuation.zig"); const view = @import("../views/projections.zig"); const history = @import("../history.zig"); +const timeline = @import("../analytics/timeline.zig"); /// Hardcoded benchmark symbols (configurable in a future version). const stock_benchmark = "SPY"; @@ -33,8 +34,16 @@ const AsOfResolution = struct { /// The requested date, as parsed by the caller. requested: Date, /// The date that was actually loaded. Differs from `requested` - /// when we auto-snapped to the nearest-earlier snapshot. + /// when we auto-snapped to the nearest-earlier data point. actual: Date, + /// Whether the resolution landed on a native snapshot or an + /// `imported_values.srf` row. Snapshot wins when both are + /// available within the at-or-before window. + source: history.AsOfSourceKind = .snapshot, + /// Liquid total at `actual`. Filled when `source == .imported` + /// (read directly from the imported_values row); zero otherwise + /// — the snapshot path reads its totals from the loaded snapshot. + liquid: f64 = 0, }; /// Run projections. @@ -53,6 +62,8 @@ pub fn run( events_enabled: bool, as_of: Date, from_snapshot: bool, + today: Date, + overlay_actuals: bool, color: bool, out: *std.Io.Writer, ) !void { @@ -78,35 +89,88 @@ pub fn run( // defer runs at the end of `run`. var snap_bundle: ?history.LoadedSnapshot = null; defer if (snap_bundle) |*s| s.deinit(allocator); + // Live-portfolio bundle. Loaded for the live (no-as-of) path + // AND for the imported-only as-of path (which uses today's + // composition scaled to the imported liquid total). + var live_loaded: ?cli.LoadedPortfolio = null; + defer if (live_loaded) |*l| l.deinit(allocator); + var live_pf_data: ?cli.PortfolioData = null; + defer if (live_pf_data) |*p| p.deinit(allocator); if (from_snapshot) { resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) { error.NoSnapshot => return, else => return err, }; - const hist_dir = try history.deriveHistoryDir(va, file_path); - snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual); - ctx = try view.loadProjectionContextAsOf( - io, - va, - portfolio_dir, - &snap_bundle.?.snap, - resolution.?.actual, - svc, - events_enabled, - ); + if (resolution.?.source == .snapshot) { + const hist_dir = try history.deriveHistoryDir(va, file_path); + snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual); + + ctx = try view.loadProjectionContextAsOf( + io, + va, + portfolio_dir, + &snap_bundle.?.snap, + resolution.?.actual, + svc, + events_enabled, + ); + } else { + // Imported-only as-of: need today's portfolio composition + // (allocations + cash/CD totals) to scale to the + // imported liquid value. + // + // Today's `as_of` parameter is being repurposed here as + // the historical reference date for the simulation; for + // the composition we ALWAYS want today's mix (the only + // composition we know). Pass `today` for the cash/CD + // computation. + live_loaded = cli.loadPortfolio(io, allocator, file_path, today) orelse return; + const lp = &live_loaded.?; + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + for (lp.positions) |pos| { + if (pos.shares <= 0) continue; + if (svc.getCachedCandles(pos.symbol)) |cs| { + defer cs.deinit(); + if (cs.data.len > 0) { + try prices.put(pos.symbol, cs.data[cs.data.len - 1].close); + } + } + } + + live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, today) catch |err| switch (err) { + error.NoAllocations, error.SummaryFailed => { + try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n"); + return; + }, + else => return err, + }; + + ctx = try view.loadProjectionContextFromImported( + io, + va, + portfolio_dir, + live_pf_data.?.summary.allocations, + live_pf_data.?.summary.total_value, + lp.portfolio.totalCash(today), + lp.portfolio.totalCdFaceValue(today), + resolution.?.liquid, + resolution.?.actual, + svc, + events_enabled, + ); + } } else { - var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; - defer loaded.deinit(allocator); - const portfolio = loaded.portfolio; - const positions = loaded.positions; - const syms = loaded.syms; + live_loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; + const lp = &live_loaded.?; // Prices from cache — matches pre-as-of behavior exactly. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); - for (positions) |pos| { + for (lp.positions) |pos| { if (pos.shares <= 0) continue; if (svc.getCachedCandles(pos.symbol)) |cs| { defer cs.deinit(); @@ -116,53 +180,89 @@ pub fn run( } } - var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) { + live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { try cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, }; - defer pf_data.deinit(allocator); ctx = try view.loadProjectionContext( io, va, portfolio_dir, - pf_data.summary.allocations, - pf_data.summary.total_value, - portfolio.totalCash(as_of), - portfolio.totalCdFaceValue(as_of), + live_pf_data.?.summary.allocations, + live_pf_data.?.summary.total_value, + lp.portfolio.totalCash(as_of), + lp.portfolio.totalCdFaceValue(as_of), svc, events_enabled, as_of, ); } + // ── Optional actuals overlay ──────────────────────────────── + // + // Requires `--as-of` (an as-of-resolved snapshot date). When the + // user passes `--overlay-actuals` without `--as-of`, warn and + // continue without the overlay; the overlay against today-as-now + // is meaningless because the future hasn't happened yet. + if (overlay_actuals) { + if (!from_snapshot) { + try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); + } else if (resolution) |r| { + ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, today) catch |err| blk: { + // Non-fatal — the projection still renders without + // the overlay. Surface the error so the user can fix + // their history dir but don't block the report. + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n"; + try cli.stderrPrint(io, msg); + break :blk null; + }; + } + } + const horizons = ctx.config.getHorizons(); const confidence_levels = ctx.config.getConfidenceLevels(); const comparison = ctx.comparison; try out.print("\n", .{}); if (resolution) |r| { - try cli.printBold(out, color, "Projections (as of {f})\n", .{r.actual}); + const source_label: []const u8 = switch (r.source) { + .snapshot => "snapshot", + .imported => "imported value", + }; + try cli.printBold(out, color, "Projections (as of {f}, {s})\n", .{ r.actual, source_label }); } else { try cli.printBold(out, color, "Projections ({s})\n", .{file_path}); } try out.print("========================================\n", .{}); // If auto-snapped, print a muted note so the user knows the - // requested date wasn't an exact hit. + // requested date wasn't an exact hit. The wording reflects the + // resolution source — "nearest snapshot" vs "nearest imported + // value" — so the user knows which file to update for finer + // granularity. if (resolution) |r| { if (r.actual.days != r.requested.days) { const diff = r.requested.days - r.actual.days; - try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f}; nearest snapshot: {f}, {d} day{s} earlier)\n", .{ + const nearest_label: []const u8 = switch (r.source) { + .snapshot => "nearest snapshot", + .imported => "nearest imported value", + }; + try cli.printFg(out, color, cli.CLR_MUTED, "(requested {f}; {s}: {f}, {d} day{s} earlier)\n", .{ r.requested, + nearest_label, r.actual, diff, fmt.dayPlural(diff), }); } + if (r.source == .imported) { + try cli.printFg(out, color, cli.CLR_MUTED, "(bands use today's allocation scaled to the imported liquid total)\n", .{}); + } } try out.print("\n", .{}); @@ -268,6 +368,16 @@ pub fn run( } } + // Overlay-actuals tip: the CLI's braille chart is single-series, + // so the actuals overlay only renders in the TUI. Print a short + // pointer so the user knows where to find it. (We do NOT gate on + // ctx.overlay_actuals being non-null — even when the overlay was + // requested but had no data, the user benefits from the tip.) + if (overlay_actuals and from_snapshot) { + try cli.printFg(out, color, cli.CLR_MUTED, " (Overlay rendered in TUI only — run `zfin interactive`, set as-of with `d`, then press `o`.)\n", .{}); + try cli.printFg(out, color, cli.CLR_MUTED, " Caveat: overlay tracks trajectory, not SWR validity.\n", .{}); + } + // ── Terminal portfolio value ───────────────────────────────── try out.print("\n", .{}); try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\n", .{}); @@ -642,14 +752,15 @@ fn renderCompareRowMoney(out: *std.Io.Writer, color: bool, label: []const u8, th try cli.printGainLoss(out, color, delta, "{f}\n", .{Money.from(delta).signed().padRight(12)}); } -/// Resolve the user's requested as-of date against the history directory. +/// Resolve the user's requested as-of date against the history +/// directory, accepting either a native snapshot or an +/// `imported_values.srf` row. /// -/// Thin adapter over `cli.resolveSnapshotOrExplain` — the shared CLI -/// helper owns exact-then-fallback resolution and the stderr +/// Thin adapter over `cli.resolveAsOfOrExplain` — the shared CLI +/// helper owns the exact-then-fallback resolution and the stderr /// messaging. This wrapper just maps the error set to -/// `error.NoSnapshot` (projections-specific) and strips the `exact` -/// flag since projections doesn't surface that distinction in its -/// header (it just uses `actual` directly). +/// `error.NoSnapshot` (projections-specific) and packs the source + +/// liquid fields into the local `AsOfResolution` shape. /// /// Arena-allocates the intermediate `hist_dir` + filename strings; /// pass a short-lived arena as `va`. @@ -661,17 +772,48 @@ fn resolveAsOfSnapshot( ) !AsOfResolution { const hist_dir = try history.deriveHistoryDir(va, file_path); - const resolved = cli.resolveSnapshotOrExplain(io, va, hist_dir, requested) catch |err| switch (err) { - error.NoSnapshotAtOrBefore => return error.NoSnapshot, + const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) { + error.NoDataAtOrBefore => return error.NoSnapshot, else => |e| { - try cli.stderrPrint(io, "Error resolving snapshot: "); + try cli.stderrPrint(io, "Error resolving as-of: "); try cli.stderrPrint(io, @errorName(e)); try cli.stderrPrint(io, "\n"); return error.NoSnapshot; }, }; - return .{ .requested = resolved.requested, .actual = resolved.actual }; + return .{ + .requested = resolved.requested, + .actual = resolved.actual, + .source = resolved.source, + .liquid = resolved.liquid, + }; +} + +/// Load the merged history timeline (snapshots + imported_values), +/// filter to the [`as_of`, `today`] range, and produce an overlay +/// section. Caller passes an arena allocator so all intermediate +/// allocations are freed at the end of the request. +/// +/// Returns null on a missing/empty history dir — that's a soft +/// failure (no overlay rendered, projection still works). +fn loadOverlayActuals( + io: std.Io, + arena: std.mem.Allocator, + file_path: []const u8, + as_of: Date, + today: Date, +) !?view.OverlayActualsSection { + var loaded = history.loadTimeline(io, arena, file_path) catch |err| switch (err) { + // Missing/unreadable history dir → no overlay, no error. + error.FileNotFound, error.NotDir, error.AccessDenied => return null, + else => return err, + }; + defer loaded.deinit(); + + if (loaded.series.points.len == 0) return null; + + return try view.buildOverlayActuals(arena, loaded.series.points, as_of, today); } /// Write a return row using the view model, applying StyleIntent colors. @@ -958,7 +1100,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" { var stream = std.Io.Writer.fixed(&buf); const d = Date.fromYmd(2026, 3, 13); - try run(io, testing.allocator, &svc, pf, false, d, true, false, &stream); + try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); // No body output because the resolution failed — the stderr // message is swallowed by `cli.stderrPrint` and doesn't land in @@ -990,7 +1132,7 @@ test "run: as_of with matching snapshot produces body output" { var buf: [32_768]u8 = undefined; var stream = std.Io.Writer.fixed(&buf); - try run(io, testing.allocator, &svc, pf, false, d, true, false, &stream); + try run(io, testing.allocator, &svc, pf, false, d, true, d, false, false, &stream); const out = stream.buffered(); // Header should call out the as-of date explicitly. @@ -1021,7 +1163,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" { var stream = std.Io.Writer.fixed(&buf); const requested = Date.fromYmd(2026, 3, 13); - try run(io, testing.allocator, &svc, pf, false, requested, true, false, &stream); + try run(io, testing.allocator, &svc, pf, false, requested, true, requested, false, false, &stream); const out = stream.buffered(); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); diff --git a/src/history.zig b/src/history.zig index 28e54e5..f8a8eb5 100644 --- a/src/history.zig +++ b/src/history.zig @@ -413,6 +413,108 @@ pub const ResolveSnapshotError = error{ NoSnapshotAtOrBefore, } || std.mem.Allocator.Error || std.Io.Dir.AccessError || std.Io.File.OpenError; +/// Source of an as-of resolution: a native snapshot file, or an +/// `imported_values.srf` row. The snapshot path is preferred (higher +/// fidelity) and consulted first; only when no snapshot exists at or +/// before the requested date do we look at imported_values. +pub const AsOfSourceKind = enum { snapshot, imported }; + +/// Result of `resolveAsOfDate` — a unified resolver that consults +/// both the native snapshot directory and `imported_values.srf`. +pub const ResolvedAsOf = struct { + requested: Date, + actual: Date, + exact: bool, + source: AsOfSourceKind, + /// Liquid total at `actual`. For `.snapshot` this is filled by + /// the caller after loading the snapshot file (left at 0 here); + /// for `.imported` this is the value from the imported_values + /// row directly. + liquid: f64 = 0, +}; + +pub const ResolveAsOfError = error{ + /// Neither a snapshot nor an imported_values row exists at or + /// before the requested date. + NoDataAtOrBefore, +}; + +/// Resolve a requested as-of date against `hist_dir`, accepting +/// either a native snapshot OR an imported_values row. +/// +/// 1. Look up nearest-earlier snapshot via the existing +/// `resolveSnapshotDate`. If found → return `.snapshot`. +/// 2. Otherwise read `/imported_values.srf` and find the +/// latest row whose date is `<= requested`. If found → return +/// `.imported` with that liquid. +/// 3. Otherwise → `error.NoDataAtOrBefore`. +/// +/// When BOTH sources have a hit at the same date, snapshot wins +/// (higher fidelity). When the snapshot is older than the imported +/// row, the snapshot still wins (we prefer literal lot-level state +/// over a scaled approximation when both are available within the +/// requested window). +/// +/// Returns `anyerror` rather than a tight error set because +/// `loadImportedValues` pulls in the full file-read error universe +/// (network filesystems, etc.) and the caller usually surfaces all +/// failures via `@errorName` anyway. +pub fn resolveAsOfDate( + io: std.Io, + arena: std.mem.Allocator, + hist_dir: []const u8, + requested: Date, +) anyerror!ResolvedAsOf { + // Try the snapshot path first. Catch only the "no snapshot" + // error; surface IO / OOM unchanged. + const snap_attempt = resolveSnapshotDate(io, arena, hist_dir, requested); + if (snap_attempt) |snap| { + return .{ + .requested = snap.requested, + .actual = snap.actual, + .exact = snap.exact, + .source = .snapshot, + }; + } else |err| switch (err) { + error.NoSnapshotAtOrBefore => {}, // fall through to imported + else => |e| return e, + } + + // No snapshot at-or-before. Try imported_values. + const iv_path = try std.fs.path.join(arena, &.{ hist_dir, "imported_values.srf" }); + var iv = imported_values.loadImportedValues(io, arena, iv_path) catch |err| switch (err) { + // Treat parse errors the same as "no data" — the file is + // there but unusable. The full timeline-load path will log + // a more detailed error; we just gracefully degrade here. + error.InvalidSrf, error.DuplicateDate, error.NotSorted => return error.NoDataAtOrBefore, + else => |e| return e, + }; + defer iv.deinit(); + + // Walk ascending list to find the latest row <= requested. + // (`points` is guaranteed ascending by `parseImportedValues`.) + var match: ?imported_values.HistoryPoint = null; + for (iv.points) |p| { + if (p.date.days <= requested.days) { + match = p; + } else { + break; + } + } + + if (match) |m| { + return .{ + .requested = requested, + .actual = m.date, + .exact = m.date.eql(requested), + .source = .imported, + .liquid = m.liquid, + }; + } + + return error.NoDataAtOrBefore; +} + /// Resolve a requested snapshot date against `hist_dir`: /// - If `hist_dir/-portfolio.srf` exists, return it as /// an exact match. @@ -1383,3 +1485,143 @@ test "resolveSnapshotDate: empty history dir returns NoSnapshotAtOrBefore" { const result = resolveSnapshotDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); try testing.expectError(error.NoSnapshotAtOrBefore, result); } + +// ── resolveAsOfDate (snapshot OR imported) ──────────────────── + +test "resolveAsOfDate: snapshot-only history resolves to snapshot" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" }); + + const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1)); + try testing.expectEqual(AsOfSourceKind.snapshot, r.source); + try testing.expect(r.exact); + try testing.expectEqual(@as(f64, 0), r.liquid); +} + +test "resolveAsOfDate: imported-only falls back to imported_values" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const iv_data = + \\#!srfv1 + \\date::2016-01-03,liquid:num:1500000.00 + \\date::2016-01-10,liquid:num:1510000.00 + \\date::2016-01-17,liquid:num:1520000.00 + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data }); + + const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + // Request a date between rows — should snap to the latest <= date. + const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2016, 1, 14)); + try testing.expectEqual(AsOfSourceKind.imported, r.source); + try testing.expect(!r.exact); + try testing.expectEqual(@as(i32, Date.fromYmd(2016, 1, 10).days), r.actual.days); + try testing.expectEqual(@as(f64, 1_510_000), r.liquid); +} + +test "resolveAsOfDate: snapshot wins over imported when both present" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" }); + const iv_data = + \\#!srfv1 + \\date::2024-04-01,liquid:num:9999999.00 + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data }); + + const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1)); + try testing.expectEqual(AsOfSourceKind.snapshot, r.source); +} + +test "resolveAsOfDate: requested predates all data returns NoDataAtOrBefore" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const iv_data = + \\#!srfv1 + \\date::2016-01-03,liquid:num:1500000.00 + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data }); + + const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const result = resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2015, 1, 1)); + try testing.expectError(error.NoDataAtOrBefore, result); +} + +test "resolveAsOfDate: empty history dir returns NoDataAtOrBefore" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const result = resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15)); + try testing.expectError(error.NoDataAtOrBefore, result); +} + +test "resolveAsOfDate: snapshot at later date but imported earlier — snapshot wins (different dates)" { + // Edge: the imported date is older than the snapshot date AND + // the requested date matches the snapshot exactly. Snapshot + // wins (exact match path). + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "2024-04-01-portfolio.srf", .data = "" }); + const iv_data = + \\#!srfv1 + \\date::2016-01-03,liquid:num:1500000.00 + \\date::2024-03-01,liquid:num:2000000.00 + \\ + ; + try tmp.dir.writeFile(io, .{ .sub_path = "imported_values.srf", .data = iv_data }); + + const hist_dir = try tmp.dir.realPathFileAlloc(io, ".", testing.allocator); + defer testing.allocator.free(hist_dir); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + // Request the snapshot date — exact snapshot hit. + const r = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 4, 1)); + try testing.expectEqual(AsOfSourceKind.snapshot, r.source); + try testing.expect(r.exact); + + // Request a date between the two imported rows but BEFORE the + // snapshot — should fall through to imported (no snapshot + // at/before that date). + const r2 = try resolveAsOfDate(io, arena.allocator(), hist_dir, Date.fromYmd(2020, 6, 15)); + try testing.expectEqual(AsOfSourceKind.imported, r2.source); + try testing.expectEqual(@as(i32, Date.fromYmd(2016, 1, 3).days), r2.actual.days); +} diff --git a/src/main.zig b/src/main.zig index ea07688..d4a6687 100644 --- a/src/main.zig +++ b/src/main.zig @@ -97,6 +97,13 @@ const usage = \\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'. \\ Auto-snaps to nearest-earlier snapshot if the \\ exact date has no snapshot file. + \\ --overlay-actuals Plot the realized portfolio trajectory from --as-of + \\ up to today on top of the projected percentile + \\ bands. Requires --as-of. The TUI projections tab + \\ is the higher-fidelity surface (press `o` after + \\ setting an as-of date with `d`). Caveat: this shows + \\ whether the model was directionally honest, NOT + \\ whether the SWR claim was accurate. \\ --vs Compact side-by-side comparison: projected return \\ and safe-withdrawal @99% for live vs DATE, with \\ deltas. Combine with --as-of to compare two @@ -517,11 +524,14 @@ fn runCli(init: std.process.Init) !u8 { var events_enabled = true; var as_of: ?zfin.Date = null; var vs_date: ?zfin.Date = null; + var overlay_actuals = false; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { const a = cmd_args[i]; if (std.mem.eql(u8, a, "--no-events")) { events_enabled = false; + } else if (std.mem.eql(u8, a, "--overlay-actuals")) { + overlay_actuals = true; } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) { if (i + 1 >= cmd_args.len) { try cli.stderrPrint(io, "Error: "); @@ -567,9 +577,12 @@ fn runCli(init: std.process.Init) !u8 { // live against a historical date; `--vs X --as-of Y` // compares two historical dates with Y being the later // one. + if (overlay_actuals) { + try cli.stderrPrint(io, "Note: --overlay-actuals is ignored in --vs compare mode.\n"); + } try commands.projections.runCompare(io, allocator, &svc, pf.path, events_enabled, d, as_of orelse today, as_of != null, color, out); } else { - try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, color, out); + try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, today, overlay_actuals, color, out); } } else if (std.mem.eql(u8, command, "contributions")) { var since: ?zfin.Date = null; diff --git a/src/tui.zig b/src/tui.zig index 93e3a6f..9d42eda 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -468,6 +468,12 @@ pub const App = struct { /// the user actually typed — surfaced in the tab header as a muted /// "(requested X; snapped to Y, N days earlier)" note. projections_as_of_requested: ?zfin.Date = null, + /// When true, the projections chart overlays the realized + /// portfolio trajectory (snapshots + imported_values) on top of + /// the percentile bands. Toggled by the `o` keybind. Only + /// meaningful when `projections_as_of` is set; the keybind + /// flashes a status message and leaves this off otherwise. + projections_overlay_actuals: bool = false, // Default to `.liquid` — that's the metric most worth watching // day-to-day. Illiquid barely changes, net_worth is dominated by // liquid anyway, so "show me liquid" is the headline view. @@ -1119,6 +1125,7 @@ pub const App = struct { if (self.active_tab == .projections and self.projections_as_of != null) { self.projections_as_of = null; self.projections_as_of_requested = null; + self.projections_overlay_actuals = false; projections_tab.freeLoaded(self); self.projections_loaded = false; projections_tab.loadData(self); @@ -1319,12 +1326,42 @@ pub const App = struct { } }, .sort_reverse => { + // The `o` keybind dual-dispatches by active tab: + // - portfolio → flip the sort direction + // - projections → toggle the actuals-overlay + // `matchAction` is first-match-wins so we can't have + // separate Action variants share a codepoint; routing + // via the active tab in the handler is the project's + // existing pattern (see also `s` → compare_select / + // select_symbol). The action name stays `sort_reverse` + // because portfolio was the first consumer. if (self.active_tab == .portfolio) { self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); return ctx.consumeAndRedraw(); } + if (self.active_tab == .projections) { + if (self.projections_as_of == null) { + self.setStatus("Overlay only available with --as-of (press d to set)"); + return ctx.consumeAndRedraw(); + } + self.projections_overlay_actuals = !self.projections_overlay_actuals; + // Re-run loadData so the overlay section gets + // built (or freed). The timeline load is the + // expensive bit but it's rare — humans toggle + // this maybe a few times per session. + projections_tab.freeLoaded(self); + self.projections_loaded = false; + projections_tab.loadData(self); + self.projections_chart_dirty = true; + if (self.projections_overlay_actuals) { + self.setStatus("Overlay: ON — tracks trajectory, not SWR validity"); + } else { + self.setStatus("Overlay: OFF"); + } + return ctx.consumeAndRedraw(); + } }, .account_filter => { if (self.active_tab == .portfolio and self.portfolio != null) { diff --git a/src/tui/projection_chart.zig b/src/tui/projection_chart.zig index 85a8dad..e4f3fdb 100644 --- a/src/tui/projection_chart.zig +++ b/src/tui/projection_chart.zig @@ -5,9 +5,13 @@ //! Visual layers (bottom to top): //! - Background //! - Horizontal grid lines +//! - "Today" vertical reference line (when overlay is active) //! - p10-p90 band fill (faint) //! - p25-p75 band fill (medium) +//! - p10/p90 boundary lines //! - Median (p50) line (solid) +//! - Zero line (if visible) +//! - Actuals overlay line (when present) //! - Panel border const std = @import("std"); @@ -25,6 +29,25 @@ const margin_right: f64 = 4; const margin_top: f64 = 4; const margin_bottom: f64 = 4; +/// Single (year-offset, liquid-value) point on the actuals overlay. +/// Mirrors `views/projections.ActualsPoint` but kept duplicated here +/// so the chart module doesn't depend on the views layer. +pub const ActualsPoint = struct { + years_from_as_of: f64, + liquid: f64, +}; + +/// Optional actuals-overlay input. When non-null, the chart draws: +/// - A thin vertical "today" reference line at `today_years`. +/// - A connected line through `points` (cyan), wider than 1px so +/// it reads against the bands. +/// Y-range is expanded to include any actuals values that exceed +/// the band envelope, so the line never clips off-chart. +pub const ActualsOverlay = struct { + points: []const ActualsPoint, + today_years: f64, +}; + /// Projection chart render result. pub const ProjectionChartResult = struct { /// Raw RGB pixel data (3 bytes per pixel, row-major). @@ -40,6 +63,7 @@ pub const ProjectionChartResult = struct { /// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line. /// /// `bands` is the array of YearPercentiles (year 0 through horizon). +/// `actuals` is an optional realized-trajectory overlay. /// The returned rgb_data is allocated with `alloc` and must be freed by caller. pub fn renderProjectionChart( io: std.Io, @@ -48,6 +72,7 @@ pub fn renderProjectionChart( width_px: u32, height_px: u32, th: theme.Theme, + actuals: ?ActualsOverlay, ) !ProjectionChartResult { if (bands.len < 2) return error.InsufficientData; @@ -90,6 +115,15 @@ pub fn renderProjectionChart( if (bp.p10 < value_min) value_min = bp.p10; if (bp.p90 > value_max) value_max = bp.p90; } + // Expand to include any actuals that punch through the band + // envelope. Without this, a portfolio that out- or under-performed + // the model would clip off-chart. + if (actuals) |ov| { + for (ov.points) |p| { + if (p.liquid < value_min) value_min = p.liquid; + if (p.liquid > value_max) value_max = p.liquid; + } + } // Add 5% padding const pad = (value_max - value_min) * 0.05; value_min -= pad; @@ -103,6 +137,22 @@ pub fn renderProjectionChart( const grid_color = blendColor(th.text_muted, 40, bg); try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color); + // ── "Today" vertical reference line (overlay only) ─────────── + // + // When the actuals overlay is active, draw a thin vertical line + // at `today_years` along the x-axis. This visually separates the + // realized past (left of the line) from the projected future + // (right of the line). Drawn before bands so it sits behind the + // data — a quiet reference, not a focal point. + if (actuals) |ov| { + const horizon_years: f64 = @floatFromInt(bands.len - 1); + if (horizon_years > 0 and ov.today_years >= 0 and ov.today_years <= horizon_years) { + const today_x = chart_left + (ov.today_years / horizon_years) * chart_w; + const today_color = blendColor(th.text_muted, 100, bg); + try drawVLine(&ctx, today_x, chart_top, chart_bottom, today_color, 1.0); + } + } + // ── p10-p90 outer band fill ────────────────────────────────── { const band_color = blendColor(th.accent, 20, bg); @@ -194,6 +244,39 @@ pub fn renderProjectionChart( try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0); } + // ── Actuals overlay line ───────────────────────────────────── + // + // Drawn after all band-related layers (and the zero line) so the + // realized trajectory sits on top of the projection envelope. + // Cyan (`th.info`) keeps it visually distinct from the purple + // band/median palette. Slightly thinner than the median (1.5 vs + // 2.0) so the median stays the anchor of the projection. + if (actuals) |ov| { + if (ov.points.len >= 2) { + const horizon_years: f64 = @floatFromInt(bands.len - 1); + const line_color = opaqueColor(th.info); + ctx.setSourceToPixel(line_color); + ctx.setLineWidth(1.5); + ctx.resetPath(); + for (ov.points, 0..) |p, i| { + // Clamp to chart bounds — overlay points should + // always be in [0, today_years] which is <= horizon, + // but defending against bad input is cheap. + const yr = if (horizon_years > 0) + @max(0.0, @min(p.years_from_as_of, horizon_years)) + else + 0.0; + const x = chart_left + if (horizon_years > 0) + (yr / horizon_years) * chart_w + else + 0.0; + const y = mapY(p.liquid, value_min, value_max, chart_top, chart_bottom); + if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.stroke(); + } + } + // ── Panel border ───────────────────────────────────────────── { const border_color = blendColor(th.border, 80, bg); @@ -276,6 +359,16 @@ fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) ! ctx.setLineWidth(2.0); } +fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void { + ctx.setSourceToPixel(col); + ctx.setLineWidth(line_w); + ctx.resetPath(); + try ctx.moveTo(x, y1); + try ctx.lineTo(x, y2); + try ctx.stroke(); + ctx.setLineWidth(2.0); +} + fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void { ctx.setSourceToPixel(col); ctx.setLineWidth(line_w); @@ -321,7 +414,7 @@ test "renderProjectionChart produces valid output" { }; const th = @import("theme.zig").default_theme; - const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th); + const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null); defer alloc.free(result.rgb_data); try std.testing.expectEqual(@as(u16, 200), result.width); @@ -337,6 +430,66 @@ test "renderProjectionChart insufficient data" { }; const th = @import("theme.zig").default_theme; - const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th); + const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null); try std.testing.expectError(error.InsufficientData, result); } + +test "renderProjectionChart with overlay produces valid output" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 }, + .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 }, + .{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 }, + }; + const points = [_]ActualsPoint{ + .{ .years_from_as_of = 0.0, .liquid = 8000000 }, + .{ .years_from_as_of = 0.5, .liquid = 8500000 }, + .{ .years_from_as_of = 1.0, .liquid = 9200000 }, + }; + const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 }; + + const th = @import("theme.zig").default_theme; + const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); + defer alloc.free(result.rgb_data); + + try std.testing.expectEqual(@as(u16, 200), result.width); + try std.testing.expectEqual(@as(u16, 100), result.height); + try std.testing.expect(result.value_max > result.value_min); +} + +test "renderProjectionChart overlay expands y-range when actuals exceed bands" { + const alloc = std.testing.allocator; + // Bands top out at 25M + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 }, + .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 }, + }; + // Actuals punch through the top band at 50M + const points = [_]ActualsPoint{ + .{ .years_from_as_of = 0.0, .liquid = 8000000 }, + .{ .years_from_as_of = 1.0, .liquid = 50000000 }, + }; + const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 }; + + const th = @import("theme.zig").default_theme; + const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); + defer alloc.free(result.rgb_data); + + // Without expansion, value_max would be ~25M (band p90 + 5%). + // With expansion to include actuals, it must be >= 50M. + try std.testing.expect(result.value_max >= 50_000_000); +} + +test "renderProjectionChart overlay with no points renders without crash" { + const alloc = std.testing.allocator; + const bands = [_]projections.YearPercentiles{ + .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 }, + .{ .year = 10, .p10 = 5000000, .p25 = 8000000, .p50 = 12000000, .p75 = 18000000, .p90 = 25000000 }, + }; + const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 }; + + const th = @import("theme.zig").default_theme; + const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); + defer alloc.free(result.rgb_data); + try std.testing.expect(result.rgb_data.len > 0); +} diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index a85d9c8..af7b009 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -63,12 +63,13 @@ pub fn loadData(app: *App) void { as_of: { const requested_date = app.projections_as_of orelse break :as_of; - const actual_date = resolveSnapshotDate(app, portfolio_path, requested_date) orelse { - // `setStatus` already called by resolveSnapshotDate. + const resolution = resolveAsOf(app, portfolio_path, requested_date) orelse { + // `setStatus` already called by resolveAsOf. app.projections_as_of = null; app.projections_as_of_requested = null; break :as_of; }; + const actual_date = resolution.actual; app.projections_as_of = actual_date; // Preserve requested for the header note; clear if it matches actual. if (actual_date.eql(requested_date)) { @@ -83,30 +84,85 @@ pub fn loadData(app: *App) void { }; defer app.allocator.free(hist_dir); - var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch { - app.setStatus("Failed to load snapshot — showing live"); - app.projections_as_of = null; - app.projections_as_of_requested = null; - break :as_of; - }; - defer loaded.deinit(app.allocator); + const ctx = switch (resolution.source) { + .snapshot => snap: { + var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch { + app.setStatus("Failed to load snapshot — showing live"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + defer loaded.deinit(app.allocator); - const ctx = view.loadProjectionContextAsOf( - app.io, - app.allocator, - portfolio_dir, - &loaded.snap, - actual_date, - app.svc, - app.projections_events_enabled, - ) catch { - app.setStatus("Failed to compute as-of projections — showing live"); - app.projections_as_of = null; - app.projections_as_of_requested = null; - break :as_of; + break :snap view.loadProjectionContextAsOf( + app.io, + app.allocator, + portfolio_dir, + &loaded.snap, + actual_date, + app.svc, + app.projections_events_enabled, + ) catch { + app.setStatus("Failed to compute as-of projections — showing live"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + }, + .imported => imp: { + // Imported-only as-of: scale today's allocations to + // the imported liquid total. Requires the live + // portfolio summary, which the portfolio tab loads + // up-front into `app.portfolio_summary`. + const summary = app.portfolio_summary orelse { + app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + const portfolio = app.portfolio orelse { + app.setStatus("Imported as-of needs live portfolio — visit Portfolio tab first"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + + break :imp view.loadProjectionContextFromImported( + app.io, + app.allocator, + portfolio_dir, + summary.allocations, + summary.total_value, + portfolio.totalCash(app.today), + portfolio.totalCdFaceValue(app.today), + resolution.liquid, + actual_date, + app.svc, + app.projections_events_enabled, + ) catch { + app.setStatus("Failed to compute as-of projections — showing live"); + app.projections_as_of = null; + app.projections_as_of_requested = null; + break :as_of; + }; + }, }; - app.projections_ctx = ctx; + var ctx_with_overlay = ctx; + // Attach the actuals overlay if the toggle is on. Failures + // here are non-fatal — the chart still renders without the + // overlay; the toggle stays on so the user knows the intent. + if (app.projections_overlay_actuals) { + if (loadOverlayActuals(app, portfolio_path, actual_date)) |ov| { + ctx_with_overlay.overlay_actuals = ov; + } else |_| { + // Silent — the chart-render path will simply not + // draw an overlay layer. Status would be noisy on + // every redraw. + } + } + + app.projections_ctx = ctx_with_overlay; return; } @@ -139,14 +195,14 @@ pub fn loadData(app: *App) void { } /// Resolve the user's requested as-of date against the portfolio's -/// history directory. Returns the actual date to load (exact match or -/// nearest-earlier snapshot), or null with a status-bar message if -/// no usable snapshot exists. +/// history directory, accepting either a native snapshot OR an +/// `imported_values.srf` row. Returns the resolved record, or null +/// with a status-bar message if no usable data exists. /// -/// Thin adapter over `history.resolveSnapshotDate` — the shared pure -/// resolver owns the exact/snap logic; this wrapper maps its errors -/// to user-visible status-bar messages and handles the arena. -fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?zfin.Date { +/// Thin adapter over `history.resolveAsOfDate` — the shared pure +/// resolver owns exact-then-fallback logic; this wrapper maps its +/// errors to user-visible status-bar messages and handles the arena. +fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf { var arena_state = std.heap.ArenaAllocator.init(app.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); @@ -156,19 +212,19 @@ fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Da return null; }; - const resolved = history.resolveSnapshotDate(app.io, arena, hist_dir, requested) catch |err| switch (err) { - error.NoSnapshotAtOrBefore => { + const resolved = history.resolveAsOfDate(app.io, arena, hist_dir, requested) catch |err| switch (err) { + error.NoDataAtOrBefore => { var status_buf: [128]u8 = undefined; - const msg = std.fmt.bufPrint(&status_buf, "No snapshot at or before {f}", .{requested}) catch "No snapshot at or before requested date"; + const msg = std.fmt.bufPrint(&status_buf, "No snapshot or imported value at or before {f}", .{requested}) catch "No data at or before requested date"; app.setStatus(msg); return null; }, error.OutOfMemory => { - app.setStatus("Out of memory resolving snapshot"); + app.setStatus("Out of memory resolving as-of"); return null; }, else => { - app.setStatus("Error accessing snapshot"); + app.setStatus("Error accessing as-of data"); return null; }, }; @@ -177,17 +233,28 @@ fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Da // Remember the original request for the muted header note. app.projections_as_of_requested = requested; } - return resolved.actual; + return resolved; +} + +/// Load the merged history timeline (snapshots + imported_values) +/// and produce an overlay section for the projections chart. +/// Allocates the resulting `OverlayActualsSection.points` slice +/// from `app.allocator` so it survives until `freeLoaded` runs. +fn loadOverlayActuals(app: *App, portfolio_path: []const u8, as_of: zfin.Date) !view.OverlayActualsSection { + var loaded = try history.loadTimeline(app.io, app.allocator, portfolio_path); + defer loaded.deinit(); + return try view.buildOverlayActuals(app.allocator, loaded.series.points, as_of, app.today); } pub fn freeLoaded(app: *App) void { - if (app.projections_ctx) |ctx| { + if (app.projections_ctx) |*ctx| { app.allocator.free(ctx.data.withdrawals); for (ctx.data.bands) |b| { if (b) |slice| app.allocator.free(slice); } app.allocator.free(ctx.data.bands); if (ctx.earliest) |er| app.allocator.free(er); + if (ctx.overlay_actuals) |*ov| ov.deinit(); } app.projections_ctx = null; // Mark projection chart as dirty so it re-renders on next draw @@ -299,6 +366,28 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, } if (app.vx_app) |va| { + // Build the actuals overlay only when overlay is on AND + // we're in as-of mode. The overlay is meaningless without + // an as-of anchor (no projected future to overlay onto). + // + // Copy the view's ActualsPoint slice into the chart's + // ActualsPoint slice — same field shape, but distinct + // types so the chart module stays leaf-level (no view + // dependency). Render-scoped allocation; fine to do per + // dirty redraw because the overlay is at most ~12 years + // of weekly data (~600 points). + const overlay_input: ?projection_chart.ActualsOverlay = blk: { + if (!app.projections_overlay_actuals) break :blk null; + const ctx_data = app.projections_ctx orelse break :blk null; + const ov = ctx_data.overlay_actuals orelse break :blk null; + const ov_buf = app.allocator.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk null; + for (ov.points, 0..) |p, idx| { + ov_buf[idx] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; + } + break :blk .{ .points = ov_buf, .today_years = ov.today_years }; + }; + defer if (overlay_input) |ov| app.allocator.free(@constCast(ov.points)); + const chart_result = projection_chart.renderProjectionChart( app.io, app.allocator, @@ -306,6 +395,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, capped_w, capped_h, th, + overlay_input, ) catch { app.projections_chart_dirty = false; return; @@ -814,7 +904,12 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine // with the main content. If the user asked for a date that had no // exact snapshot, a second muted line explains the auto-snap. if (app.projections_as_of) |actual| { - const header = try std.fmt.allocPrint(arena, " As-of: {f} (snapshot)", .{actual}); + const source_label: []const u8 = if (app.projections_ctx) |c| switch (c.as_of_source) { + .snapshot => "snapshot", + .imported => "imported", + .live => "live", + } else "snapshot"; + const header = try std.fmt.allocPrint(arena, " As-of: {f} ({s})", .{ actual, source_label }); try lines.append(arena, .{ .text = header, .style = th.mutedStyle() }); if (app.projections_as_of_requested) |requested| { @@ -828,6 +923,22 @@ pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine try lines.append(arena, .{ .text = note, .style = th.mutedStyle() }); } } + if (app.projections_ctx) |c| { + if (c.as_of_source == .imported) { + try lines.append(arena, .{ + .text = " (bands use today's allocation scaled to the imported liquid total)", + .style = th.mutedStyle(), + }); + } + } + if (app.projections_overlay_actuals) { + const ov_note = try std.fmt.allocPrint( + arena, + " Overlay: actuals from {f} · tracks trajectory, not SWR validity", + .{actual}, + ); + try lines.append(arena, .{ .text = ov_note, .style = th.infoStyle() }); + } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); } diff --git a/src/tui/theme.zig b/src/tui/theme.zig index b4e3666..55a53ce 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -120,6 +120,14 @@ pub const Theme = struct { return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) }; } + /// Cyan-ish style for informational / overlay content. Distinct + /// from `accent` (purple — used by the projection-chart median + /// line and bands) and `warning` (yellow). Used for the actuals + /// overlay status-line indicator. + pub fn infoStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.info), .bg = vcolor(self.bg) }; + } + /// Map a semantic StyleIntent to a vaxis style. pub fn styleFor(self: Theme, intent: fmt.StyleIntent) vaxis.Style { return switch (intent) { diff --git a/src/views/projections.zig b/src/views/projections.zig index 3a1d488..399a758 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -8,6 +8,7 @@ const Money = @import("../Money.zig"); const performance = @import("../analytics/performance.zig"); const benchmark = @import("../analytics/benchmark.zig"); const projections = @import("../analytics/projections.zig"); +const timeline = @import("../analytics/timeline.zig"); const valuation = @import("../analytics/valuation.zig"); const zfin = @import("../root.zig"); const snapshot_model = @import("../models/snapshot.zig"); @@ -159,8 +160,23 @@ pub const ProjectionContext = struct { /// Which retirement-planning inputs the user configured. Drives /// which display blocks render. inputs: ProjectionInputs = .distribution_only, + /// Realized trajectory overlay (snapshots + imported_values). + /// Populated only when the user passed `--overlay-actuals` (CLI) + /// or toggled `o` in the TUI projections tab. Requires `as_of` + /// to be set; meaningless in live mode. + overlay_actuals: ?OverlayActualsSection = null, + /// Where the as-of context came from. `.live` for normal "now" + /// mode (no `--as-of`); `.snapshot` when the as-of date had a + /// native `*-portfolio.srf` snapshot; `.imported` when only an + /// `imported_values.srf` row was available and today's + /// allocations had to be scaled to the imported liquid total. + /// Drives the header note ("As-of: ... (snapshot)" vs "(imported)") + /// so users know how literal the bands are. + as_of_source: AsOfSource = .live, }; +pub const AsOfSource = enum { live, snapshot, imported }; + /// Statistics extracted from the bands at the retirement-boundary /// year. Used to render the median portfolio at retirement and the /// p10–p90 range under the "Accumulation phase" display block. @@ -172,6 +188,90 @@ pub const AccumulationStats = struct { contribution_inflation_adjusted: bool, }; +/// One realized (date, total liquid) point on the actuals overlay, +/// expressed in years from the as-of date so the chart can place it +/// on the same x-axis as the projected percentile bands. +pub const ActualsPoint = struct { + /// Years since `as_of` (as_of itself = 0.0). Uses 365.25 to + /// match the rest of the codebase's year math (`Date.yearsBetween`). + years_from_as_of: f64, + /// Total liquid value on that date, in nominal dollars. + liquid: f64, +}; + +/// View-model section produced when `--overlay-actuals` is active. +/// Carries the realized-trajectory points to render on top of the +/// percentile-band chart, plus the "today" position for the +/// vertical reference line. +/// +/// Caveat (must be surfaced in any UI consuming this section): +/// this overlay shows whether the model was directionally honest, +/// not whether the SWR claim was accurate. The SWR claim is a +/// 30-year claim; we have at most ~12 years of weekly history. +pub const OverlayActualsSection = struct { + points: []ActualsPoint, + /// Years from `as_of` to today. The actuals line ends here; + /// beyond this x-position only bands are visible. + today_years: f64, + /// The as-of date the overlay is anchored to; surfaced for + /// status-line / footnote display ("actuals from YYYY-MM-DD"). + as_of: Date, + allocator: std.mem.Allocator, + + pub fn deinit(self: *OverlayActualsSection) void { + self.allocator.free(self.points); + } +}; + +/// Build an `OverlayActualsSection` from a merged `TimelineSeries` +/// (snapshots + imported_values). Filters to `as_of..today`, +/// converts each point's date to fractional years from `as_of`, and +/// pulls the liquid total. Snapshot precedence is already handled +/// upstream by `timeline.buildMergedSeries`. +/// +/// Returns an empty section (no error) when the timeline yields no +/// points in range — the caller still toggles the overlay on, the +/// chart simply has no actuals line to draw. +pub fn buildOverlayActuals( + allocator: std.mem.Allocator, + timeline_points: []const timeline.TimelinePoint, + as_of: Date, + today: Date, +) !OverlayActualsSection { + const days_per_year: f64 = 365.25; + + // Count first so we can size the slice exactly. + var keep: usize = 0; + for (timeline_points) |p| { + if (p.as_of_date.days < as_of.days) continue; + if (p.as_of_date.days > today.days) continue; + keep += 1; + } + + const out = try allocator.alloc(ActualsPoint, keep); + errdefer allocator.free(out); + + var i: usize = 0; + for (timeline_points) |p| { + if (p.as_of_date.days < as_of.days) continue; + if (p.as_of_date.days > today.days) continue; + const days_since: f64 = @floatFromInt(p.as_of_date.days - as_of.days); + out[i] = .{ + .years_from_as_of = days_since / days_per_year, + .liquid = p.liquid, + }; + i += 1; + } + + const today_days: f64 = @floatFromInt(today.days - as_of.days); + return .{ + .points = out, + .today_years = today_days / days_per_year, + .as_of = as_of, + .allocator = allocator, + }; +} + /// Which retirement-planning inputs the user has configured. /// /// The simulation always runs the same two-phase model @@ -450,7 +550,7 @@ pub fn loadProjectionContextAsOf( var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap); defer snap_allocs.deinit(alloc); - return buildContextFromParts( + var ctx = try buildContextFromParts( io, alloc, portfolio_dir, @@ -462,6 +562,84 @@ pub fn loadProjectionContextAsOf( events_enabled, as_of_date, ); + ctx.as_of_source = .snapshot; + return ctx; +} + +/// Build a `ProjectionContext` for an as-of date that has only an +/// `imported_values.srf` row — no native `*-portfolio.srf` snapshot. +/// +/// We can't reconstruct the historical lot composition from just a +/// `liquid` total, so we use **today's allocations** scaled to the +/// imported liquid value. The simulation then runs with: +/// - Today's stock/bond split (ratio-preserving), +/// - Per-position trailing returns truncated to <= as_of, +/// - `total_value = imported_liquid` (the simulation starting point), +/// - Cash/CD totals scaled by the same factor. +/// +/// This is the best approximation available for back-dated runs that +/// predate native snapshots — useful for users with weekly imported +/// history going back years. Limitations are documented in the +/// caveat surfaced by the calling display layer. +/// +/// Caller-provided `live_allocations` is from the current portfolio +/// (live `portfolioSummary().allocations`), `live_total_value` is the +/// matching `summary.total_value`, etc. The function builds a +/// freshly-allocated, scaled copy of the allocations slice and frees +/// it before returning — `buildContextFromParts` only borrows +/// `allocations` during the build (to derive the stock/bond split +/// and per-position trailing returns) and doesn't store it on the +/// context. Same lifetime convention as +/// `loadProjectionContextAsOf`, which similarly frees its +/// snapshot-derived allocations slice via `defer snap_allocs.deinit`. +pub fn loadProjectionContextFromImported( + io: std.Io, + alloc: std.mem.Allocator, + portfolio_dir: []const u8, + live_allocations: []const valuation.Allocation, + live_total_value: f64, + live_cash_value: f64, + live_cd_value: f64, + imported_liquid: f64, + as_of_date: Date, + svc: *zfin.DataService, + events_enabled: bool, +) !ProjectionContext { + // Defensive: degenerate live total. Skip scaling and pass through; + // simulation will produce flat bands anchored at imported_liquid + // either way. + const scale: f64 = if (live_total_value > 0) imported_liquid / live_total_value else 1.0; + + // Scale a copy of the allocations. The slice is consumed by + // `buildContextFromParts` during the build (split derivation, + // per-position trailing returns) but not retained on the + // returned context — so we free it here, mirroring the snapshot + // path's `defer snap_allocs.deinit(alloc)`. + const scaled = try alloc.alloc(valuation.Allocation, live_allocations.len); + defer alloc.free(scaled); + for (live_allocations, 0..) |a, i| { + scaled[i] = a; + scaled[i].market_value = a.market_value * scale; + // Weight is preserved (it's a ratio); shares/cost stay as + // today's because the simulation only consumes weight + + // total_value. Carrying real share counts at imported scale + // would be misleading — they don't reflect history. + } + + var ctx = try buildContextFromParts( + io, + alloc, + portfolio_dir, + scaled, + imported_liquid, + live_cash_value * scale, + live_cd_value * scale, + svc, + events_enabled, + as_of_date, + ); + ctx.as_of_source = .imported; + return ctx; } /// Shared core: build a `ProjectionContext` from pre-computed @@ -1481,3 +1659,132 @@ test "buildProjectionContext: both_targets inputs when both fields configured" { try std.testing.expect(ctx.accumulation != null); try std.testing.expect(ctx.earliest != null); } + +// ── Overlay-actuals tests ───────────────────────────────────── + +/// Build a TimelinePoint with just the date and liquid value +/// — the overlay only reads those two fields. Empty accounts / +/// tax_types are fine; we never deinit these synthetic points. +fn makeTp(date: Date, liquid: f64) timeline.TimelinePoint { + return .{ + .as_of_date = date, + .net_worth = liquid, + .liquid = liquid, + .illiquid = 0, + .accounts = &.{}, + .tax_types = &.{}, + .source = .snapshot, + }; +} + +test "buildOverlayActuals: empty input produces empty section" { + var section = try buildOverlayActuals( + std.testing.allocator, + &.{}, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2025, 1, 1), + ); + defer section.deinit(); + try std.testing.expectEqual(@as(usize, 0), section.points.len); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01); +} + +test "buildOverlayActuals: single point at as_of has years=0" { + const points = [_]timeline.TimelinePoint{ + makeTp(Date.fromYmd(2024, 1, 1), 1_000_000), + }; + var section = try buildOverlayActuals( + std.testing.allocator, + &points, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2024, 1, 1), + ); + defer section.deinit(); + try std.testing.expectEqual(@as(usize, 1), section.points.len); + try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of); + try std.testing.expectEqual(@as(f64, 1_000_000), section.points[0].liquid); + try std.testing.expectEqual(@as(f64, 0.0), section.today_years); +} + +test "buildOverlayActuals: filters out points before as_of and after today" { + const points = [_]timeline.TimelinePoint{ + makeTp(Date.fromYmd(2023, 6, 1), 800_000), // before as_of - excluded + makeTp(Date.fromYmd(2024, 1, 1), 1_000_000), + makeTp(Date.fromYmd(2024, 7, 1), 1_100_000), + makeTp(Date.fromYmd(2025, 1, 1), 1_200_000), + makeTp(Date.fromYmd(2025, 6, 1), 1_300_000), // after today - excluded + }; + var section = try buildOverlayActuals( + std.testing.allocator, + &points, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2025, 1, 1), + ); + defer section.deinit(); + try std.testing.expectEqual(@as(usize, 3), section.points.len); + // First point is at as_of itself, last point is at today. + try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of); + try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.points[2].years_from_as_of, 0.01); +} + +test "buildOverlayActuals: years_from_as_of math is monotonic" { + const points = [_]timeline.TimelinePoint{ + makeTp(Date.fromYmd(2024, 1, 1), 1_000_000), + makeTp(Date.fromYmd(2024, 7, 1), 1_100_000), + makeTp(Date.fromYmd(2025, 1, 1), 1_200_000), + }; + var section = try buildOverlayActuals( + std.testing.allocator, + &points, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2025, 1, 1), + ); + defer section.deinit(); + var prev: f64 = -1.0; + for (section.points) |p| { + try std.testing.expect(p.years_from_as_of > prev); + prev = p.years_from_as_of; + } +} + +test "buildOverlayActuals: today_years matches today - as_of" { + var section = try buildOverlayActuals( + std.testing.allocator, + &.{}, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2026, 1, 1), + ); + defer section.deinit(); + // Two calendar years = 731 days / 365.25 ≈ 2.001 years. + try std.testing.expectApproxEqAbs(@as(f64, 2.0), section.today_years, 0.01); +} + +test "buildOverlayActuals: liquid value is preserved verbatim" { + const points = [_]timeline.TimelinePoint{ + makeTp(Date.fromYmd(2024, 6, 15), 1_234_567.89), + }; + var section = try buildOverlayActuals( + std.testing.allocator, + &points, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2025, 1, 1), + ); + defer section.deinit(); + try std.testing.expectEqual(@as(f64, 1_234_567.89), section.points[0].liquid); +} + +test "buildOverlayActuals: empty range (today < as_of) produces empty points" { + const points = [_]timeline.TimelinePoint{ + makeTp(Date.fromYmd(2024, 6, 1), 1_000_000), + }; + var section = try buildOverlayActuals( + std.testing.allocator, + &points, + Date.fromYmd(2025, 1, 1), + Date.fromYmd(2024, 1, 1), // today before as_of (degenerate) + ); + defer section.deinit(); + // Filter is `>= as_of AND <= today`. With today < as_of, no points + // can satisfy both — section is empty. + try std.testing.expectEqual(@as(usize, 0), section.points.len); +}