initial projections back test

This commit is contained in:
Emil Lerch 2026-05-16 19:20:18 -07:00
parent 1b7b3992ba
commit ddf32e36da
4 changed files with 768 additions and 1 deletions

View file

@ -719,6 +719,51 @@ and a muted note flags the scaling.
The TUI surfaces this caveat on a status line whenever the overlay is
active.
### Forecast-vs-actual evaluation (`--convergence`, `--return-backtest`)
Two evaluation views over the spreadsheet's historical forecasts in
`imported_values.srf`. They complement `--overlay-actuals`:
- **Overlay** answers "did the actual trajectory fall inside the
model's bands?"
- **Convergence** answers "did the model converge on a retirement
date as data accumulated?"
- **Return back-test** answers "was the model's expected-return
assumption honest, in hindsight?"
```bash
zfin projections --convergence
zfin projections --return-backtest [--real]
```
`--convergence` reads each historical row's `projected_retirement`
field — the date the spreadsheet predicted you could retire — and
emits a table of `(observation_date, projected_date, years-until)`.
A flat downward slope of ~1y/year means the model was honestly
counting down. A flat-line at constant N means each year passes with
no progress (the prediction always says "N years away"). A `reached`
sentinel marks rows where the model said "you're already
retirement-ready."
`--return-backtest` reads each row's `expected_return` claim and
compares it to the realized 1y/3y/5y forward CAGR of the `liquid`
series. Useful for gut-checking whether the spreadsheet's
`min(1y,3y,5y,10y)`-weighted formula systematically over- or
under-predicted. Pair with `--real` to compare against
inflation-deflated realized returns (the expected column stays
nominal — it's a return rate the source spreadsheet captured as
nominal).
The CLI emits sampled tables (every Nth observation/anchor) for
quick scanning. The TUI projections tab renders the same data as
high-fidelity Kitty-graphics line charts; press `c` (convergence)
or `r` (return back-test) on the projections tab to switch views.
Both views are also subject to the "directional honesty, not SWR
validity" caveat: they evaluate the model's inputs and outputs over
time, not whether the underlying SWR claim will hold up over a
30-year retirement.
### Life events
Life events modify the simulation's annual cash flow. Positive

View file

