zfin/src/commands/milestones.zig

459 lines
17 KiB
Zig

//! `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 <expr> [--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 <expr> 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: <N}` pads by bytes and under-pads multibyte
// content; `fmt.padRightToCols` accounts for display width.
const ds_prev_target_cols: usize = 16;
var prev_cell_buf: [48]u8 = undefined;
const ds_prev_cell = blk: {
const inner: []const u8 = if (c.days_since_prev) |d|
std.fmt.bufPrint(&prev_cell_buf, "{d} days", .{d}) catch "?"
else inner_dash: {
const dash = "";
@memcpy(prev_cell_buf[0..dash.len], dash);
break :inner_dash prev_cell_buf[0..dash.len];
};
break :blk fmt.padRightToCols(&prev_cell_buf, inner, ds_prev_target_cols);
};
const star: []const u8 = if (c.is_start) " *" else " ";
if (c.is_start) has_start_row = true;
if (is_relative) {
// Multiple expressed relative to crossing index. The
// synthetic starting row is "1x"; subsequent are
// computed via factor, but the simplest faithful
// rendering is to label by `factor^(index-1)` —
// which the analytics already encoded in `threshold`.
// We render the *index* as `Nx` rendering for clarity.
// For the synthetic row, that's "1x"; for subsequent
// rows it's `factor^(index-1)x`. We display via a
// computed multiple from `threshold / start`.
const start_threshold = crossings[0].threshold;
const multiple = if (start_threshold > 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);
}