initial implementation of projections actual (note TODO comments)

This commit is contained in:
Emil Lerch 2026-05-13 13:05:01 -07:00
parent cbb32727d8
commit 9cea368f2c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
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,
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 <DATE>` already reruns the simulation
against a past snapshot (the prerequisite for this overlay). What's
missing is the overlay itself — loading multiple downstream snapshots
and plotting their net-worth trajectory on the same chart.
## Export chart as PNG (`--export-chart <path>`) — 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 <path>` 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.<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, Kelly'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
/// 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 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);

View file

@ -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 `<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`:
/// - If `hist_dir/<requested>-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);
}

View file

@ -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 <DATE|N[WMQY]> 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;

View file

@ -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) {

View file

@ -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);
}

View file

@ -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() });
}

View file

@ -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) {

View file

@ -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
/// p10p90 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);
}