//! `zfin milestones` — show portfolio threshold crossings. //! //! Given the merged history series (native `*-portfolio.srf` //! snapshots take precedence over `imported_values.srf` on //! overlapping dates), find the dates the portfolio first //! reached each of a configured set of thresholds. //! //! Two threshold modes: //! - `--step 1M` (or `1000000`, `500K`, etc.) — fixed dollar //! multiples. //! - `--step 2x` — geometric multiples of the starting value //! ("doublings", "1.5x growth", etc.). //! //! Optional `--real` flag deflates the series to a reference //! year (last full year in `shiller.annual_returns`) before //! detecting crossings. //! //! No I/O beyond reading the data files; no network. const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const history = @import("../history.zig"); const Date = @import("../Date.zig"); const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); pub const ParsedArgs = struct { /// Raw `--step` value (e.g. `"1M"`, `"2x"`). Resolved into a typed /// step inside `run` so `parseStep`'s detailed error reporting can /// surface to the user with the right framing. step_raw: []const u8, real: bool = false, }; pub const meta = struct { pub const name: []const u8 = "milestones"; pub const group: framework.Group = .portfolio; pub const synopsis: []const u8 = "Show portfolio threshold crossings (each $1M, doublings, etc.)"; pub const help: []const u8 = \\Usage: zfin milestones --step [--real] \\ \\Find the dates the portfolio first reached each of a \\configured set of thresholds. Two threshold modes: \\ \\ Absolute dollar: 1M / 1m / 1500000 / 1.5M / 500K / 500k \\ Relative multiplier: 2x / 2X / 1.5x \\ \\Rejects %, ≤0 dollar steps, ≤1.0x multipliers, NaN/Inf. \\ \\Options: \\ --step Threshold step (required). \\ --real Deflate the series to the last full Shiller \\ year before detecting crossings (CPI-adjusted). \\ Default is nominal. \\ \\Note: crossing dates are "first observed at," bounded by the \\source series cadence (typically weekly). \\ ; }; comptime { framework.validateCommandModule(@This()); } pub const RunError = error{ UnexpectedArg, MissingStep, InvalidStep, NoData, OutOfMemory, WriteFailed, } || std.Io.Reader.Error || std.Io.Writer.Error || std.fs.File.OpenError || std.posix.RealPathError; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { var step_str: ?[]const u8 = null; var want_real = false; var i: usize = 0; while (i < cmd_args.len) : (i += 1) { const a = cmd_args[i]; if (std.mem.eql(u8, a, "--step")) { i += 1; if (i >= cmd_args.len) { try cli.stderrPrint(ctx.io, "Error: --step requires an argument\n"); return error.MissingStep; } step_str = cmd_args[i]; } else if (std.mem.eql(u8, a, "--real")) { want_real = true; } else { try cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': "); try cli.stderrPrint(ctx.io, a); try cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } const step_raw = step_str orelse { try cli.stderrPrint(ctx.io, "Error: --step is required\n"); try cli.stderrPrint(ctx.io, meta.help); return error.MissingStep; }; return .{ .step_raw = step_raw, .real = want_real }; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const io = ctx.io; const allocator = ctx.allocator; const out = ctx.out; const color = ctx.color; const want_real = parsed.real; const step = milestones.parseStep(parsed.step_raw) catch |err| { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, "Error: cannot parse --step '{s}': {s}\n", .{ parsed.step_raw, @errorName(err) }, ) catch "Error: invalid --step\n"; try cli.stderrPrint(io, msg); return error.InvalidStep; }; const pf = ctx.resolvePortfolioPath(); defer pf.deinit(allocator); const portfolio_path = pf.path; // Load merged series. var series_owned = try loadMergedSeries(io, allocator, portfolio_path); defer series_owned.deinit(allocator); if (series_owned.points.len == 0) { try cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n"); return error.NoData; } // Inflation adjustment: deflate the series to the reference year. const reference_year: u16 = shiller.last_year; const cpi_view = try buildCpiView(allocator); defer allocator.free(cpi_view); const series = if (want_real) blk: { const deflated = try allocator.alloc(milestones.Point, series_owned.points.len); for (series_owned.points, 0..) |p, idx| { const yr: u16 = @intCast(p.date.year()); deflated[idx] = .{ .date = p.date, .value = milestones.deflate(p.value, yr, reference_year, cpi_view), }; } break :blk deflated; } else series_owned.points; defer if (want_real) allocator.free(series); // Detect crossings. const crossings = try milestones.detectCrossings(allocator, series, step); defer allocator.free(crossings); // Render. try renderHeader(out, color, step, want_real, reference_year, series); if (crossings.len == 0) { try renderNoCrossings(out, color, series); return; } try renderTable(out, color, step, crossings); } // ── Series loading ─────────────────────────────────────────── /// A lightweight owned merged series: imported values overlaid /// with native snapshots (snapshots win on overlap), sorted /// ascending by date, deduped. const MergedSeries = struct { points: []milestones.Point, fn deinit(self: *MergedSeries, allocator: std.mem.Allocator) void { allocator.free(self.points); self.points = &.{}; } }; /// Thin wrapper over `history.loadTimeline` that projects the /// shared `TimelineSeries` (rich `TimelinePoint` records with /// liquid/illiquid/breakdowns/source) into the lightweight /// `(date, liquid)` shape that milestone-detection consumes. /// /// The merge logic — including snapshot-wins-on-overlap, sort /// order, and `imported_values.srf` discovery — lives in /// `history.loadTimeline` and `timeline.buildMergedSeries`. /// Keeping milestones routed through that single source of /// truth means future improvements (e.g. honoring more snapshot /// metadata) propagate automatically. fn loadMergedSeries( io: std.Io, allocator: std.mem.Allocator, portfolio_path: []const u8, ) !MergedSeries { var tl = try history.loadTimeline(io, allocator, portfolio_path); defer tl.deinit(); const points = try allocator.alloc(milestones.Point, tl.series.points.len); for (tl.series.points, 0..) |p, i| { points[i] = .{ .date = p.as_of_date, .value = p.liquid }; } return .{ .points = points }; } // ── CPI view builder ───────────────────────────────────────── fn buildCpiView( allocator: std.mem.Allocator, ) ![]milestones.YearCpi { const data = shiller.annual_returns; const view = try allocator.alloc(milestones.YearCpi, data.len); for (data, 0..) |yr, i| { view[i] = .{ .year = yr.year, .cpi = yr.cpi_inflation }; } return view; } // ── Rendering ──────────────────────────────────────────────── fn renderHeader( out: *std.Io.Writer, color: bool, step: milestones.Step, want_real: bool, reference_year: u16, series: []const milestones.Point, ) !void { try cli.setBold(out, color); switch (step) { .absolute => |s| { if (want_real) { try out.print( "Milestones — step {f} (real, reference year: {d})\n", .{ Money.from(s), reference_year }, ); } else { try out.print( "Milestones — step {f} (nominal)\n", .{Money.from(s)}, ); } }, .relative => |f| { const start = series[0].value; const real_str = if (want_real) " (real)" else ""; try out.print( "Milestones — step {d}x from {f} ({f}){s}\n", .{ f, Money.from(start), series[0].date, real_str }, ); }, } try cli.reset(out, color); try out.writeAll("\n"); } fn renderNoCrossings( out: *std.Io.Writer, color: bool, series: []const milestones.Point, ) !void { var max_v: f64 = series[0].value; for (series) |p| { if (p.value > max_v) max_v = p.value; } const start_v = series[0].value; try cli.setStyleIntent(out, color, .muted); try out.print( " No milestones reached. Series max: {f} (start: {f}).\n", .{ Money.from(max_v), Money.from(start_v) }, ); try cli.reset(out, color); } fn renderTable( out: *std.Io.Writer, color: bool, step: milestones.Step, crossings: []const milestones.Crossing, ) !void { const is_relative = step == .relative; // Header row. try cli.setBold(out, color); if (is_relative) { try out.writeAll(" Multiple Threshold Date Crossed Days Since Prev Days Since First\n"); try out.writeAll(" ──────── ────────── ───────────── ─────────────── ────────────────\n"); } else { try out.writeAll(" Milestone Date Crossed Days Since Prev Days Since First\n"); try out.writeAll(" ───────── ───────────── ─────────────── ────────────────\n"); } try cli.reset(out, color); var has_start_row = false; for (crossings) |c| { var date_buf: [10]u8 = undefined; const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{c.date}) catch "????-??-??"; var money_buf: [32]u8 = undefined; const money_str = std.fmt.bufPrint(&money_buf, "{f}", .{Money.from(c.threshold)}) catch "$?"; // The "days since prev" cell holds either "N days" (ASCII) // or the em-dash sentinel "—" (3 bytes / 1 display col). // Zig's `{s: 0) c.threshold / start_threshold else 0; try out.print( " {d: <8.4}x {s: <16} {s: <16}{s} {d} days{s}\n", .{ multiple, money_str, date_str, ds_prev_cell, c.days_since_first, star }, ); } else { try out.print( " {s: <16} {s: <16}{s} {d} days{s}\n", .{ money_str, date_str, ds_prev_cell, c.days_since_first, star }, ); } } if (has_start_row) { try out.writeAll("\n"); try cli.setStyleIntent(out, color, .muted); try out.writeAll(" * starting value, not a true crossing.\n"); try cli.reset(out, color); } } // ── Tests ──────────────────────────────────────────────────── test "parseArgs: --step and --real" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "--step", "1M", "--real" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqualStrings("1M", parsed.step_raw); try std.testing.expect(parsed.real); } test "parseArgs: --step only" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "--step", "2x" }; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqualStrings("2x", parsed.step_raw); try std.testing.expect(!parsed.real); } test "parseArgs: missing --step errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; try std.testing.expectError(error.MissingStep, parseArgs(&ctx, &args)); } test "parseArgs: --step without value errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"--step"}; try std.testing.expectError(error.MissingStep, parseArgs(&ctx, &args)); } test "parseArgs: unknown flag errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "--step", "1M", "--bogus" }; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "loadMergedSeries: empty when no history" { const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); const dir_path = path_buf[0..dir_len]; // A portfolio path that has no sibling history dir. const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" }); defer std.testing.allocator.free(fake_pf); var s = try loadMergedSeries(io, std.testing.allocator, fake_pf); defer s.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 0), s.points.len); } test "loadMergedSeries: imported values only" { const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); const dir_path = path_buf[0..dir_len]; // Create history/ dir and imported_values.srf. const hist_dir = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "history" }); defer std.testing.allocator.free(hist_dir); try std.Io.Dir.cwd().createDirPath(io, hist_dir); const iv_path = try std.fs.path.join(std.testing.allocator, &.{ hist_dir, "imported_values.srf" }); defer std.testing.allocator.free(iv_path); const iv_data = \\#!srfv1 \\date::2014-07-03,liquid:num:1280000 \\date::2015-01-09,liquid:num:1500000 \\date::2020-06-01,liquid:num:3000000 \\ ; { var f = try std.Io.Dir.cwd().createFile(io, iv_path, .{}); try f.writeStreamingAll(io, iv_data); f.close(io); } const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" }); defer std.testing.allocator.free(fake_pf); var s = try loadMergedSeries(io, std.testing.allocator, fake_pf); defer s.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 3), s.points.len); try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), s.points[0].date); try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value); try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date); }