initial implementation of projections actual (note TODO comments)
This commit is contained in:
parent
cbb32727d8
commit
9cea368f2c
10 changed files with 1429 additions and 102 deletions
328
TODO.md
328
TODO.md
|
|
@ -29,29 +29,317 @@ manual-check accounts mechanism (medium effort, real user value).
|
|||
- Multiple spending models: flat (current), decreasing (1-2% real annual decrease,
|
||||
Blanchett "spending smile"). Late-life healthcare better modeled as a life event.
|
||||
- Unclassified position handling in allocation split (warn user)
|
||||
- Historical projection comparison: re-run projections from any past snapshot date,
|
||||
overlay actual portfolio trajectory from subsequent snapshots onto the projected
|
||||
percentile bands. Shows how reality tracked against the model. Data is already
|
||||
available in history/*.srf snapshots — just need to load a historical portfolio
|
||||
value and re-run `computePercentileBands` with that starting point, then plot
|
||||
actual values from later snapshots as a line overlaid on the bands.
|
||||
- **Historical projection overlay follow-ups.** The base
|
||||
`--overlay-actuals` overlay shipped (CLI tip + TUI primary surface).
|
||||
Open enhancements:
|
||||
- Historical `metadata.srf` / `projections.srf` for back-dated
|
||||
runs. Today the overlay re-runs against current classifications
|
||||
and assumptions; for historically faithful what-the-model-said-then
|
||||
output we'd check out the git-tracked versions of those files
|
||||
at the as-of commit and load those instead. Edge case until
|
||||
classifications materially drift.
|
||||
- Contribution-attribution overlay. Today's actuals line includes
|
||||
contributions implicitly; the bands assume modeled contributions
|
||||
that may or may not match reality. A "decompose actuals into
|
||||
market return vs contributions" annotation would clarify how
|
||||
much of the trajectory was the model being right vs new money
|
||||
arriving on schedule.
|
||||
- Mosaic mode: overlay multiple as-of starting points on one chart
|
||||
("show me 1Y, 3Y, 5Y, 10Y projections all at once") so the user
|
||||
can see how the projection envelope tightened as data came in.
|
||||
- **Better composition basis for imported-only as-of.** Today
|
||||
the imported-only path uses today's allocations scaled by
|
||||
`imported_liquid / today_total_liquid`. That's the simplest
|
||||
thing that could work, but it's "today's mix back-dated" —
|
||||
it ignores everything we know about the historical context.
|
||||
Specifically: `imported_values.srf` already carries an
|
||||
`expected_return` field per row that the user captured at
|
||||
that date in their source spreadsheet. We could:
|
||||
- Use the imported `expected_return` as a sanity check
|
||||
against the simulation's per-position weighted return
|
||||
(warn or clamp if they diverge wildly — the spreadsheet's
|
||||
number reflects what the user actually saw at the time).
|
||||
- Use the imported `expected_return` to bias the
|
||||
stock/bond split inference: a higher expected return
|
||||
implies a higher historical equity weighting than today's
|
||||
mix probably reflects.
|
||||
- Reach further: derive a synthetic stock/bond split from
|
||||
the imported `expected_return` directly, treating it as
|
||||
a weighted average of SPY and AGG returns at that date
|
||||
and solving for the weights. That gives a per-imported-
|
||||
row composition that's locally faithful instead of
|
||||
one-mix-fits-all.
|
||||
None of these are urgent — the current "today's mix scaled"
|
||||
approximation is documented as such and the bands still
|
||||
render meaningfully — but each would tighten the historical
|
||||
faithfulness one notch. Pick whichever has the highest
|
||||
payoff vs. complexity when this gets revisited.
|
||||
- **Chart zoom for short-history overlays.** With a 50-year
|
||||
projection horizon and only ~10 years of imported actuals,
|
||||
the actuals line is squashed into the first 20% of the
|
||||
chart and the comparison-against-bands story is hard to
|
||||
read. Two design directions:
|
||||
- **Auto-zoom**: when the overlay is on, the chart's
|
||||
x-axis defaults to `[as_of, today + N years]` (where
|
||||
N is small, e.g. 2x the actuals span) instead of
|
||||
`[as_of, as_of + horizon]`. The bands beyond `today
|
||||
+ N` are still computed but clipped from view. The
|
||||
tradeoff: the user loses the long-tail terminal-value
|
||||
context unless they toggle back out.
|
||||
- **Toggle**: a separate keybind (e.g. `z` for zoom)
|
||||
flips between full-horizon and zoomed views. Default
|
||||
off so the bands tell their full story; user opts in
|
||||
when they want overlay legibility.
|
||||
Auto-zoom is more invasive (changes the default chart
|
||||
semantics for everyone running with overlay-on) but better
|
||||
matches what the user actually wants when they toggle the
|
||||
overlay. Toggle is safer but requires the user to know the
|
||||
feature exists. Probably do auto-zoom but expose a toggle
|
||||
to escape it ("show full horizon").
|
||||
|
||||
`zfin projections --as-of <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 1–2 fields to `App`, and the
|
||||
struct keeps growing. Eventually it becomes unreviewable.
|
||||
|
||||
## Bug: braille charts use raw `close`, not `adj_close` — cliff at splits
|
||||
|
||||
**Reproduction:** `zfin quote SOXX` (or the TUI quote tab). The
|
||||
braille chart drops sharply on **2024-03-07**, which is the
|
||||
iShares Semiconductor ETF's 3-for-1 split date:
|
||||
|
||||
- 2024-03-06 close: $689.60
|
||||
- 2024-03-07 close: $237.75 (≈ $689.60 / 2.9)
|
||||
|
||||
The `adj_close` column in `~/.cache/zfin/SOXX/candles_daily.srf`
|
||||
tracks correctly through the split (~$226 → ~$234), so the
|
||||
provider data is fine. The bug is purely cosmetic: the chart
|
||||
renders the *unadjusted* close price.
|
||||
|
||||
**Root cause:** `computeBrailleChart` in `src/format.zig:888`
|
||||
indexes `data[i].close` instead of `data[i].adj_close`. Lines 901,
|
||||
902, 904, 905, 935 all use `.close`.
|
||||
|
||||
**Independent confirmation:** `zfin splits SOXX` returns
|
||||
`2024-03-07 3:1` from Polygon. So the split data exists in the
|
||||
provider layer (and gets cached as `splits.srf` once requested),
|
||||
but the charting code path doesn't consult it.
|
||||
|
||||
**Fix candidates:**
|
||||
|
||||
A. **Switch `computeBrailleChart` to consume `adj_close` directly.**
|
||||
Simplest. Affects every chart caller (quote, history, projections
|
||||
median band, TUI quote/projections tabs). Cosmetic only — no
|
||||
computation depends on it. The price-axis labels would render
|
||||
adjusted prices, which may surprise users used to seeing the
|
||||
raw last-close. Mitigate with a comment in the chart's right-edge
|
||||
label region or a header note.
|
||||
|
||||
B. **Pass a flag to `computeBrailleChart` selecting `close` vs
|
||||
`adj_close`.** Default to adjusted; let the quote tab show raw.
|
||||
More flexible, marginally more code.
|
||||
|
||||
C. **Add a `chart_close` accessor to `Candle` that returns
|
||||
`adj_close` if non-zero, else `close`.** Same effect as (A) with a
|
||||
defensive fallback.
|
||||
|
||||
D. **Apply split adjustments at chart-data prep time using
|
||||
`splits.srf`.** Walk the candle slice with the split history and
|
||||
pre-multiply pre-split closes by the cumulative ratio. More
|
||||
work, but produces a chart-axis dollar value the user expects
|
||||
("today's last close was $X, the chart starts at $Y from N
|
||||
years ago"). This is what most charting libraries do.
|
||||
Requires plumbing `DataService.getSplits` into the chart-prep
|
||||
path on every chart caller, OR doing the adjustment once in the
|
||||
service layer alongside candle fetching. Not all callers have a
|
||||
`DataService` reference today (e.g., `runProjection`'s synthetic
|
||||
median-band candles).
|
||||
|
||||
**Recommendation:** Start with (A) or (C) — single-line fix, gets
|
||||
the cliff out of all charts immediately. (D) is the "correct" fix
|
||||
but a bigger refactor; file as a follow-up if (A)/(C) lands first.
|
||||
|
||||
**Other affected symbols:** Any held position with a split in the
|
||||
last 10 years will have the same artifact. Check NVDA (10:1 split
|
||||
on 2024-06-10) for a louder example.
|
||||
|
||||
**Priority:** LOW. Cosmetic only — analytics already use
|
||||
`adj_close` correctly via the per-position trailing-returns path.
|
||||
But it's confusing when scanning a chart and seeing a 50% drop
|
||||
that isn't real.
|
||||
|
||||
## Audit: manual-check accounts mechanism (NYL, Kelly's ESPP, etc.) — priority HIGH
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
242
src/history.zig
242
src/history.zig
|
|
@ -413,6 +413,108 @@ pub const ResolveSnapshotError = error{
|
|||
NoSnapshotAtOrBefore,
|
||||
} || std.mem.Allocator.Error || std.Io.Dir.AccessError || std.Io.File.OpenError;
|
||||
|
||||
/// Source of an as-of resolution: a native snapshot file, or an
|
||||
/// `imported_values.srf` row. The snapshot path is preferred (higher
|
||||
/// fidelity) and consulted first; only when no snapshot exists at or
|
||||
/// before the requested date do we look at imported_values.
|
||||
pub const AsOfSourceKind = enum { snapshot, imported };
|
||||
|
||||
/// Result of `resolveAsOfDate` — a unified resolver that consults
|
||||
/// both the native snapshot directory and `imported_values.srf`.
|
||||
pub const ResolvedAsOf = struct {
|
||||
requested: Date,
|
||||
actual: Date,
|
||||
exact: bool,
|
||||
source: AsOfSourceKind,
|
||||
/// Liquid total at `actual`. For `.snapshot` this is filled by
|
||||
/// the caller after loading the snapshot file (left at 0 here);
|
||||
/// for `.imported` this is the value from the imported_values
|
||||
/// row directly.
|
||||
liquid: f64 = 0,
|
||||
};
|
||||
|
||||
pub const ResolveAsOfError = error{
|
||||
/// Neither a snapshot nor an imported_values row exists at or
|
||||
/// before the requested date.
|
||||
NoDataAtOrBefore,
|
||||
};
|
||||
|
||||
/// Resolve a requested as-of date against `hist_dir`, accepting
|
||||
/// either a native snapshot OR an imported_values row.
|
||||
///
|
||||
/// 1. Look up nearest-earlier snapshot via the existing
|
||||
/// `resolveSnapshotDate`. If found → return `.snapshot`.
|
||||
/// 2. Otherwise read `<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);
|
||||
}
|
||||
|
|
|
|||
15
src/main.zig
15
src/main.zig
|
|
@ -97,6 +97,13 @@ const usage =
|
|||
\\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'.
|
||||
\\ Auto-snaps to nearest-earlier snapshot if the
|
||||
\\ exact date has no snapshot file.
|
||||
\\ --overlay-actuals Plot the realized portfolio trajectory from --as-of
|
||||
\\ up to today on top of the projected percentile
|
||||
\\ bands. Requires --as-of. The TUI projections tab
|
||||
\\ is the higher-fidelity surface (press `o` after
|
||||
\\ setting an as-of date with `d`). Caveat: this shows
|
||||
\\ whether the model was directionally honest, NOT
|
||||
\\ whether the SWR claim was accurate.
|
||||
\\ --vs <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;
|
||||
|
|
|
|||
37
src/tui.zig
37
src/tui.zig
|
|
@ -468,6 +468,12 @@ pub const App = struct {
|
|||
/// the user actually typed — surfaced in the tab header as a muted
|
||||
/// "(requested X; snapped to Y, N days earlier)" note.
|
||||
projections_as_of_requested: ?zfin.Date = null,
|
||||
/// When true, the projections chart overlays the realized
|
||||
/// portfolio trajectory (snapshots + imported_values) on top of
|
||||
/// the percentile bands. Toggled by the `o` keybind. Only
|
||||
/// meaningful when `projections_as_of` is set; the keybind
|
||||
/// flashes a status message and leaves this off otherwise.
|
||||
projections_overlay_actuals: bool = false,
|
||||
// Default to `.liquid` — that's the metric most worth watching
|
||||
// day-to-day. Illiquid barely changes, net_worth is dominated by
|
||||
// liquid anyway, so "show me liquid" is the headline view.
|
||||
|
|
@ -1119,6 +1125,7 @@ pub const App = struct {
|
|||
if (self.active_tab == .projections and self.projections_as_of != null) {
|
||||
self.projections_as_of = null;
|
||||
self.projections_as_of_requested = null;
|
||||
self.projections_overlay_actuals = false;
|
||||
projections_tab.freeLoaded(self);
|
||||
self.projections_loaded = false;
|
||||
projections_tab.loadData(self);
|
||||
|
|
@ -1319,12 +1326,42 @@ pub const App = struct {
|
|||
}
|
||||
},
|
||||
.sort_reverse => {
|
||||
// The `o` keybind dual-dispatches by active tab:
|
||||
// - portfolio → flip the sort direction
|
||||
// - projections → toggle the actuals-overlay
|
||||
// `matchAction` is first-match-wins so we can't have
|
||||
// separate Action variants share a codepoint; routing
|
||||
// via the active tab in the handler is the project's
|
||||
// existing pattern (see also `s` → compare_select /
|
||||
// select_symbol). The action name stays `sort_reverse`
|
||||
// because portfolio was the first consumer.
|
||||
if (self.active_tab == .portfolio) {
|
||||
self.portfolio_sort_dir = self.portfolio_sort_dir.flip();
|
||||
self.sortPortfolioAllocations();
|
||||
self.rebuildPortfolioRows();
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
if (self.active_tab == .projections) {
|
||||
if (self.projections_as_of == null) {
|
||||
self.setStatus("Overlay only available with --as-of (press d to set)");
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
self.projections_overlay_actuals = !self.projections_overlay_actuals;
|
||||
// Re-run loadData so the overlay section gets
|
||||
// built (or freed). The timeline load is the
|
||||
// expensive bit but it's rare — humans toggle
|
||||
// this maybe a few times per session.
|
||||
projections_tab.freeLoaded(self);
|
||||
self.projections_loaded = false;
|
||||
projections_tab.loadData(self);
|
||||
self.projections_chart_dirty = true;
|
||||
if (self.projections_overlay_actuals) {
|
||||
self.setStatus("Overlay: ON — tracks trajectory, not SWR validity");
|
||||
} else {
|
||||
self.setStatus("Overlay: OFF");
|
||||
}
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
},
|
||||
.account_filter => {
|
||||
if (self.active_tab == .portfolio and self.portfolio != null) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const Money = @import("../Money.zig");
|
|||
const performance = @import("../analytics/performance.zig");
|
||||
const benchmark = @import("../analytics/benchmark.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const timeline = @import("../analytics/timeline.zig");
|
||||
const valuation = @import("../analytics/valuation.zig");
|
||||
const zfin = @import("../root.zig");
|
||||
const snapshot_model = @import("../models/snapshot.zig");
|
||||
|
|
@ -159,8 +160,23 @@ pub const ProjectionContext = struct {
|
|||
/// Which retirement-planning inputs the user configured. Drives
|
||||
/// which display blocks render.
|
||||
inputs: ProjectionInputs = .distribution_only,
|
||||
/// Realized trajectory overlay (snapshots + imported_values).
|
||||
/// Populated only when the user passed `--overlay-actuals` (CLI)
|
||||
/// or toggled `o` in the TUI projections tab. Requires `as_of`
|
||||
/// to be set; meaningless in live mode.
|
||||
overlay_actuals: ?OverlayActualsSection = null,
|
||||
/// Where the as-of context came from. `.live` for normal "now"
|
||||
/// mode (no `--as-of`); `.snapshot` when the as-of date had a
|
||||
/// native `*-portfolio.srf` snapshot; `.imported` when only an
|
||||
/// `imported_values.srf` row was available and today's
|
||||
/// allocations had to be scaled to the imported liquid total.
|
||||
/// Drives the header note ("As-of: ... (snapshot)" vs "(imported)")
|
||||
/// so users know how literal the bands are.
|
||||
as_of_source: AsOfSource = .live,
|
||||
};
|
||||
|
||||
pub const AsOfSource = enum { live, snapshot, imported };
|
||||
|
||||
/// Statistics extracted from the bands at the retirement-boundary
|
||||
/// year. Used to render the median portfolio at retirement and the
|
||||
/// p10–p90 range under the "Accumulation phase" display block.
|
||||
|
|
@ -172,6 +188,90 @@ pub const AccumulationStats = struct {
|
|||
contribution_inflation_adjusted: bool,
|
||||
};
|
||||
|
||||
/// One realized (date, total liquid) point on the actuals overlay,
|
||||
/// expressed in years from the as-of date so the chart can place it
|
||||
/// on the same x-axis as the projected percentile bands.
|
||||
pub const ActualsPoint = struct {
|
||||
/// Years since `as_of` (as_of itself = 0.0). Uses 365.25 to
|
||||
/// match the rest of the codebase's year math (`Date.yearsBetween`).
|
||||
years_from_as_of: f64,
|
||||
/// Total liquid value on that date, in nominal dollars.
|
||||
liquid: f64,
|
||||
};
|
||||
|
||||
/// View-model section produced when `--overlay-actuals` is active.
|
||||
/// Carries the realized-trajectory points to render on top of the
|
||||
/// percentile-band chart, plus the "today" position for the
|
||||
/// vertical reference line.
|
||||
///
|
||||
/// Caveat (must be surfaced in any UI consuming this section):
|
||||
/// this overlay shows whether the model was directionally honest,
|
||||
/// not whether the SWR claim was accurate. The SWR claim is a
|
||||
/// 30-year claim; we have at most ~12 years of weekly history.
|
||||
pub const OverlayActualsSection = struct {
|
||||
points: []ActualsPoint,
|
||||
/// Years from `as_of` to today. The actuals line ends here;
|
||||
/// beyond this x-position only bands are visible.
|
||||
today_years: f64,
|
||||
/// The as-of date the overlay is anchored to; surfaced for
|
||||
/// status-line / footnote display ("actuals from YYYY-MM-DD").
|
||||
as_of: Date,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
pub fn deinit(self: *OverlayActualsSection) void {
|
||||
self.allocator.free(self.points);
|
||||
}
|
||||
};
|
||||
|
||||
/// Build an `OverlayActualsSection` from a merged `TimelineSeries`
|
||||
/// (snapshots + imported_values). Filters to `as_of..today`,
|
||||
/// converts each point's date to fractional years from `as_of`, and
|
||||
/// pulls the liquid total. Snapshot precedence is already handled
|
||||
/// upstream by `timeline.buildMergedSeries`.
|
||||
///
|
||||
/// Returns an empty section (no error) when the timeline yields no
|
||||
/// points in range — the caller still toggles the overlay on, the
|
||||
/// chart simply has no actuals line to draw.
|
||||
pub fn buildOverlayActuals(
|
||||
allocator: std.mem.Allocator,
|
||||
timeline_points: []const timeline.TimelinePoint,
|
||||
as_of: Date,
|
||||
today: Date,
|
||||
) !OverlayActualsSection {
|
||||
const days_per_year: f64 = 365.25;
|
||||
|
||||
// Count first so we can size the slice exactly.
|
||||
var keep: usize = 0;
|
||||
for (timeline_points) |p| {
|
||||
if (p.as_of_date.days < as_of.days) continue;
|
||||
if (p.as_of_date.days > today.days) continue;
|
||||
keep += 1;
|
||||
}
|
||||
|
||||
const out = try allocator.alloc(ActualsPoint, keep);
|
||||
errdefer allocator.free(out);
|
||||
|
||||
var i: usize = 0;
|
||||
for (timeline_points) |p| {
|
||||
if (p.as_of_date.days < as_of.days) continue;
|
||||
if (p.as_of_date.days > today.days) continue;
|
||||
const days_since: f64 = @floatFromInt(p.as_of_date.days - as_of.days);
|
||||
out[i] = .{
|
||||
.years_from_as_of = days_since / days_per_year,
|
||||
.liquid = p.liquid,
|
||||
};
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const today_days: f64 = @floatFromInt(today.days - as_of.days);
|
||||
return .{
|
||||
.points = out,
|
||||
.today_years = today_days / days_per_year,
|
||||
.as_of = as_of,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Which retirement-planning inputs the user has configured.
|
||||
///
|
||||
/// The simulation always runs the same two-phase model
|
||||
|
|
@ -450,7 +550,7 @@ pub fn loadProjectionContextAsOf(
|
|||
var snap_allocs = try history.aggregateSnapshotAllocations(alloc, snap);
|
||||
defer snap_allocs.deinit(alloc);
|
||||
|
||||
return buildContextFromParts(
|
||||
var ctx = try buildContextFromParts(
|
||||
io,
|
||||
alloc,
|
||||
portfolio_dir,
|
||||
|
|
@ -462,6 +562,84 @@ pub fn loadProjectionContextAsOf(
|
|||
events_enabled,
|
||||
as_of_date,
|
||||
);
|
||||
ctx.as_of_source = .snapshot;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// Build a `ProjectionContext` for an as-of date that has only an
|
||||
/// `imported_values.srf` row — no native `*-portfolio.srf` snapshot.
|
||||
///
|
||||
/// We can't reconstruct the historical lot composition from just a
|
||||
/// `liquid` total, so we use **today's allocations** scaled to the
|
||||
/// imported liquid value. The simulation then runs with:
|
||||
/// - Today's stock/bond split (ratio-preserving),
|
||||
/// - Per-position trailing returns truncated to <= as_of,
|
||||
/// - `total_value = imported_liquid` (the simulation starting point),
|
||||
/// - Cash/CD totals scaled by the same factor.
|
||||
///
|
||||
/// This is the best approximation available for back-dated runs that
|
||||
/// predate native snapshots — useful for users with weekly imported
|
||||
/// history going back years. Limitations are documented in the
|
||||
/// caveat surfaced by the calling display layer.
|
||||
///
|
||||
/// Caller-provided `live_allocations` is from the current portfolio
|
||||
/// (live `portfolioSummary().allocations`), `live_total_value` is the
|
||||
/// matching `summary.total_value`, etc. The function builds a
|
||||
/// freshly-allocated, scaled copy of the allocations slice and frees
|
||||
/// it before returning — `buildContextFromParts` only borrows
|
||||
/// `allocations` during the build (to derive the stock/bond split
|
||||
/// and per-position trailing returns) and doesn't store it on the
|
||||
/// context. Same lifetime convention as
|
||||
/// `loadProjectionContextAsOf`, which similarly frees its
|
||||
/// snapshot-derived allocations slice via `defer snap_allocs.deinit`.
|
||||
pub fn loadProjectionContextFromImported(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
portfolio_dir: []const u8,
|
||||
live_allocations: []const valuation.Allocation,
|
||||
live_total_value: f64,
|
||||
live_cash_value: f64,
|
||||
live_cd_value: f64,
|
||||
imported_liquid: f64,
|
||||
as_of_date: Date,
|
||||
svc: *zfin.DataService,
|
||||
events_enabled: bool,
|
||||
) !ProjectionContext {
|
||||
// Defensive: degenerate live total. Skip scaling and pass through;
|
||||
// simulation will produce flat bands anchored at imported_liquid
|
||||
// either way.
|
||||
const scale: f64 = if (live_total_value > 0) imported_liquid / live_total_value else 1.0;
|
||||
|
||||
// Scale a copy of the allocations. The slice is consumed by
|
||||
// `buildContextFromParts` during the build (split derivation,
|
||||
// per-position trailing returns) but not retained on the
|
||||
// returned context — so we free it here, mirroring the snapshot
|
||||
// path's `defer snap_allocs.deinit(alloc)`.
|
||||
const scaled = try alloc.alloc(valuation.Allocation, live_allocations.len);
|
||||
defer alloc.free(scaled);
|
||||
for (live_allocations, 0..) |a, i| {
|
||||
scaled[i] = a;
|
||||
scaled[i].market_value = a.market_value * scale;
|
||||
// Weight is preserved (it's a ratio); shares/cost stay as
|
||||
// today's because the simulation only consumes weight +
|
||||
// total_value. Carrying real share counts at imported scale
|
||||
// would be misleading — they don't reflect history.
|
||||
}
|
||||
|
||||
var ctx = try buildContextFromParts(
|
||||
io,
|
||||
alloc,
|
||||
portfolio_dir,
|
||||
scaled,
|
||||
imported_liquid,
|
||||
live_cash_value * scale,
|
||||
live_cd_value * scale,
|
||||
svc,
|
||||
events_enabled,
|
||||
as_of_date,
|
||||
);
|
||||
ctx.as_of_source = .imported;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// Shared core: build a `ProjectionContext` from pre-computed
|
||||
|
|
@ -1481,3 +1659,132 @@ test "buildProjectionContext: both_targets inputs when both fields configured" {
|
|||
try std.testing.expect(ctx.accumulation != null);
|
||||
try std.testing.expect(ctx.earliest != null);
|
||||
}
|
||||
|
||||
// ── Overlay-actuals tests ─────────────────────────────────────
|
||||
|
||||
/// Build a TimelinePoint with just the date and liquid value
|
||||
/// — the overlay only reads those two fields. Empty accounts /
|
||||
/// tax_types are fine; we never deinit these synthetic points.
|
||||
fn makeTp(date: Date, liquid: f64) timeline.TimelinePoint {
|
||||
return .{
|
||||
.as_of_date = date,
|
||||
.net_worth = liquid,
|
||||
.liquid = liquid,
|
||||
.illiquid = 0,
|
||||
.accounts = &.{},
|
||||
.tax_types = &.{},
|
||||
.source = .snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: empty input produces empty section" {
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&.{},
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
Date.fromYmd(2025, 1, 1),
|
||||
);
|
||||
defer section.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 0), section.points.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.today_years, 0.01);
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: single point at as_of has years=0" {
|
||||
const points = [_]timeline.TimelinePoint{
|
||||
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
|
||||
};
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&points,
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
);
|
||||
defer section.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 1), section.points.len);
|
||||
try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of);
|
||||
try std.testing.expectEqual(@as(f64, 1_000_000), section.points[0].liquid);
|
||||
try std.testing.expectEqual(@as(f64, 0.0), section.today_years);
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: filters out points before as_of and after today" {
|
||||
const points = [_]timeline.TimelinePoint{
|
||||
makeTp(Date.fromYmd(2023, 6, 1), 800_000), // before as_of - excluded
|
||||
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
|
||||
makeTp(Date.fromYmd(2024, 7, 1), 1_100_000),
|
||||
makeTp(Date.fromYmd(2025, 1, 1), 1_200_000),
|
||||
makeTp(Date.fromYmd(2025, 6, 1), 1_300_000), // after today - excluded
|
||||
};
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&points,
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
Date.fromYmd(2025, 1, 1),
|
||||
);
|
||||
defer section.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 3), section.points.len);
|
||||
// First point is at as_of itself, last point is at today.
|
||||
try std.testing.expectEqual(@as(f64, 0.0), section.points[0].years_from_as_of);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), section.points[2].years_from_as_of, 0.01);
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: years_from_as_of math is monotonic" {
|
||||
const points = [_]timeline.TimelinePoint{
|
||||
makeTp(Date.fromYmd(2024, 1, 1), 1_000_000),
|
||||
makeTp(Date.fromYmd(2024, 7, 1), 1_100_000),
|
||||
makeTp(Date.fromYmd(2025, 1, 1), 1_200_000),
|
||||
};
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&points,
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
Date.fromYmd(2025, 1, 1),
|
||||
);
|
||||
defer section.deinit();
|
||||
var prev: f64 = -1.0;
|
||||
for (section.points) |p| {
|
||||
try std.testing.expect(p.years_from_as_of > prev);
|
||||
prev = p.years_from_as_of;
|
||||
}
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: today_years matches today - as_of" {
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&.{},
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
Date.fromYmd(2026, 1, 1),
|
||||
);
|
||||
defer section.deinit();
|
||||
// Two calendar years = 731 days / 365.25 ≈ 2.001 years.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2.0), section.today_years, 0.01);
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: liquid value is preserved verbatim" {
|
||||
const points = [_]timeline.TimelinePoint{
|
||||
makeTp(Date.fromYmd(2024, 6, 15), 1_234_567.89),
|
||||
};
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&points,
|
||||
Date.fromYmd(2024, 1, 1),
|
||||
Date.fromYmd(2025, 1, 1),
|
||||
);
|
||||
defer section.deinit();
|
||||
try std.testing.expectEqual(@as(f64, 1_234_567.89), section.points[0].liquid);
|
||||
}
|
||||
|
||||
test "buildOverlayActuals: empty range (today < as_of) produces empty points" {
|
||||
const points = [_]timeline.TimelinePoint{
|
||||
makeTp(Date.fromYmd(2024, 6, 1), 1_000_000),
|
||||
};
|
||||
var section = try buildOverlayActuals(
|
||||
std.testing.allocator,
|
||||
&points,
|
||||
Date.fromYmd(2025, 1, 1),
|
||||
Date.fromYmd(2024, 1, 1), // today before as_of (degenerate)
|
||||
);
|
||||
defer section.deinit();
|
||||
// Filter is `>= as_of AND <= today`. With today < as_of, no points
|
||||
// can satisfy both — section is empty.
|
||||
try std.testing.expectEqual(@as(usize, 0), section.points.len);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue