zfin/src/main.zig
2026-04-23 16:02:34 -07:00

544 lines
24 KiB
Zig

const std = @import("std");
const zfin = @import("root.zig");
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const usage =
\\Usage: zfin [global options] <command> [command options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio Load and analyze the portfolio
\\ analysis Show portfolio analysis
\\ contributions Show money added since last commit (git-based diff)
\\ snapshot [opts] Write a daily portfolio snapshot to history/
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\ version [-v] Show zfin version and build info
\\
\\Global options (must appear before the subcommand):
\\ --no-color Disable colored output
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf; cwd then ZFIN_HOME)
\\ metadata.srf and accounts.srf are loaded from the
\\ same directory as the resolved portfolio.
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\
\\Interactive command options:
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
\\Options command options:
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\Portfolio command options:
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\History command options (portfolio mode; omit SYMBOL):
\\ --since <YYYY-MM-DD> Earliest as_of_date (inclusive)
\\ --until <YYYY-MM-DD> Latest as_of_date (inclusive)
\\ --metric <name> liquid (default), illiquid, or net_worth
\\ --resolution <name> daily, weekly, monthly, or auto (default: auto)
\\ auto: daily ≤90d, weekly ≤730d, else monthly
\\ --limit <N> Max rows in the recent-snapshots table (default: 40)
\\ --rebuild-rollup (Re)write history/rollup.srf and exit
\\
\\Audit command options:
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
\\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
\\
\\Analysis & Contributions commands:
\\ Both operate on the globally-specified portfolio. They also read
\\ metadata.srf and accounts.srf from the same directory.
\\ Contributions additionally requires the portfolio file to be tracked
\\ in a git repo; `git` must be on PATH.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ ZFIN_HOME User file directory (portfolio, watchlist, .env)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
/// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME).
const Globals = struct {
no_color: bool = false,
/// Explicit portfolio path from -p/--portfolio (raw, null if not set).
portfolio_path: ?[]const u8 = null,
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
watchlist_path: ?[]const u8 = null,
/// Index into args of the first post-global token (the subcommand).
cursor: usize,
};
const GlobalParseError = error{
MissingValue,
UnknownGlobalFlag,
};
/// Parse global flags from args[1..] up to the first non-flag (subcommand)
/// token. Errors if a pre-subcommand token starts with '-' but isn't a
/// recognized global, or if a value-taking flag is missing its value.
fn parseGlobals(args: []const []const u8) GlobalParseError!Globals {
var g: Globals = .{ .cursor = 1 };
var i: usize = 1;
while (i < args.len) {
const a = args[i];
if (a.len == 0 or a[0] != '-') break;
if (std.mem.eql(u8, a, "--no-color")) {
g.no_color = true;
i += 1;
continue;
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) {
if (i + 1 >= args.len) return error.MissingValue;
g.portfolio_path = args[i + 1];
i += 2;
continue;
}
if (std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist")) {
if (i + 1 >= args.len) return error.MissingValue;
g.watchlist_path = args[i + 1];
i += 2;
continue;
}
// Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
return error.UnknownGlobalFlag;
}
g.cursor = i;
return g;
}
/// Resolve a portfolio-like path. If `explicit` is non-null the user supplied
/// it explicitly; we still run resolveUserFile to allow bare filenames to
/// resolve through cwd → ZFIN_HOME. If null, use the given default filename
/// and run through resolveUserFile.
fn resolveUserPath(
allocator: std.mem.Allocator,
config: zfin.Config,
explicit: ?[]const u8,
default_name: []const u8,
) struct { path: []const u8, resolved: ?zfin.Config.ResolvedPath } {
if (explicit) |p| {
// Try resolveUserFile so bare names like "foo.srf" fall back to ZFIN_HOME.
if (config.resolveUserFile(allocator, p)) |r| {
return .{ .path = r.path, .resolved = r };
}
return .{ .path = p, .resolved = null };
}
if (config.resolveUserFile(allocator, default_name)) |r| {
return .{ .path = r.path, .resolved = r };
}
return .{ .path = default_name, .resolved = null };
}
pub fn main() !u8 {
// Long-lived allocator for things that span the whole process. Only
// actually used for the early argsAlloc and the TUI path — CLI
// commands run under a per-invocation arena (see below).
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const gpa_alloc = gpa.allocator();
const args = try std.process.argsAlloc(gpa_alloc);
defer std.process.argsFree(gpa_alloc, args);
// Single buffered writer for all stdout output
var stdout_buf: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
const out: *std.Io.Writer = &stdout_writer.interface;
if (args.len < 2) {
try cli.stderrPrint(usage);
return 1;
}
// Early help handling (before global parsing so `zfin --help` works).
if (std.mem.eql(u8, args[1], "help") or
std.mem.eql(u8, args[1], "--help") or
std.mem.eql(u8, args[1], "-h"))
{
try out.writeAll(usage);
try out.flush();
return 0;
}
// Parse global flags.
const globals = parseGlobals(args) catch |err| {
switch (err) {
error.MissingValue => try cli.stderrPrint("Error: global flag is missing its value\n"),
error.UnknownGlobalFlag => {
try cli.stderrPrint("Error: unknown global flag: ");
if (globalOffender(args)) |bad| {
try cli.stderrPrint(bad);
}
try cli.stderrPrint("\nRun 'zfin help' for usage.\n");
},
}
return 1;
};
if (globals.cursor >= args.len) {
try cli.stderrPrint("Error: missing command.\nRun 'zfin help' for usage.\n");
return 1;
}
const color = @import("format.zig").shouldUseColor(globals.no_color);
const command = args[globals.cursor];
const cmd_args = args[globals.cursor + 1 ..];
// Interactive TUI: long-lived, per-frame allocations benefit from a
// real (non-arena) allocator. Runs against `gpa` directly.
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
var tui_config = zfin.Config.fromEnv(gpa_alloc);
defer tui_config.deinit();
try out.flush();
try tui.run(gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args);
return 0;
}
// ── Per-invocation arena ─────────────────────────────────────
//
// CLI commands do a batch of work then exit. Almost every allocation
// they make has the same lifetime (the invocation). An arena matched
// to that unit gives us three wins: skip per-allocation bookkeeping,
// ignore all the per-object `defer X.deinit()` calls (they become
// no-ops but remain correct code if the function is ever called from
// a non-arena context), and avoid gpa's leak-checking overhead for
// ephemeral state we're about to discard anyway.
//
// See models/portfolio.zig for the "match the arena to the unit of
// work" principle. Here the unit is one `zfin <subcommand>`.
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
// Version: doesn't need DataService; uses build_info + Config paths.
if (std.mem.eql(u8, command, "version")) {
commands.version.run(config, cmd_args, out) catch |err| switch (err) {
error.UnexpectedArg => return 1,
else => return err,
};
try out.flush();
return 0;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
// Normalize symbol argument (cmd_args[0]) to uppercase for commands
// that take a symbol. Skip for commands whose first arg is a subcommand
// or operand of a different kind.
const symbol_cmd =
!std.mem.eql(u8, command, "cache") and
!std.mem.eql(u8, command, "enrich") and
!std.mem.eql(u8, command, "audit") and
!std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "contributions") and
!std.mem.eql(u8, command, "portfolio") and
!std.mem.eql(u8, command, "snapshot") and
!std.mem.eql(u8, command, "version");
// Upper-case the first arg for symbol-taking commands, but skip when
// the arg is a flag (starts with '-'). This lets commands like
// `history` have both symbol mode (`zfin history VTI`) and
// flag-driven mode (`zfin history --since 2026-01-01`).
if (symbol_cmd and cmd_args.len >= 1 and
(cmd_args[0].len == 0 or cmd_args[0][0] != '-'))
{
for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*);
}
if (std.mem.eql(u8, command, "perf")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
return 1;
}
try commands.perf.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
return 1;
}
try commands.quote.run(allocator, &svc, cmd_args[0], color, out);
} else 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(allocator, &svc, "", cmd_args, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.MissingFlagValue, error.InvalidFlagValue, error.UnknownMetric => return 1,
else => return err,
};
} else {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.history.run(allocator, &svc, pf.path, cmd_args, 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, "divs")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
return 1;
}
try commands.divs.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
return 1;
}
try commands.splits.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
return 1;
}
// Parse --ntm flag.
var ntm: usize = 8;
var ai: usize = 1;
while (ai < cmd_args.len) : (ai += 1) {
if (std.mem.eql(u8, cmd_args[ai], "--ntm") and ai + 1 < cmd_args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8;
}
}
try commands.options.run(allocator, &svc, cmd_args[0], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
return 1;
}
try commands.earnings.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
return 1;
}
try commands.etf.run(allocator, &svc, cmd_args[0], color, out);
} else 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;
for (cmd_args) |a| {
if (std.mem.eql(u8, a, "--refresh")) {
force_refresh = true;
} else {
try reportUnexpectedArg("portfolio", a);
return 1;
}
}
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
const wl = resolveUserPath(allocator, config, globals.watchlist_path, zfin.Config.default_watchlist_filename);
defer if (wl.resolved) |r| r.deinit(allocator);
const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null;
try commands.portfolio.run(allocator, &svc, pf.path, wl_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
return 1;
}
try commands.lookup.run(allocator, &svc, cmd_args[0], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
return 1;
}
try commands.cache.run(allocator, config, cmd_args[0], out);
} else if (std.mem.eql(u8, command, "enrich")) {
if (cmd_args.len < 1) {
try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
return 1;
}
try commands.enrich.run(allocator, &svc, cmd_args[0], out);
} else if (std.mem.eql(u8, command, "audit")) {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.audit.run(allocator, &svc, pf.path, cmd_args, color, out);
} else if (std.mem.eql(u8, command, "analysis")) {
for (cmd_args) |a| {
try reportUnexpectedArg("analysis", a);
return 1;
}
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.analysis.run(allocator, &svc, pf.path, color, out);
} else if (std.mem.eql(u8, command, "contributions")) {
for (cmd_args) |a| {
try reportUnexpectedArg("contributions", a);
return 1;
}
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
try commands.contributions.run(allocator, &svc, pf.path, color, out);
} else if (std.mem.eql(u8, command, "snapshot")) {
const pf = resolveUserPath(allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
defer if (pf.resolved) |r| r.deinit(allocator);
commands.snapshot.run(allocator, &svc, pf.path, cmd_args, color, out) catch |err| switch (err) {
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
else => return err,
};
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1;
}
// Single flush for all stdout output
try out.flush();
return 0;
}
/// Emit a consistent "unexpected argument" error, with a hint pointing at
/// the global-flag migration. Called when a command finds an arg it doesn't
/// understand (typically a stale positional file path or a misplaced global
/// flag like `--no-color` after the subcommand).
fn reportUnexpectedArg(command: []const u8, arg: []const u8) !void {
try cli.stderrPrint("Error: unexpected argument to '");
try cli.stderrPrint(command);
try cli.stderrPrint("': ");
try cli.stderrPrint(arg);
try cli.stderrPrint("\n");
if (std.mem.eql(u8, arg, "--no-color") or
std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--portfolio") or
std.mem.eql(u8, arg, "-w") or std.mem.eql(u8, arg, "--watchlist"))
{
try cli.stderrPrint("Hint: global flags must appear before the subcommand.\n");
} else {
try cli.stderrPrint("Hint: the portfolio file is now a global option; use `zfin -p <FILE> ");
try cli.stderrPrint(command);
try cli.stderrPrint("`.\n");
}
}
/// Return the first args[1..] token that looks like a flag we didn't handle.
/// Used only to craft an error message; best-effort.
fn globalOffender(args: []const []const u8) ?[]const u8 {
var i: usize = 1;
while (i < args.len) {
const a = args[i];
if (a.len == 0 or a[0] != '-') return null;
if (std.mem.eql(u8, a, "--no-color")) {
i += 1;
continue;
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio") or
std.mem.eql(u8, a, "-w") or std.mem.eql(u8, a, "--watchlist"))
{
i += 2;
continue;
}
return a;
}
return null;
}
// ── Command modules ──────────────────────────────────────────
const commands = struct {
const perf = @import("commands/perf.zig");
const quote = @import("commands/quote.zig");
const history = @import("commands/history.zig");
const divs = @import("commands/divs.zig");
const splits = @import("commands/splits.zig");
const options = @import("commands/options.zig");
const earnings = @import("commands/earnings.zig");
const etf = @import("commands/etf.zig");
const portfolio = @import("commands/portfolio.zig");
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig");
const contributions = @import("commands/contributions.zig");
const snapshot = @import("commands/snapshot.zig");
const version = @import("commands/version.zig");
};
// ── Tests ────────────────────────────────────────────────────
test "parseGlobals: no flags, subcommand only" {
const argv = [_][]const u8{ "zfin", "portfolio" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 1), g.cursor);
try std.testing.expectEqual(false, g.no_color);
try std.testing.expect(g.portfolio_path == null);
try std.testing.expect(g.watchlist_path == null);
}
test "parseGlobals: --no-color, -p, -w then subcommand" {
const argv = [_][]const u8{ "zfin", "--no-color", "-p", "foo.srf", "-w", "wl.srf", "analysis" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 6), g.cursor);
try std.testing.expectEqual(true, g.no_color);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?);
try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}
test "parseGlobals: long forms" {
const argv = [_][]const u8{ "zfin", "--portfolio", "foo.srf", "--watchlist", "wl.srf", "portfolio" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 5), g.cursor);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?);
try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}
test "parseGlobals: unknown flag errors" {
const argv = [_][]const u8{ "zfin", "--bogus", "quote", "AAPL" };
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv));
}
test "parseGlobals: flag missing value errors" {
const argv = [_][]const u8{ "zfin", "-p" };
try std.testing.expectError(error.MissingValue, parseGlobals(&argv));
}
test "parseGlobals: --help stops scanning" {
const argv = [_][]const u8{ "zfin", "--help" };
const g = try parseGlobals(&argv);
try std.testing.expectEqual(@as(usize, 1), g.cursor);
}
test "parseGlobals: subcommand-local flag NOT consumed as global" {
// `--refresh` is a portfolio-command flag; should stop global scanning
// when it appears before the subcommand (even though that's not the
// intended usage, make sure the error is "unknown global").
const argv = [_][]const u8{ "zfin", "--refresh", "portfolio" };
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv));
}
// Single test binary: all source is in one module (file imports, no module
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
test {
std.testing.refAllDeclsRecursive(@This());
}