initial implementation of projections actual (note TODO comments)

This commit is contained in:
Emil Lerch 2026-05-13 13:05:01 -07:00
parent bda38e3c05
commit cf948d607b
10 changed files with 1429 additions and 102 deletions

328
TODO.md
View file

@ -29,29 +29,317 @@ manual-check accounts mechanism (medium effort, real user value).
- Multiple spending models: flat (current), decreasing (1-2% real annual decrease, - Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
Blanchett "spending smile"). Late-life healthcare better modeled as a life event. Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
- Unclassified position handling in allocation split (warn user) - Unclassified position handling in allocation split (warn user)
- Historical projection comparison: re-run projections from any past snapshot date, - **Historical projection overlay follow-ups.** The base
overlay actual portfolio trajectory from subsequent snapshots onto the projected `--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
percentile bands. Shows how reality tracked against the model. Data is already Open enhancements:
available in history/*.srf snapshots — just need to load a historical portfolio - Historical `metadata.srf` / `projections.srf` for back-dated
value and re-run `computePercentileBands` with that starting point, then plot runs. Today the overlay re-runs against current classifications
actual values from later snapshots as a line overlaid on the bands. 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 <DATE>` already reruns the simulation ## Export chart as PNG (`--export-chart <path>`) — priority MEDIUM
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 z2d already supports PNG export natively. Today the chart-bearing
(currently building up; meaningful backtest requires 12+ months). commands (`quote`, `history`, `projections`, plus the equivalent TUI
Backfilling from git history is not viable — the lot-level state on tabs) render to braille (CLI) or Kitty graphics (TUI). Adding a
portfolio.srf at a past commit is insufficient to reconstruct the `--export-chart <path>` flag would land just the chart (not the
full transaction+contribution picture. Revisit once there are 12+ surrounding text output) as a PNG file at the given path, at full
months of continuous snapshot data. fidelity, regardless of which surface invoked it.
Also consider: `metadata.srf` and `projections.srf` classifications / Driver: when reviewing a back-dated projection or a notable price
assumptions drift over time. For back-dated runs we currently use move, capturing the chart as an image (e.g. for a write-up, an email
the current versions of both; historical git-tracked versions could to the household, or a wiki page) is currently a screenshot-and-crop
be checked out and loaded instead. Edge case for now. 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.<tab>_<field>` references → `app.<tab>.<field>`.
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 12 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, Riley's ESPP, etc.) — priority HIGH ## Audit: manual-check accounts mechanism (NYL, Riley's ESPP, etc.) — priority HIGH

View file

@ -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 // Watchlist loading
/// Load a watchlist SRF file containing symbol records. /// Load a watchlist SRF file containing symbol records.

View file

@ -20,6 +20,7 @@ const benchmark = @import("../analytics/benchmark.zig");
const valuation = @import("../analytics/valuation.zig"); const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig"); const view = @import("../views/projections.zig");
const history = @import("../history.zig"); const history = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig");
/// Hardcoded benchmark symbols (configurable in a future version). /// Hardcoded benchmark symbols (configurable in a future version).
const stock_benchmark = "SPY"; const stock_benchmark = "SPY";
@ -33,8 +34,16 @@ const AsOfResolution = struct {
/// The requested date, as parsed by the caller. /// The requested date, as parsed by the caller.
requested: Date, requested: Date,
/// The date that was actually loaded. Differs from `requested` /// 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, 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. /// Run projections.
@ -53,6 +62,8 @@ pub fn run(
events_enabled: bool, events_enabled: bool,
as_of: Date, as_of: Date,
from_snapshot: bool, from_snapshot: bool,
today: Date,
overlay_actuals: bool,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
) !void { ) !void {
@ -78,35 +89,88 @@ pub fn run(
// defer runs at the end of `run`. // defer runs at the end of `run`.
var snap_bundle: ?history.LoadedSnapshot = null; var snap_bundle: ?history.LoadedSnapshot = null;
defer if (snap_bundle) |*s| s.deinit(allocator); 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) { if (from_snapshot) {
resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) { resolution = resolveAsOfSnapshot(io, va, file_path, as_of) catch |err| switch (err) {
error.NoSnapshot => return, error.NoSnapshot => return,
else => return err, 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( if (resolution.?.source == .snapshot) {
io, const hist_dir = try history.deriveHistoryDir(va, file_path);
va, snap_bundle = try history.loadSnapshotAt(io, allocator, hist_dir, resolution.?.actual);
portfolio_dir,
&snap_bundle.?.snap, ctx = try view.loadProjectionContextAsOf(
resolution.?.actual, io,
svc, va,
events_enabled, 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 { } else {
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return; live_loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
defer loaded.deinit(allocator); const lp = &live_loaded.?;
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
// Prices from cache matches pre-as-of behavior exactly. // Prices from cache matches pre-as-of behavior exactly.
var prices = std.StringHashMap(f64).init(allocator); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
for (positions) |pos| { for (lp.positions) |pos| {
if (pos.shares <= 0) continue; if (pos.shares <= 0) continue;
if (svc.getCachedCandles(pos.symbol)) |cs| { if (svc.getCachedCandles(pos.symbol)) |cs| {
defer cs.deinit(); 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 => { error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint(io, "Error computing portfolio summary.\n"); try cli.stderrPrint(io, "Error computing portfolio summary.\n");
return; return;
}, },
else => return err, else => return err,
}; };
defer pf_data.deinit(allocator);
ctx = try view.loadProjectionContext( ctx = try view.loadProjectionContext(
io, io,
va, va,
portfolio_dir, portfolio_dir,
pf_data.summary.allocations, live_pf_data.?.summary.allocations,
pf_data.summary.total_value, live_pf_data.?.summary.total_value,
portfolio.totalCash(as_of), lp.portfolio.totalCash(as_of),
portfolio.totalCdFaceValue(as_of), lp.portfolio.totalCdFaceValue(as_of),
svc, svc,
events_enabled, events_enabled,
as_of, 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 horizons = ctx.config.getHorizons();
const confidence_levels = ctx.config.getConfidenceLevels(); const confidence_levels = ctx.config.getConfidenceLevels();
const comparison = ctx.comparison; const comparison = ctx.comparison;
try out.print("\n", .{}); try out.print("\n", .{});
if (resolution) |r| { 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 { } else {
try cli.printBold(out, color, "Projections ({s})\n", .{file_path}); try cli.printBold(out, color, "Projections ({s})\n", .{file_path});
} }
try out.print("========================================\n", .{}); try out.print("========================================\n", .{});
// If auto-snapped, print a muted note so the user knows the // 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 (resolution) |r| {
if (r.actual.days != r.requested.days) { if (r.actual.days != r.requested.days) {
const diff = r.requested.days - r.actual.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, r.requested,
nearest_label,
r.actual, r.actual,
diff, diff,
fmt.dayPlural(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", .{}); 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 // Terminal portfolio value
try out.print("\n", .{}); try out.print("\n", .{});
try cli.printBold(out, color, "Terminal Portfolio Value (nominal, at 99% withdrawal rate)\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)}); 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 /// Thin adapter over `cli.resolveAsOfOrExplain` the shared CLI
/// helper owns exact-then-fallback resolution and the stderr /// helper owns the exact-then-fallback resolution and the stderr
/// messaging. This wrapper just maps the error set to /// messaging. This wrapper just maps the error set to
/// `error.NoSnapshot` (projections-specific) and strips the `exact` /// `error.NoSnapshot` (projections-specific) and packs the source +
/// flag since projections doesn't surface that distinction in its /// liquid fields into the local `AsOfResolution` shape.
/// header (it just uses `actual` directly).
/// ///
/// Arena-allocates the intermediate `hist_dir` + filename strings; /// Arena-allocates the intermediate `hist_dir` + filename strings;
/// pass a short-lived arena as `va`. /// pass a short-lived arena as `va`.
@ -661,17 +772,48 @@ fn resolveAsOfSnapshot(
) !AsOfResolution { ) !AsOfResolution {
const hist_dir = try history.deriveHistoryDir(va, file_path); const hist_dir = try history.deriveHistoryDir(va, file_path);
const resolved = cli.resolveSnapshotOrExplain(io, va, hist_dir, requested) catch |err| switch (err) { const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => return error.NoSnapshot, error.NoDataAtOrBefore => return error.NoSnapshot,
else => |e| { 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, @errorName(e));
try cli.stderrPrint(io, "\n"); try cli.stderrPrint(io, "\n");
return error.NoSnapshot; 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. /// 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); var stream = std.Io.Writer.fixed(&buf);
const d = Date.fromYmd(2026, 3, 13); 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 // No body output because the resolution failed the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in // 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 buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf); 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(); const out = stream.buffered();
// Header should call out the as-of date explicitly. // 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); var stream = std.Io.Writer.fixed(&buf);
const requested = Date.fromYmd(2026, 3, 13); 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(); const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);

View file

@ -413,6 +413,108 @@ pub const ResolveSnapshotError = error{
NoSnapshotAtOrBefore, NoSnapshotAtOrBefore,
} || std.mem.Allocator.Error || std.Io.Dir.AccessError || std.Io.File.OpenError; } || 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 `<hist_dir>/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`: /// Resolve a requested snapshot date against `hist_dir`:
/// - If `hist_dir/<requested>-portfolio.srf` exists, return it as /// - If `hist_dir/<requested>-portfolio.srf` exists, return it as
/// an exact match. /// 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)); const result = resolveSnapshotDate(io, arena.allocator(), hist_dir, Date.fromYmd(2024, 3, 15));
try testing.expectError(error.NoSnapshotAtOrBefore, result); 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);
}

View file

@ -97,6 +97,13 @@ const usage =
\\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'. \\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'.
\\ Auto-snaps to nearest-earlier snapshot if the \\ Auto-snaps to nearest-earlier snapshot if the
\\ exact date has no snapshot file. \\ 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 <DATE|N[WMQY]> Compact side-by-side comparison: projected return \\ --vs <DATE|N[WMQY]> Compact side-by-side comparison: projected return
\\ and safe-withdrawal @99% for live vs DATE, with \\ and safe-withdrawal @99% for live vs DATE, with
\\ deltas. Combine with --as-of to compare two \\ deltas. Combine with --as-of to compare two
@ -517,11 +524,14 @@ fn runCli(init: std.process.Init) !u8 {
var events_enabled = true; var events_enabled = true;
var as_of: ?zfin.Date = null; var as_of: ?zfin.Date = null;
var vs_date: ?zfin.Date = null; var vs_date: ?zfin.Date = null;
var overlay_actuals = false;
var i: usize = 0; var i: usize = 0;
while (i < cmd_args.len) : (i += 1) { while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i]; const a = cmd_args[i];
if (std.mem.eql(u8, a, "--no-events")) { if (std.mem.eql(u8, a, "--no-events")) {
events_enabled = false; 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")) { } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
if (i + 1 >= cmd_args.len) { if (i + 1 >= cmd_args.len) {
try cli.stderrPrint(io, "Error: "); 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` // live against a historical date; `--vs X --as-of Y`
// compares two historical dates with Y being the later // compares two historical dates with Y being the later
// one. // 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); try commands.projections.runCompare(io, allocator, &svc, pf.path, events_enabled, d, as_of orelse today, as_of != null, color, out);
} else { } 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")) { } else if (std.mem.eql(u8, command, "contributions")) {
var since: ?zfin.Date = null; var since: ?zfin.Date = null;