@ -0,0 +1,432 @@
//! Forecast-vs-actual evaluation analytics.
//!
//! Two evaluation views over the spreadsheet's historical
//! forecasts (loaded from `imported_values.srf` via
//! `src/data/imported_values.zig`):
//!
//! 1. **Convergence**: how the spreadsheet's predicted
//! retirement date moved over time as data accumulated.
//! For each anchor row that supplied a
//! `projected_retirement` field, emit a
//! `(observation_date, years_until_retirement)` pair so a
//! chart can show whether the prediction was converging on
//! "now" (slope -1) or pushing out (slope > -1).
//!
//! 2. **Return back-test**: how the spreadsheet's
//! `expected_return` claim compared to what actually
//! happened over the next 1y/3y/5y. For each anchor row
//! that supplied an `expected_return`, find the future
//! data point ~N years later and compute the realized
//! CAGR of the `liquid` series over that window.
//!
//! Both functions are pure: deterministic over their inputs,
//! no I/O, no allocation lifetime concerns beyond the returned
//! slice. The view layer (`src/views/forecast_evaluation.zig`)
//! and the rendering layer (CLI braille / TUI Kitty graphics)
//! consume these structs.
const std = @import("std");
const Date = @import("../Date.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("milestones.zig");
const HistoryPoint = imported.HistoryPoint;
const ProjectedRetirement = imported.ProjectedRetirement;
// View 1: Convergence
/// One emitted point on the retirement-date convergence chart.
///
/// `years_until_retirement` is computed as
/// `Date.yearsBetween(observation_date, projected_date)` for
/// `projected_retirement = .date`, or `0.0` when the source
/// row was `.reached`. The reached_at_observation flag lets
/// renderers style "reached" rows differently (e.g. a
/// distinct marker color) without having to detect them by
/// the `0.0` value alone a row could legitimately have the
/// projected date EQUAL the observation date and produce
/// `years_until_retirement = 0.0` without being a `reached`
/// sentinel.
pub const ConvergencePoint = struct {
observation_date: Date,
projected_date: Date,
years_until_retirement: f64,
reached: bool,
};
/// Extract convergence points from a slice of imported history
/// rows. Rows with `projected_retirement = null` are skipped
/// (they don't contribute to the chart). The output is in
/// `points`-input order, which the importer guarantees is
/// date-ascending.
///
/// Caller owns the returned slice.
pub fn convergencePoints(
allocator: std.mem.Allocator,
points: []const HistoryPoint,
) ![]ConvergencePoint {
var out: std.ArrayList(ConvergencePoint) = .empty;
errdefer out.deinit(allocator);
for (points) |p| {
const proj = p.projected_retirement orelse continue;
switch (proj) {
.reached => try out.append(allocator, .{
.observation_date = p.date,
// The "projected_date" for a reached row is the
// observation date itself the model said
// "retirement-ready right now."
.projected_date = p.date,
.years_until_retirement = 0.0,
.reached = true,
}),
.date => |target| try out.append(allocator, .{
.observation_date = p.date,
.projected_date = target,
.years_until_retirement = Date.yearsBetween(p.date, target),
.reached = false,
}),
}
}
return out.toOwnedSlice(allocator);
}
// View 2: Return back-test
/// One row of back-test output: realized forward CAGR for one
/// (anchor_date, horizon_years) pair, alongside the spreadsheet's
/// claimed `expected_return` at that anchor.
///
/// `realized_cagr` is `null` when no future data point exists at
/// or near `anchor + horizon` (i.e., the anchor is too recent
/// for that horizon).
///
/// `expected_return` is replicated from the anchor row so the
/// chart layer can plot expected and realized side-by-side
/// without re-joining against the source data.
pub const BacktestPoint = struct {
anchor_date: Date,
horizon_years: u16,
expected_return: f64,
realized_cagr: ?f64,
};
/// Tolerance window around `anchor + horizon` years when
/// matching a future data point. ±2 weeks per spec wider
/// than 1 week (catches week-of-the-month drift) but narrow
/// enough that the matched point is still meaningfully "N
/// years later."
const horizon_match_tolerance_days: i32 = 14;
/// Compute realized forward CAGR for each (anchor row, horizon)
/// pair. Anchors lacking `expected_return` are skipped (no
/// claim to back-test). Anchors with `expected_return` but no
/// matching future data point at any requested horizon emit
/// rows with `realized_cagr = null` for that horizon.
///
/// `points` must be date-ascending (the importer guarantees
/// this via `parseImportedValues`). `horizons` lists the
/// forward windows to evaluate, in years (typical: 1, 3, 5).
///
/// When `real_mode` is true, both the anchor and matched-future
/// `liquid` values are deflated to a common reference year (the
/// year of the most recent anchor in `points`) before the CAGR
/// is computed. Uses the Shiller annual CPI series via
/// `milestones.deflate`. The `expected_return` on the anchor
/// row is left as-is it's a return rate not a level, and the
/// source spreadsheet captured it as nominal.
///
/// Caller owns the returned slice. Output order is
/// `(anchor_index, horizon_index)` anchors in input order,
/// horizons in `horizons` order. Skipped anchors produce zero
/// output rows; partially-skipped anchors produce one row per
/// horizon with `realized_cagr = null` only for the
/// horizons that lack matching future data.
pub fn returnBacktest(
allocator: std.mem.Allocator,
points: []const HistoryPoint,
horizons: []const u16,
real_mode: bool,
cpi: []const milestones.YearCpi,
) ![]BacktestPoint {
var out: std.ArrayList(BacktestPoint) = .empty;
errdefer out.deinit(allocator);
if (points.len == 0 or horizons.len == 0) return out.toOwnedSlice(allocator);
// Pick the reference year for `--real` deflation: the year
// of the most recent anchor. All values normalize to that
// year's dollars.
const ref_year: u16 = blk: {
const last_year = points[points.len - 1].date.year();
break :blk @intCast(@max(last_year, 0));
};
for (points) |anchor| {
const expected = anchor.expected_return orelse continue;
for (horizons) |h| {
const matched = matchForwardPoint(points, anchor.date, h);
const realized: ?f64 = if (matched) |fp|
computeCagr(anchor, fp, h, real_mode, ref_year, cpi)
else
null;
try out.append(allocator, .{
.anchor_date = anchor.date,
.horizon_years = h,
.expected_return = expected,
.realized_cagr = realized,
});
}
}
return out.toOwnedSlice(allocator);
}
/// Find a `HistoryPoint` whose `date` is within
/// `±horizon_match_tolerance_days` of `anchor + horizon` years.
/// Returns the closest matching point, or null if no point is
/// within tolerance.
///
/// Linear scan `points` is at most a few hundred rows, no
/// indexing optimization needed.
fn matchForwardPoint(
points: []const HistoryPoint,
anchor_date: Date,
horizon_years: u16,
) ?HistoryPoint {
const target = anchor_date.addYears(horizon_years);
var best: ?HistoryPoint = null;
var best_dist: i32 = std.math.maxInt(i32);
for (points) |p| {
const diff: i32 = @intCast(@abs(p.date.days - target.days));
if (diff > horizon_match_tolerance_days) continue;
if (diff < best_dist) {
best = p;
best_dist = diff;
}
}
return best;
}
/// Compute the CAGR of `from.liquid` `to.liquid` over
/// `horizon_years`. When `real_mode` is true, both endpoints
/// are deflated to `ref_year` dollars first. Returns null if
/// the math would produce a non-finite result (e.g. zero or
/// negative liquid value at either endpoint).
fn computeCagr(
from: HistoryPoint,
to: HistoryPoint,
horizon_years: u16,
real_mode: bool,
ref_year: u16,
cpi: []const milestones.YearCpi,
) ?f64 {
var v0 = from.liquid;
var v1 = to.liquid;
if (real_mode) {
const from_year: u16 = @intCast(@max(from.date.year(), 0));
const to_year: u16 = @intCast(@max(to.date.year(), 0));
v0 = milestones.deflate(v0, from_year, ref_year, cpi);
v1 = milestones.deflate(v1, to_year, ref_year, cpi);
}
if (v0 <= 0 or v1 <= 0) return null;
const ratio = v1 / v0;
const exponent = 1.0 / @as(f64, @floatFromInt(horizon_years));
return std.math.pow(f64, ratio, exponent) - 1.0;
}
// Tests
const testing = std.testing;
fn pt(year: i16, month: u8, day: u8, liquid: f64, exp_ret: ?f64, proj: ?ProjectedRetirement) HistoryPoint {
return .{
.date = Date.fromYmd(year, month, day),
.liquid = liquid,
.expected_return = exp_ret,
.projected_retirement = proj,
};
}
test "convergencePoints: empty input returns empty" {
const out = try convergencePoints(testing.allocator, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "convergencePoints: skips rows with null projected_retirement" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, null, null),
pt(2020, 1, 8, 1010, null, .{ .date = Date.fromYmd(2030, 1, 1) }),
pt(2020, 1, 15, 1020, null, null),
};
const out = try convergencePoints(testing.allocator, &points);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
try testing.expect(out[0].observation_date.eql(Date.fromYmd(2020, 1, 8)));
}
test "convergencePoints: years_until_retirement is yearsBetween" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, null, .{ .date = Date.fromYmd(2030, 1, 1) }),
};
const out = try convergencePoints(testing.allocator, &points);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
try testing.expectApproxEqAbs(@as(f64, 10.0), out[0].years_until_retirement, 0.01);
try testing.expect(!out[0].reached);
}
test "convergencePoints: reached emits zero years and reached=true" {
const points = [_]HistoryPoint{
pt(2025, 6, 15, 5000000, null, .reached),
};
const out = try convergencePoints(testing.allocator, &points);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
try testing.expectEqual(@as(f64, 0.0), out[0].years_until_retirement);
try testing.expect(out[0].reached);
// For reached rows, projected_date == observation_date so
// the chart can plot them on the y=0 axis at the anchor x.
try testing.expect(out[0].projected_date.eql(out[0].observation_date));
}
test "convergencePoints: preserves source date order" {
// Importer guarantees ascending; we should preserve that.
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, null, .{ .date = Date.fromYmd(2030, 1, 1) }),
pt(2021, 1, 1, 1100, null, .{ .date = Date.fromYmd(2031, 1, 1) }),
pt(2022, 1, 1, 1200, null, .{ .date = Date.fromYmd(2030, 6, 1) }),
};
const out = try convergencePoints(testing.allocator, &points);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 3), out.len);
try testing.expectApproxEqAbs(@as(f64, 10.0), out[0].years_until_retirement, 0.01);
try testing.expectApproxEqAbs(@as(f64, 10.0), out[1].years_until_retirement, 0.01);
// 2022-01-01 2030-06-01 8.4 years
try testing.expectApproxEqAbs(@as(f64, 8.42), out[2].years_until_retirement, 0.05);
}
test "returnBacktest: empty input returns empty" {
const out = try returnBacktest(testing.allocator, &.{}, &.{ 1, 3, 5 }, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "returnBacktest: empty horizons returns empty" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, 0.10, null),
};
const out = try returnBacktest(testing.allocator, &points, &.{}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "returnBacktest: skips rows lacking expected_return" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, null, null),
pt(2021, 1, 1, 1100, null, null),
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "returnBacktest: 1y CAGR matches hand-computed value" {
const points = [_]HistoryPoint{
// Anchor 2020-01-01 with claimed 10%; future point one
// year later at $1100 realized 10%.
pt(2020, 1, 1, 1000, 0.10, null),
pt(2021, 1, 2, 1100, null, null), // close to 2021-01-01 (+1 day, within 14-day tolerance)
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
try testing.expectEqualDeep(Date.fromYmd(2020, 1, 1), out[0].anchor_date);
try testing.expectEqual(@as(u16, 1), out[0].horizon_years);
try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].expected_return, 0.001);
try testing.expect(out[0].realized_cagr != null);
try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
}
test "returnBacktest: realized null when no future data point in tolerance" {
const points = [_]HistoryPoint{
// Only one row, no future data 1y horizon can't match.
pt(2025, 6, 1, 1000, 0.08, null),
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
try testing.expectEqual(@as(?f64, null), out[0].realized_cagr);
}
test "returnBacktest: emits one row per horizon per qualifying anchor" {
const points = [_]HistoryPoint{
pt(2018, 1, 1, 1000, 0.10, null),
pt(2019, 1, 1, 1100, null, null), // +1y match
pt(2021, 1, 1, 1331, null, null), // +3y match (1.10^3 = 1.331)
pt(2023, 1, 1, 1611, null, null), // +5y match (1.10^5 1.611)
};
const out = try returnBacktest(testing.allocator, &points, &.{ 1, 3, 5 }, false, &.{});
defer testing.allocator.free(out);
// One anchor × three horizons = 3 rows.
try testing.expectEqual(@as(usize, 3), out.len);
try testing.expectEqual(@as(u16, 1), out[0].horizon_years);
try testing.expectEqual(@as(u16, 3), out[1].horizon_years);
try testing.expectEqual(@as(u16, 5), out[2].horizon_years);
// Each horizon's realized CAGR is ~10%.
try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
try testing.expectApproxEqAbs(@as(f64, 0.10), out[1].realized_cagr.?, 0.005);
try testing.expectApproxEqAbs(@as(f64, 0.10), out[2].realized_cagr.?, 0.005);
}
test "returnBacktest: tolerance picks the closest point within ±14 days" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, 0.10, null),
// Two candidates both within tolerance for the 1y window;
// closer one (2021-01-01, exact match) should win over
// the +10-day candidate.
pt(2021, 1, 11, 1200, null, null),
pt(2021, 1, 1, 1100, null, null),
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
// Closer match (2021-01-01) wins realized = 1100/1000 - 1 = 10%.
try testing.expectApproxEqAbs(@as(f64, 0.10), out[0].realized_cagr.?, 0.005);
}
test "returnBacktest: real mode deflates endpoints to reference year" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 1000, 0.10, null),
pt(2021, 1, 1, 1100, null, null),
};
// Synthetic CPI: 2020 had 5% inflation. With real mode
// and 2021 as ref year:
// v0 (2020) inflated to 2021 dollars: 1000 * 1.05 = 1050
// v1 (2021) stays at 1100
// CAGR = 1100/1050 - 1 4.76% (vs 10% nominal)
const cpi = [_]milestones.YearCpi{
.{ .year = 2020, .cpi = 0.05 },
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, true, &cpi);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 1), out.len);
try testing.expectApproxEqAbs(@as(f64, 0.0476), out[0].realized_cagr.?, 0.001);
}
test "returnBacktest: zero or negative liquid yields null realized" {
const points = [_]HistoryPoint{
pt(2020, 1, 1, 0, 0.10, null), // zero starting value
pt(2021, 1, 1, 1100, null, null),
};
const out = try returnBacktest(testing.allocator, &points, &.{1}, false, &.{});
defer testing.allocator.free(out);
try testing.expectEqual(@as(?f64, null), out[0].realized_cagr);
}

