initial projections back test
This commit is contained in:
parent
1b7b3992ba
commit
ddf32e36da
4 changed files with 768 additions and 1 deletions
45
README.md
45
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
|
||||
|
|
|
|||
432
src/analytics/forecast_evaluation.zig
Normal file
432
src/analytics/forecast_evaluation.zig
Normal 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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
45
src/main.zig
45
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 <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`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue