migrate history to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:02:02 -07:00
parent 63f5cc445b
commit 1228d50ce6
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 126 additions and 47 deletions

View file

@ -32,6 +32,7 @@ const std = @import("std");
const srf = @import("srf");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const atomic = @import("../atomic.zig");
const timeline = @import("../analytics/timeline.zig");
const history = @import("../history.zig");
@ -40,6 +41,54 @@ const view = @import("../views/history.zig");
const fmt = cli.fmt;
const Date = @import("../Date.zig");
/// Parsed args for `zfin history`. Tagged union since the command has
/// two genuinely different modes:
///
/// - Symbol mode: `zfin history <SYMBOL>` shows the last 30 days of
/// candles for one symbol. Triggered when `args[0]` exists and
/// doesn't start with `-`.
/// - Portfolio mode: `zfin history [flags]` shows the portfolio-value
/// timeline derived from `history/*-portfolio.srf` snapshots.
/// Triggered when there are no positional args (or only flags).
pub const ParsedArgs = union(enum) {
symbol: []const u8,
portfolio: PortfolioOpts,
};
pub const meta = struct {
pub const name: []const u8 = "history";
pub const group: framework.Group = .symbol_lookup;
pub const synopsis: []const u8 = "Price history (symbol) or portfolio value timeline";
pub const uppercase_first_arg: bool = true;
pub const help: []const u8 =
\\Usage:
\\ zfin history <SYMBOL> # last 30 days of candles
\\ zfin history [flags] # portfolio-value timeline
\\
\\Two modes in one command. Symbol mode (positional symbol)
\\shows the last 30 trading days of candles for that symbol.
\\Portfolio mode (no positional, optionally with flags) reads
\\`history/*-portfolio.srf` snapshots and renders rolling-windows
\\returns + a braille chart + a recent-snapshots table.
\\
\\Portfolio-mode flags:
\\ --since <DATE> earliest as-of date (inclusive)
\\ --until <DATE> latest as-of date (inclusive)
\\ --metric <name> liquid (default), illiquid, or net_worth
\\ --resolution <name> daily | weekly | monthly | auto
\\ (auto: daily ≤90d, weekly ≤730d, else monthly)
\\ --limit <N> cap recent-snapshots table to N rows (default 40)
\\ --rebuild-rollup regenerate history/rollup.srf and exit
\\
\\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y).
\\
;
};
comptime {
framework.validateCommandModule(@This());
}
pub const Error = error{
UnexpectedArg,
InvalidFlagValue,
@ -116,35 +165,39 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port
return opts;
}
/// Entry point. Dispatches to symbol mode or portfolio mode based on
/// the first argument.
pub fn run(
io: std.Io,
allocator: std.mem.Allocator,
svc: *zfin.DataService,
portfolio_path: []const u8,
args: []const []const u8,
as_of: zfin.Date,
color: bool,
out: *std.Io.Writer,
) !void {
if (args.len > 0 and args[0].len > 0 and args[0][0] != '-') {
try runSymbol(io, svc, args[0], as_of, color, out);
return;
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') {
if (cmd_args.len > 1) {
try cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n");
return error.UnexpectedArg;
}
return .{ .symbol = cmd_args[0] };
}
const opts = parsePortfolioOpts(as_of, args) catch |err| {
const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| {
switch (err) {
error.UnexpectedArg => try cli.stderrPrint(io, "Error: unknown flag in 'history'. See --help.\n"),
error.MissingFlagValue => try cli.stderrPrint(io, "Error: flag requires a value.\n"),
error.InvalidFlagValue => try cli.stderrPrint(io, "Error: invalid flag value.\n"),
error.UnknownMetric => try cli.stderrPrint(io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
error.UnknownResolution => try cli.stderrPrint(io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"),
error.MissingFlagValue => try cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"),
error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
error.UnknownMetric => try cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
error.UnknownResolution => try cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
}
return err;
};
return .{ .portfolio = opts };
}
try runPortfolio(io, allocator, portfolio_path, opts, color, out);
/// Entry point. Dispatches to symbol mode or portfolio mode based on
/// the parsed args.
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
switch (parsed) {
.symbol => |sym| try runSymbol(ctx.io, svc, sym, ctx.today, ctx.color, ctx.out),
.portfolio => |opts| {
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out);
},
}
}
// Symbol mode (legacy)
@ -627,6 +680,54 @@ fn renderCascadingTable(
const testing = std.testing;
test "parseArgs: positional symbol → .symbol variant" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{"AAPL"};
const parsed = try parseArgs(&ctx, &args);
switch (parsed) {
.symbol => |s| try std.testing.expectEqualStrings("AAPL", s),
.portfolio => try std.testing.expect(false),
}
}
test "parseArgs: empty args → .portfolio variant with defaults" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
switch (parsed) {
.symbol => try std.testing.expect(false),
.portfolio => |opts| {
try std.testing.expect(opts.since == null);
try std.testing.expect(opts.until == null);
try std.testing.expectEqual(timeline.Metric.liquid, opts.metric);
},
}
}
test "parseArgs: --since flag → .portfolio variant" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "--since", "2026-01-01" };
const parsed = try parseArgs(&ctx, &args);
switch (parsed) {
.symbol => try std.testing.expect(false),
.portfolio => |opts| try std.testing.expect(opts.since.?.eql(Date.fromYmd(2026, 1, 1))),
}
}
test "parseArgs: symbol mode rejects extra positional" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = Date.fromYmd(2026, 5, 9);
const args = [_][]const u8{ "AAPL", "MSFT" };
try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "parsePortfolioOpts: defaults" {
const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &.{});
try testing.expect(o.since == null);

View file

@ -15,6 +15,7 @@ const command_modules = .{
// Per-symbol lookups
.perf = @import("commands/perf.zig"),
.quote = @import("commands/quote.zig"),
.history = @import("commands/history.zig"),
.divs = @import("commands/divs.zig"),
.splits = @import("commands/splits.zig"),
.options = @import("commands/options.zig"),
@ -468,30 +469,7 @@ fn runCli(init: std.process.Init) !u8 {
cmd_args = owned;
}
if (std.mem.eql(u8, command, "history")) {
// Two modes in one command:
// zfin history <SYMBOL> candle history for a symbol (legacy)
// zfin history [flags] portfolio timeline from history/*.srf
//
// Only portfolio mode needs portfolio.srf; symbol mode must keep
// working in directories without a configured portfolio. Dispatch
// at this level so that constraint is visible here, not buried
// inside the command.
const is_symbol_mode = cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-';
if (is_symbol_mode) {
commands.history.run(io, allocator, &svc, "", cmd_args, today, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1,
else => return err,
};
} else {
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.history.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1,
else => return err,
};
}
} else if (std.mem.eql(u8, command, "portfolio")) {
if (std.mem.eql(u8, command, "portfolio")) {
// Parse --refresh flag; reject any other token (including old
// positional FILE, which is now a global -p).
var force_refresh = false;