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 options] \\ \\Commands: \\ interactive [opts] Launch interactive TUI \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) \\ quote Show latest quote with chart and history \\ history [SYMBOL|opts] Show price history (symbol) or portfolio timeline \\ divs Show dividend history \\ splits Show split history \\ options Show options chain (all expirations) \\ earnings Show earnings history and upcoming \\ etf 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 Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ lookup 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 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 Watchlist file (default: watchlist.srf) \\ \\Interactive command options: \\ -s, --symbol Initial symbol (default: VTI) \\ --chart Chart graphics: auto, braille, or WxH (e.g. 1920x1080) \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ \\Options command options: \\ --ntm 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 Earliest as_of_date (inclusive) \\ --until Latest as_of_date (inclusive) \\ --metric liquid (default), illiquid, or net_worth \\ --resolution daily, weekly, monthly, or auto (default: auto) \\ auto: daily ≤90d, weekly ≤730d, else monthly \\ --limit Max rows in the recent-snapshots table (default: 40) \\ --rebuild-rollup (Re)write history/rollup.srf and exit \\ \\Audit command options: \\ --fidelity Fidelity positions CSV export (download from "All accounts" positions tab) \\ --schwab 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 `. 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 → 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 "); 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()); }