View file

@ -468,6 +468,12 @@ pub const App = struct {
/// the user actually typed surfaced in the tab header as a muted /// the user actually typed surfaced in the tab header as a muted
/// "(requested X; snapped to Y, N days earlier)" note. /// "(requested X; snapped to Y, N days earlier)" note.
projections_as_of_requested: ?zfin.Date = null, 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 // Default to `.liquid` that's the metric most worth watching
// day-to-day. Illiquid barely changes, net_worth is dominated by // day-to-day. Illiquid barely changes, net_worth is dominated by
// liquid anyway, so "show me liquid" is the headline view. // 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) { if (self.active_tab == .projections and self.projections_as_of != null) {
self.projections_as_of = null; self.projections_as_of = null;
self.projections_as_of_requested = null; self.projections_as_of_requested = null;
self.projections_overlay_actuals = false;
projections_tab.freeLoaded(self); projections_tab.freeLoaded(self);
self.projections_loaded = false; self.projections_loaded = false;
projections_tab.loadData(self); projections_tab.loadData(self);
@ -1319,12 +1326,42 @@ pub const App = struct {
} }
}, },
.sort_reverse => { .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) { if (self.active_tab == .portfolio) {
self.portfolio_sort_dir = self.portfolio_sort_dir.flip(); self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
self.sortPortfolioAllocations(); self.sortPortfolioAllocations();
self.rebuildPortfolioRows(); self.rebuildPortfolioRows();
return ctx.consumeAndRedraw(); 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 => { .account_filter => {
if (self.active_tab == .portfolio and self.portfolio != null) { if (self.active_tab == .portfolio and self.portfolio != null) {

View file

@ -5,9 +5,13 @@
//! Visual layers (bottom to top): //! Visual layers (bottom to top):
//! - Background //! - Background
//! - Horizontal grid lines //! - Horizontal grid lines
//! - "Today" vertical reference line (when overlay is active)
//! - p10-p90 band fill (faint) //! - p10-p90 band fill (faint)
//! - p25-p75 band fill (medium) //! - p25-p75 band fill (medium)
//! - p10/p90 boundary lines
//! - Median (p50) line (solid) //! - Median (p50) line (solid)
//! - Zero line (if visible)
//! - Actuals overlay line (when present)
//! - Panel border //! - Panel border
const std = @import("std"); const std = @import("std");
@ -25,6 +29,25 @@ const margin_right: f64 = 4;
const margin_top: f64 = 4; const margin_top: f64 = 4;
const margin_bottom: 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. /// Projection chart render result.
pub const ProjectionChartResult = struct { pub const ProjectionChartResult = struct {
/// Raw RGB pixel data (3 bytes per pixel, row-major). /// 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. /// Draws p10-p90 outer band, p25-p75 inner band, and p50 median line.
/// ///
/// `bands` is the array of YearPercentiles (year 0 through horizon). /// `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. /// The returned rgb_data is allocated with `alloc` and must be freed by caller.
pub fn renderProjectionChart( pub fn renderProjectionChart(
io: std.Io, io: std.Io,
@ -48,6 +72,7 @@ pub fn renderProjectionChart(
width_px: u32, width_px: u32,
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
actuals: ?ActualsOverlay,
) !ProjectionChartResult { ) !ProjectionChartResult {
if (bands.len < 2) return error.InsufficientData; 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.p10 < value_min) value_min = bp.p10;
if (bp.p90 > value_max) value_max = bp.p90; 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 // Add 5% padding
const pad = (value_max - value_min) * 0.05; const pad = (value_max - value_min) * 0.05;
value_min -= pad; value_min -= pad;
@ -103,6 +137,22 @@ pub fn renderProjectionChart(
const grid_color = blendColor(th.text_muted, 40, bg); const grid_color = blendColor(th.text_muted, 40, bg);
try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color); 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 // p10-p90 outer band fill
{ {
const band_color = blendColor(th.accent, 20, bg); 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); 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 // Panel border
{ {
const border_color = blendColor(th.border, 80, bg); 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); 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 { fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col); ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w); ctx.setLineWidth(line_w);
@ -321,7 +414,7 @@ test "renderProjectionChart produces valid output" {
}; };
const th = @import("theme.zig").default_theme; 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); defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 200), result.width); 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 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); 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);
}

View file

@ -63,12 +63,13 @@ pub fn loadData(app: *App) void {
as_of: { as_of: {
const requested_date = app.projections_as_of orelse break :as_of; const requested_date = app.projections_as_of orelse break :as_of;
const actual_date = resolveSnapshotDate(app, portfolio_path, requested_date) orelse { const resolution = resolveAsOf(app, portfolio_path, requested_date) orelse {
// `setStatus` already called by resolveSnapshotDate. // `setStatus` already called by resolveAsOf.
app.projections_as_of = null; app.projections_as_of = null;
app.projections_as_of_requested = null; app.projections_as_of_requested = null;
break :as_of; break :as_of;
}; };
const actual_date = resolution.actual;
app.projections_as_of = actual_date; app.projections_as_of = actual_date;
// Preserve requested for the header note; clear if it matches actual. // Preserve requested for the header note; clear if it matches actual.
if (actual_date.eql(requested_date)) { if (actual_date.eql(requested_date)) {
@ -83,30 +84,85 @@ pub fn loadData(app: *App) void {
}; };
defer app.allocator.free(hist_dir); defer app.allocator.free(hist_dir);
var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch { const ctx = switch (resolution.source) {
app.setStatus("Failed to load snapshot — showing live"); .snapshot => snap: {
app.projections_as_of = null; var loaded = history.loadSnapshotAt(app.io, app.allocator, hist_dir, actual_date) catch {
app.projections_as_of_requested = null; app.setStatus("Failed to load snapshot — showing live");
break :as_of; app.projections_as_of = null;
}; app.projections_as_of_requested = null;
defer loaded.deinit(app.allocator); break :as_of;
};
defer loaded.deinit(app.allocator);
const ctx = view.loadProjectionContextAsOf( break :snap view.loadProjectionContextAsOf(
app.io, app.io,
app.allocator, app.allocator,
portfolio_dir, portfolio_dir,
&loaded.snap, &loaded.snap,
actual_date, actual_date,
app.svc, app.svc,
app.projections_events_enabled, app.projections_events_enabled,
) catch { ) catch {
app.setStatus("Failed to compute as-of projections — showing live"); app.setStatus("Failed to compute as-of projections — showing live");
app.projections_as_of = null; app.projections_as_of = null;
app.projections_as_of_requested = null; app.projections_as_of_requested = null;
break :as_of; 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; return;
} }
@ -139,14 +195,14 @@ pub fn loadData(app: *App) void {
} }
/// Resolve the user's requested as-of date against the portfolio's /// Resolve the user's requested as-of date against the portfolio's
/// history directory. Returns the actual date to load (exact match or /// history directory, accepting either a native snapshot OR an
/// nearest-earlier snapshot), or null with a status-bar message if /// `imported_values.srf` row. Returns the resolved record, or null
/// no usable snapshot exists. /// with a status-bar message if no usable data exists.
/// ///
/// Thin adapter over `history.resolveSnapshotDate` the shared pure /// Thin adapter over `history.resolveAsOfDate` the shared pure
/// resolver owns the exact/snap logic; this wrapper maps its errors /// resolver owns exact-then-fallback logic; this wrapper maps its
/// to user-visible status-bar messages and handles the arena. /// errors to user-visible status-bar messages and handles the arena.
fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?zfin.Date { fn resolveAsOf(app: *App, portfolio_path: []const u8, requested: zfin.Date) ?history.ResolvedAsOf {
var arena_state = std.heap.ArenaAllocator.init(app.allocator); var arena_state = std.heap.ArenaAllocator.init(app.allocator);
defer arena_state.deinit(); defer arena_state.deinit();
const arena = arena_state.allocator(); const arena = arena_state.allocator();
@ -156,19 +212,19 @@ fn resolveSnapshotDate(app: *App, portfolio_path: []const u8, requested: zfin.Da
return null; return null;
}; };
const resolved = history.resolveSnapshotDate(app.io, arena, hist_dir, requested) catch |err| switch (err) { const resolved = history.resolveAsOfDate(app.io, arena, hist_dir, requested) catch |err| switch (err) {
error.NoSnapshotAtOrBefore => { error.NoDataAtOrBefore => {
var status_buf: [128]u8 = undefined; 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); app.setStatus(msg);
return null; return null;
}, },
error.OutOfMemory => { error.OutOfMemory => {
app.setStatus("Out of memory resolving snapshot"); app.setStatus("Out of memory resolving as-of");
return null; return null;
}, },
else => { else => {
app.setStatus("Error accessing snapshot"); app.setStatus("Error accessing as-of data");
return null; 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. // Remember the original request for the muted header note.
app.projections_as_of_requested = requested; 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 { pub fn freeLoaded(app: *App) void {
if (app.projections_ctx) |ctx| { if (app.projections_ctx) |*ctx| {
app.allocator.free(ctx.data.withdrawals); app.allocator.free(ctx.data.withdrawals);
for (ctx.data.bands) |b| { for (ctx.data.bands) |b| {
if (b) |slice| app.allocator.free(slice); if (b) |slice| app.allocator.free(slice);
} }
app.allocator.free(ctx.data.bands); app.allocator.free(ctx.data.bands);
if (ctx.earliest) |er| app.allocator.free(er); if (ctx.earliest) |er| app.allocator.free(er);
if (ctx.overlay_actuals) |*ov| ov.deinit();
} }
app.projections_ctx = null; app.projections_ctx = null;
// Mark projection chart as dirty so it re-renders on next draw // 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| { 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( const chart_result = projection_chart.renderProjectionChart(
app.io, app.io,
app.allocator, app.allocator,
@ -306,6 +395,7 @@ fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell,
capped_w, capped_w,
capped_h, capped_h,
th, th,
overlay_input,
) catch { ) catch {
app.projections_chart_dirty = false; app.projections_chart_dirty = false;
return; 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 // with the main content. If the user asked for a date that had no
// exact snapshot, a second muted line explains the auto-snap. // exact snapshot, a second muted line explains the auto-snap.
if (app.projections_as_of) |actual| { 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() }); try lines.append(arena, .{ .text = header, .style = th.mutedStyle() });
if (app.projections_as_of_requested) |requested| { 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() }); 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() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
} }

View file

@ -120,6 +120,14 @@ pub const Theme = struct {
return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) }; 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. /// Map a semantic StyleIntent to a vaxis style.
pub fn styleFor(self: Theme, intent: fmt.StyleIntent) vaxis.Style { pub fn styleFor(self: Theme, intent: fmt.StyleIntent) vaxis.Style {
return switch (intent) { return switch (intent) {

View file

@ -8,6 +8,7 @@ const Money = @import("../Money.zig");
const performance = @import("../analytics/performance.zig"); const performance = @import("../analytics/performance.zig");
const benchmark = @import("../analytics/benchmark.zig"); const benchmark = @import("../analytics/benchmark.zig");
const projections = @import("../analytics/projections.zig"); const projections = @import("../analytics/projections.zig");
const timeline = @import("../analytics/timeline.zig");
const valuation = @import("../analytics/valuation.zig"); const valuation = @import("../analytics/valuation.zig");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const snapshot_model = @import("../models/snapshot.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 retirement-planning inputs the user configured. Drives
/// which display blocks render. /// which display blocks render.
inputs: ProjectionInputs = .distribution_only, 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 /// Statistics extracted from the bands at the retirement-boundary
/// year. Used to render the median portfolio at retirement and the /// year. Used to render the median portfolio at retirement and the
/// p10p90 range under the "Accumulation phase" display block. /// p10p90 range under the "Accumulation phase" display block.
@ -172,6 +188,90 @@ pub const AccumulationStats = struct {
contribution_inflation_adjusted: bool, 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. /// Which retirement-planning inputs the user has configured.
/// ///
/// The simulation always runs the same two-phase model /// The simulation always runs the same two-phase model
@ -450,7 +550,7 @@ pub fn loadProjectionContextAsOf(
var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap); var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap);
defer snap_allocs.deinit(alloc); defer snap_allocs.deinit(alloc);
return buildContextFromParts( var ctx = try buildContextFromParts(
io, io,
alloc, alloc,
portfolio_dir, portfolio_dir,
@ -462,6 +562,84 @@ pub fn loadProjectionContextAsOf(
events_enabled, events_enabled,
as_of_date, 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 /// 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.accumulation != null);
try std.testing.expect(ctx.earliest != 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);
}