diff --git a/README.md b/README.md index 1b73df6..52e6f62 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/analytics/forecast_evaluation.zig b/src/analytics/forecast_evaluation.zig new file mode 100644 index 0000000..9658ad1 --- /dev/null +++ b/src/analytics/forecast_evaluation.zig @@ -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); +} diff --git a/src/commands/projections.zig b/src/commands/projections.zig index f972430..d49c725 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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: `/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. diff --git a/src/main.zig b/src/main.zig index d4a6687..a69cba6 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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 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`