View file

@ -21,6 +21,10 @@ const valuation = @import("../analytics/valuation.zig");
const view = @import("../views/projections.zig");
const history = @import("../history.zig");
const timeline = @import("../analytics/timeline.zig");
const imported = @import("../data/imported_values.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
/// Hardcoded benchmark symbols (configurable in a future version).
const stock_benchmark = "SPY";
@ -586,6 +590,249 @@ pub fn runCompare(
try out.print("\n", .{});
}
// Forecast-vs-actual evaluation views
/// Horizons used by `--return-backtest`. 1y/3y/5y per the
/// V1 spec; 10y is a polish-pass addition.
const backtest_horizons: []const u16 = &.{ 1, 3, 5 };
/// `zfin projections --convergence` entry point. Renders a
/// summary table of `(observation_date, projected_date,
/// years_until)` from the imported spreadsheet history. The CLI
/// is intentionally table-based the high-fidelity chart lives
/// on the TUI projections tab.
///
/// Source data: `<portfolio_dir>/history/imported_values.srf`,
/// loaded via `data.imported_values.loadImportedValues`. Missing
/// file is treated as "no historical data," not an error: the
/// command emits a one-line note and returns successfully.
///
/// Caveat (per spec): this view shows whether the model was
/// directionally honest about retirement timing. It does NOT
/// validate the SWR claim itself that's a 30-year claim we
/// can't validate within either of our lifetimes.
pub fn runConvergence(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
color: bool,
out: *std.Io.Writer,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const hist_dir = try history.deriveHistoryDir(va, file_path);
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
const points = try forecast.convergencePoints(va, iv.points);
try cli.setBold(out, color);
try out.print("Projection convergence (spreadsheet-projected retirement date over time)\n", .{});
try cli.reset(out, color);
if (points.len == 0) {
try cli.printFg(out, color, cli.CLR_MUTED, "No convergence data available (imported_values.srf empty or missing projected_retirement fields).\n", .{});
return;
}
// Range header first/last observation dates.
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" {d} observations from {f} → {f}\n",
.{ points.len, points[0].observation_date, points[points.len - 1].observation_date },
);
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" Caveat: tracks the model's directional honesty, not SWR validity.\n",
.{},
);
try out.print("\n", .{});
// Table header
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<12} {s:<12} {s:>14}\n", .{ "Observed", "Projected", "Years until" });
try out.print(" {s:-<12} {s:-<12} {s:->14}\n", .{ "", "", "" });
try cli.reset(out, color);
// Body show first, last, and every 13th in between for a
// ~quarterly cadence on weekly imported data. Reading the
// entire 11.5-year history line-by-line is rarely useful;
// the chart on the TUI is the high-fidelity surface.
const stride: usize = if (points.len > 26) (points.len + 25) / 26 else 1;
for (points, 0..) |p, i| {
if (i != 0 and i != points.len - 1 and (i % stride) != 0) continue;
var proj_buf: [16]u8 = undefined;
const proj_str: []const u8 = if (p.reached) "reached" else std.fmt.bufPrint(&proj_buf, "{f}", .{p.projected_date}) catch "??????????";
var years_buf: [16]u8 = undefined;
const years_str: []const u8 = if (p.reached)
"0.00"
else
std.fmt.bufPrint(&years_buf, "{d:.2}", .{p.years_until_retirement}) catch "??";
try out.print(" {f} {s:<12} {s:>14}\n", .{ p.observation_date, proj_str, years_str });
}
if (stride > 1) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (Showing every {d}th observation — full chart on TUI projections tab.)\n", .{stride});
}
try out.print("\n", .{});
}
/// `zfin projections --return-backtest [--real]` entry point.
/// Renders a summary table comparing the spreadsheet's
/// `expected_return` claim to realized 1y/3y/5y forward CAGR
/// for each anchor row.
///
/// When `real_mode` is true, the realized CAGR is computed
/// against inflation-deflated `liquid` values (Shiller annual
/// CPI). The expected_return column is left as-is (it's a return
/// rate, not a level but it's a nominal return as captured by
/// the source spreadsheet, which means real-mode is comparing
/// nominal-claim against real-realized; useful but watch the
/// caveat in the output).
pub fn runReturnBacktest(
io: std.Io,
allocator: std.mem.Allocator,
file_path: []const u8,
real_mode: bool,
color: bool,
out: *std.Io.Writer,
) !void {
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
const va = arena_state.allocator();
const hist_dir = try history.deriveHistoryDir(va, file_path);
const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" });
var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| {
try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n");
return err;
};
defer iv.deinit();
// Build the YearCpi slice from Shiller's annual_returns. Same
// shape `milestones.deflate` accepts.
var cpi_list: std.ArrayList(milestones.YearCpi) = .empty;
defer cpi_list.deinit(va);
for (shiller.annual_returns) |yr| {
try cpi_list.append(va, .{ .year = yr.year, .cpi = yr.cpi_inflation });
}
const rows = try forecast.returnBacktest(va, iv.points, backtest_horizons, real_mode, cpi_list.items);
try cli.setBold(out, color);
try out.print("Expected vs realized return back-test\n", .{});
try cli.reset(out, color);
if (rows.len == 0) {
try cli.printFg(out, color, cli.CLR_MUTED, "No back-test data available (imported_values.srf empty or missing expected_return fields).\n", .{});
return;
}
// Header note explaining the methodology.
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" expected_return = spreadsheet's min(1y,3y,5y,10y)-weighted claim at each anchor.\n",
.{},
);
if (real_mode) {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" realized = inflation-deflated forward CAGR (Shiller CPI). expected is left nominal.\n",
.{},
);
} else {
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" realized = nominal forward CAGR. Pair with --real to deflate.\n",
.{},
);
}
try cli.printFg(
out,
color,
cli.CLR_MUTED,
" Caveat: tracks the model's expected-return honesty, not SWR validity.\n",
.{},
);
try out.print("\n", .{});
// Group rows by anchor: emit one line per anchor with three
// realized columns. Rows are emitted in (anchor, horizon)
// order from `forecast.returnBacktest`.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:<12} {s:>10} {s:>10} {s:>10} {s:>10}\n", .{ "Anchor", "Expected", "1y", "3y", "5y" });
try out.print(" {s:-<12} {s:->10} {s:->10} {s:->10} {s:->10}\n", .{ "", "", "", "", "" });
try cli.reset(out, color);
// Show at most ~30 evenly-spaced anchors so the table
// stays scannable. Anchors are in ascending order.
var anchor_rows = std.ArrayList(struct { date: Date, expected: f64, r1: ?f64, r3: ?f64, r5: ?f64 }).empty;
defer anchor_rows.deinit(va);
var i: usize = 0;
while (i < rows.len) {
const anchor_date = rows[i].anchor_date;
const expected = rows[i].expected_return;
var r1: ?f64 = null;
var r3: ?f64 = null;
var r5: ?f64 = null;
while (i < rows.len and rows[i].anchor_date.eql(anchor_date)) : (i += 1) {
switch (rows[i].horizon_years) {
1 => r1 = rows[i].realized_cagr,
3 => r3 = rows[i].realized_cagr,
5 => r5 = rows[i].realized_cagr,
else => {},
}
}
try anchor_rows.append(va, .{ .date = anchor_date, .expected = expected, .r1 = r1, .r3 = r3, .r5 = r5 });
}
const stride: usize = if (anchor_rows.items.len > 30) (anchor_rows.items.len + 29) / 30 else 1;
for (anchor_rows.items, 0..) |a, idx| {
if (idx != 0 and idx != anchor_rows.items.len - 1 and (idx % stride) != 0) continue;
var ebuf: [16]u8 = undefined;
const e_str = std.fmt.bufPrint(&ebuf, "{d:.2}%", .{a.expected * 100}) catch "??";
var r1_buf: [16]u8 = undefined;
var r3_buf: [16]u8 = undefined;
var r5_buf: [16]u8 = undefined;
const r1_str: []const u8 = if (a.r1) |v| std.fmt.bufPrint(&r1_buf, "{d:.2}%", .{v * 100}) catch "??" else "";
const r3_str: []const u8 = if (a.r3) |v| std.fmt.bufPrint(&r3_buf, "{d:.2}%", .{v * 100}) catch "??" else "";
const r5_str: []const u8 = if (a.r5) |v| std.fmt.bufPrint(&r5_buf, "{d:.2}%", .{v * 100}) catch "??" else "";
try out.print(" {f} {s:>10} {s:>10} {s:>10} {s:>10}\n", .{ a.date, e_str, r1_str, r3_str, r5_str });
}
if (stride > 1) {
try out.print("\n", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (Showing every {d}th anchor — full chart on TUI projections tab.)\n", .{stride});
}
try out.print("\n", .{});
}
/// Shared key-metrics comparison used by both `projections --vs` and
/// `compare --projections`. Returns `then`/`now` metrics ready for
/// rendering, plus the snapshot resolutions for header rendering.

