migrate milestones to new cli framework
This commit is contained in:
parent
1228d50ce6
commit
51aaee9966
2 changed files with 109 additions and 57 deletions
|
|
@ -20,6 +20,7 @@
|
|||
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");
|
||||
|
|
@ -27,6 +28,45 @@ 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,
|
||||
|
|
@ -37,79 +77,60 @@ pub const RunError = error{
|
|||
} || std.Io.Reader.Error || std.Io.Writer.Error || std.fs.File.OpenError ||
|
||||
std.posix.RealPathError;
|
||||
|
||||
const usage_text =
|
||||
\\Usage: zfin milestones --step <expr> [--real]
|
||||
\\
|
||||
\\ --step <expr> Threshold step. Examples:
|
||||
\\ 1M / 1m / 1500000 / 1.5M (absolute dollar)
|
||||
\\ 500K / 500k (absolute thousands)
|
||||
\\ 2x / 2X / 1.5x (relative multiplier)
|
||||
\\ Rejects %, <=0, <=1.0x, NaN, Inf.
|
||||
\\ --real Deflate the series to the last full Shiller year
|
||||
\\ before detecting crossings (CPI-adjusted dollars).
|
||||
\\ Default is nominal.
|
||||
\\ -h, --help Show this help.
|
||||
\\
|
||||
\\Note: crossing dates are "first observed at," bounded by the
|
||||
\\source series cadence (typically weekly).
|
||||
\\
|
||||
;
|
||||
|
||||
pub fn run(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
svc: *zfin.DataService,
|
||||
portfolio_path: []const u8,
|
||||
args: []const []const u8,
|
||||
today: Date,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
_ = svc; // milestones reads local files only
|
||||
_ = today; // current behavior: detection bounded by series, no "now"
|
||||
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 < args.len) : (i += 1) {
|
||||
const a = args[i];
|
||||
while (i < cmd_args.len) : (i += 1) {
|
||||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--step")) {
|
||||
i += 1;
|
||||
if (i >= args.len) {
|
||||
try cli.stderrPrint(io, "Error: --step requires an argument\n");
|
||||
if (i >= cmd_args.len) {
|
||||
try cli.stderrPrint(ctx.io, "Error: --step requires an argument\n");
|
||||
return error.MissingStep;
|
||||
}
|
||||
step_str = args[i];
|
||||
step_str = cmd_args[i];
|
||||
} else if (std.mem.eql(u8, a, "--real")) {
|
||||
want_real = true;
|
||||
} else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
|
||||
try out.writeAll(usage_text);
|
||||
return;
|
||||
} else {
|
||||
try cli.stderrPrint(io, "Error: unknown argument to 'milestones': ");
|
||||
try cli.stderrPrint(io, a);
|
||||
try cli.stderrPrint(io, "\n");
|
||||
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_input = step_str orelse {
|
||||
try cli.stderrPrint(io, "Error: --step is required\n");
|
||||
try cli.stderrPrint(io, usage_text);
|
||||
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 };
|
||||
}
|
||||
|
||||
const step = milestones.parseStep(step_input) catch |err| {
|
||||
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",
|
||||
.{ step_input, @errorName(err) },
|
||||
.{ 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);
|
||||
|
|
@ -339,6 +360,45 @@ fn renderTable(
|
|||
|
||||
// ── 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(.{});
|
||||
|
|
|
|||
14
src/main.zig
14
src/main.zig
|
|
@ -22,6 +22,9 @@ const command_modules = .{
|
|||
.earnings = @import("commands/earnings.zig"),
|
||||
.etf = @import("commands/etf.zig"),
|
||||
|
||||
// Portfolio analysis
|
||||
.milestones = @import("commands/milestones.zig"),
|
||||
|
||||
// Data hygiene
|
||||
.enrich = @import("commands/enrich.zig"),
|
||||
.lookup = @import("commands/lookup.zig"),
|
||||
|
|
@ -721,17 +724,6 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
=> return 1,
|
||||
else => return err,
|
||||
};
|
||||
} else if (std.mem.eql(u8, command, "milestones")) {
|
||||
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
||||
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||
commands.milestones.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) {
|
||||
error.UnexpectedArg,
|
||||
error.MissingStep,
|
||||
error.InvalidStep,
|
||||
error.NoData,
|
||||
=> return 1,
|
||||
else => return err,
|
||||
};
|
||||
} else {
|
||||
try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n");
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue