459 lines
17 KiB
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);
|
|
}
|