View file

@ -108,6 +108,14 @@ const usage =
\\ and safe-withdrawal @99% for live vs DATE, with
\\ deltas. Combine with --as-of to compare two
\\ historical dates (--vs = then, --as-of = now).
\\ --convergence Plot the spreadsheet's predicted retirement date
\\ over time as data accumulated. Sources data from
\\ imported_values.srf. Caveat: this evaluates the
\\ model's directional honesty, not its SWR claim.
\\ --return-backtest Plot the spreadsheet's expected_return claim over
\\ time alongside realized 1y/3y/5y forward CAGR.
\\ Sources data from imported_values.srf. Pair with
\\ --real to compare in inflation-adjusted dollars.
\\
\\Milestones command options:
\\ --step <expr> Threshold step. Required.
@ -525,6 +533,9 @@ fn runCli(init: std.process.Init) !u8 {
var as_of: ?zfin.Date = null;
var vs_date: ?zfin.Date = null;
var overlay_actuals = false;
var convergence = false;
var return_backtest = false;
var real_mode = false;
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i];
@ -532,6 +543,12 @@ fn runCli(init: std.process.Init) !u8 {
events_enabled = false;
} else if (std.mem.eql(u8, a, "--overlay-actuals")) {
overlay_actuals = true;
} else if (std.mem.eql(u8, a, "--convergence")) {
convergence = true;
} else if (std.mem.eql(u8, a, "--return-backtest")) {
return_backtest = true;
} else if (std.mem.eql(u8, a, "--real")) {
real_mode = 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: ");
@ -566,12 +583,38 @@ fn runCli(init: std.process.Init) !u8 {
return 1;
}
}
// Mutually-exclusive view flags. Forecast-evaluation flags
// (`--convergence`, `--return-backtest`) replace the default
// bands view entirely; combining them with each other,
// `--vs`, or `--overlay-actuals` is rejected.
if (convergence and return_backtest) {
try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
return 1;
}
if ((convergence or return_backtest) and vs_date != null) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
return 1;
}
if ((convergence or return_backtest) and overlay_actuals) {
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
return 1;
}
if (real_mode and !return_backtest) {
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
return 1;
}
if (as_of != null and vs_date == null) {
// Single-date mode: view that snapshot only.
}
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
if (vs_date) |d| {
if (convergence) {
try commands.projections.runConvergence(io, allocator, pf.path, color, out);
} else if (return_backtest) {
try commands.projections.runReturnBacktest(io, allocator, pf.path, real_mode, color, out);
} else if (vs_date) |d| {
// Compare mode. `as_of` (if set) designates the "now"
// side otherwise now is live. `--vs` alone compares
// live against a historical date; `--vs X --as-of Y`