920 lines
44 KiB
Zig
920 lines
44 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("root.zig");
|
|
const tui = @import("tui.zig");
|
|
const cli = @import("commands/common.zig");
|
|
const cmd_framework = @import("commands/framework.zig");
|
|
|
|
/// Comptime registry of CLI commands. Field name is the user-facing
|
|
/// subcommand name; value is the imported module struct. Order
|
|
/// follows the canonical group taxonomy in `framework.Group`
|
|
/// (symbol-lookup → portfolio → time-series → hygiene → infra) so
|
|
/// `zfin help` reads in workflow order. Adding a new command is one
|
|
/// edit here (after authoring the module). Validation runs at
|
|
/// comptime in the block below.
|
|
const command_modules = .{
|
|
// Per-symbol lookups
|
|
.perf = @import("commands/perf.zig"),
|
|
.quote = @import("commands/quote.zig"),
|
|
.divs = @import("commands/divs.zig"),
|
|
.splits = @import("commands/splits.zig"),
|
|
.options = @import("commands/options.zig"),
|
|
.earnings = @import("commands/earnings.zig"),
|
|
.etf = @import("commands/etf.zig"),
|
|
|
|
// Data hygiene
|
|
.lookup = @import("commands/lookup.zig"),
|
|
|
|
// Infrastructure
|
|
.cache = @import("commands/cache.zig"),
|
|
};
|
|
|
|
comptime {
|
|
for (std.meta.fields(@TypeOf(command_modules))) |f| {
|
|
cmd_framework.validateCommandModule(@field(command_modules, f.name));
|
|
}
|
|
}
|
|
|
|
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 [opts] Show money added since last commit (git-based diff)
|
|
\\ snapshot [opts] Write a daily portfolio snapshot to history/
|
|
\\ compare <DATE> [<DATE>] Compare portfolio against snapshot (one date = vs today)
|
|
\\ 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
|
|
\\ projections [opts] Retirement projections and benchmark comparison
|
|
\\ milestones [opts] Show portfolio threshold crossings (e.g. each $1M, doublings)
|
|
\\ 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:
|
|
\\ (no flags) Portfolio hygiene check + auto-reconcile discovered files
|
|
\\ --verbose Show full reconciliation output even when clean
|
|
\\ --stale-days <N> Manual price staleness threshold (default: 3)
|
|
\\ --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.
|
|
\\
|
|
\\Contributions command options:
|
|
\\ --since <DATE> Compare against the portfolio at-or-before DATE
|
|
\\ (accepts YYYY-MM-DD or relative like 1M, 3Q, 1Y).
|
|
\\ Without --until, the "after" side is HEAD (or
|
|
\\ working copy when dirty). Default: HEAD~1..HEAD.
|
|
\\ --until <DATE> Upper bound. Pair with --since to diff two
|
|
\\ commits within a date window.
|
|
\\ --commit-before <SPEC> Pin the before commit directly. Takes precedence
|
|
\\ over --since's date-based resolution. SPEC accepts
|
|
\\ YYYY-MM-DD, relative (1W/1M/1Q/1Y), HEAD, HEAD~N,
|
|
\\ or a 7+ hex SHA. Useful when you committed after
|
|
\\ your review date and --since 1W lands on the
|
|
\\ wrong commit (try --commit-before HEAD).
|
|
\\ --commit-after <SPEC> Pin the after commit. Same grammar as
|
|
\\ --commit-before, plus `working` / `WORKING` for
|
|
\\ the filesystem working copy. Mutually exclusive
|
|
\\ with --until.
|
|
\\
|
|
\\Projections command options:
|
|
\\ --no-events Exclude life events from simulation (baseline view)
|
|
\\ --as-of <DATE|N[WMQY]> Compute against a historical snapshot instead of
|
|
\\ the live portfolio. Accepts YYYY-MM-DD, relative
|
|
\\ shortcuts (1W, 1M, 3M, 1Q, 1Y, 3Y, 5Y), or 'live'.
|
|
\\ Auto-snaps to nearest-earlier snapshot if the
|
|
\\ exact date has no snapshot file.
|
|
\\ --overlay-actuals Plot the realized portfolio trajectory from --as-of
|
|
\\ up to today on top of the projected percentile
|
|
\\ bands. Requires --as-of. The TUI projections tab
|
|
\\ is the higher-fidelity surface (press `o` after
|
|
\\ setting an as-of date with `d`). Caveat: this shows
|
|
\\ whether the model was directionally honest, NOT
|
|
\\ whether the SWR claim was accurate.
|
|
\\ --vs <DATE|N[WMQY]> Compact side-by-side comparison: projected return
|
|
\\ and safe-withdrawal @99% for live vs DATE, with
|
|
\\ deltas. Combine with --as-of to compare two
|
|
\\ historical dates (--vs = then, --as-of = now).
|
|
\\ --convergence Plot the spreadsheet's predicted retirement date
|
|
\\ over time as data accumulated. Sources data from
|
|
\\ imported_values.srf. Caveat: this evaluates the
|
|
\\ model's directional honesty, not its SWR claim.
|
|
\\ --return-backtest Plot the spreadsheet's expected_return claim over
|
|
\\ time alongside realized 1y/3y/5y forward CAGR.
|
|
\\ Sources data from imported_values.srf. Pair with
|
|
\\ --real to compare in inflation-adjusted dollars.
|
|
\\
|
|
\\Milestones command options:
|
|
\\ --step <expr> Threshold step. Required.
|
|
\\ Absolute: 1M, 1m, 1500000, 1.5M, 500K, 500k
|
|
\\ Relative: 2x, 2X, 1.5x (must be > 1.0)
|
|
\\ --real Deflate the series to the last full Shiller year
|
|
\\ before detecting crossings (CPI-adjusted).
|
|
\\
|
|
\\Compare command options:
|
|
\\ --projections Include projected return + safe-withdrawal @99%
|
|
\\ deltas between the attribution rows and the
|
|
\\ per-symbol table. Opt-in because projections cost
|
|
\\ ~1-2s per endpoint (Monte Carlo SWR search).
|
|
\\ --no-events (with --projections) Exclude life events from the
|
|
\\ underlying projection simulation. Matches the
|
|
\\ `projections --no-events` flag.
|
|
\\ --snapshot-before <DATE> Override the before-snapshot independently of
|
|
\\ the positional date. DATE accepts YYYY-MM-DD or
|
|
\\ relative (1W/1M/1Q/1Y). Defaults from positional.
|
|
\\ --snapshot-after <DATE> Override the after-snapshot. Accepts the same
|
|
\\ plus `live` for the current portfolio. Defaults
|
|
\\ from positional arg 2, else live.
|
|
\\ --commit-before <SPEC> Pin the attribution's before commit. Same SPEC
|
|
\\ grammar as the contributions flag. When the
|
|
\\ positional date's commit-at-or-before lands on
|
|
\\ the wrong commit (committed after review day),
|
|
\\ pass `HEAD` or an explicit SHA.
|
|
\\ --commit-after <SPEC> Pin the attribution's after commit. Accepts
|
|
\\ `working` / `WORKING` for the working copy.
|
|
\\
|
|
\\Environment Variables:
|
|
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
|
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
|
|
\\ FMP_API_KEY Financial Modeling Prep 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(
|
|
io: std.Io,
|
|
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(io, allocator, p)) |r| {
|
|
return .{ .path = r.path, .resolved = r };
|
|
}
|
|
return .{ .path = p, .resolved = null };
|
|
}
|
|
if (config.resolveUserFile(io, allocator, default_name)) |r| {
|
|
return .{ .path = r.path, .resolved = r };
|
|
}
|
|
return .{ .path = default_name, .resolved = null };
|
|
}
|
|
|
|
pub fn main(init: std.process.Init) !u8 {
|
|
return runCli(init) catch |err| switch (err) {
|
|
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
|
|
// file writer surfaces EPIPE as WriteFailed. Treat as a clean exit
|
|
// — the consumer got what it needed and closed the pipe; further
|
|
// output isn't an error from our perspective. Matches `ls | head`,
|
|
// `git log | head`, etc.
|
|
error.WriteFailed, error.BrokenPipe => 0,
|
|
else => err,
|
|
};
|
|
}
|
|
|
|
fn runCli(init: std.process.Init) !u8 {
|
|
// Juicy Main provides two allocators: `init.gpa` (debug-mode leak-checked
|
|
// heap) and `init.arena` (process-lifetime arena). We use gpa for the
|
|
// argv copy and long-lived TUI state; per-command work runs under a
|
|
// fresh ArenaAllocator below.
|
|
const gpa_alloc = init.gpa;
|
|
const io = init.io;
|
|
|
|
const args = try init.minimal.args.toSlice(gpa_alloc);
|
|
defer gpa_alloc.free(args);
|
|
|
|
// Single buffered writer for all stdout output
|
|
var stdout_buf: [4096]u8 = undefined;
|
|
var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buf);
|
|
const out: *std.Io.Writer = &stdout_writer.interface;
|
|
|
|
if (args.len < 2) {
|
|
try cli.stderrPrint(io, 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(io, "Error: global flag is missing its value\n"),
|
|
error.UnknownGlobalFlag => {
|
|
try cli.stderrPrint(io, "Error: unknown global flag: ");
|
|
if (globalOffender(args)) |bad| {
|
|
try cli.stderrPrint(io, bad);
|
|
}
|
|
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
|
|
},
|
|
}
|
|
return 1;
|
|
};
|
|
|
|
if (globals.cursor >= args.len) {
|
|
try cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
|
|
return 1;
|
|
}
|
|
|
|
// Single wall-clock capture for the rest of this invocation. `now_s`
|
|
// is threaded into commands that record "when did this happen"
|
|
// (snapshot metadata, audit staleness, rollup header timestamps).
|
|
// `today` derives from the same read, so every dated computation in
|
|
// this process sees a consistent date even if the wall clock ticks
|
|
// over mid-run.
|
|
//
|
|
// wall-clock required: the one legitimate Timestamp.now() call in
|
|
// main dispatch — everything downstream takes now_s / today.
|
|
const Date = @import("Date.zig");
|
|
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
|
|
const today = Date.fromEpoch(now_s);
|
|
|
|
// Nag on stderr when hand-maintained data sources are overdue for
|
|
// refresh (T-bill rates, Shiller ie_data.csv). See
|
|
// src/data/staleness.zig for the registry and rules. Runs here —
|
|
// after globals parse, before command dispatch — so the warning
|
|
// lands above command output on every CLI and TUI invocation.
|
|
{
|
|
const staleness = @import("data/staleness.zig");
|
|
var stale_buf: [2048]u8 = undefined;
|
|
var stale_writer = std.Io.File.stderr().writer(io, &stale_buf);
|
|
staleness.check(&stale_writer.interface, today, &staleness.entries) catch {};
|
|
stale_writer.interface.flush() catch {};
|
|
}
|
|
|
|
const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color);
|
|
|
|
const command = args[globals.cursor];
|
|
var cmd_args: []const []const u8 = @ptrCast(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(io, gpa_alloc, init.environ_map);
|
|
defer tui_config.deinit();
|
|
try out.flush();
|
|
try tui.run(io, gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args, today);
|
|
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(io, allocator, init.environ_map);
|
|
defer config.deinit();
|
|
|
|
// Version: doesn't need DataService; uses build_info + Config paths.
|
|
if (std.mem.eql(u8, command, "version")) {
|
|
commands.version.run(io, 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(io, allocator, config);
|
|
defer svc.deinit();
|
|
|
|
// ── Framework dispatch ───────────────────────────────────────
|
|
//
|
|
// Comptime walk over `command_modules`. Each registered command
|
|
// owns its own flag parsing (`parseArgs`) and execution (`run`)
|
|
// — both take `*RunCtx`. As more commands migrate the legacy
|
|
// if-else chain below shrinks; once empty (commit 17) it goes
|
|
// away entirely along with the per-command symbol-uppercasing
|
|
// hack.
|
|
inline for (std.meta.fields(@TypeOf(command_modules))) |f| {
|
|
if (std.mem.eql(u8, command, f.name)) {
|
|
const Module = @field(command_modules, f.name);
|
|
var ctx: cmd_framework.RunCtx = .{
|
|
.io = io,
|
|
.allocator = allocator,
|
|
.gpa = gpa_alloc,
|
|
.environ_map = init.environ_map,
|
|
.config = config,
|
|
.svc = &svc,
|
|
.globals = .{
|
|
.no_color = globals.no_color,
|
|
.portfolio_path = globals.portfolio_path,
|
|
.watchlist_path = globals.watchlist_path,
|
|
},
|
|
.today = today,
|
|
.now_s = now_s,
|
|
.color = color,
|
|
.out = out,
|
|
};
|
|
const dispatched_args = if (comptime cmd_framework.uppercasesFirstArg(Module))
|
|
try cmd_framework.normalizeFirstArg(allocator, cmd_args)
|
|
else
|
|
cmd_args;
|
|
const parsed = Module.parseArgs(&ctx, dispatched_args) catch return 1;
|
|
try Module.run(&ctx, parsed);
|
|
try out.flush();
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// 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, "projections") and
|
|
!std.mem.eql(u8, command, "snapshot") and
|
|
!std.mem.eql(u8, command, "compare") 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`).
|
|
//
|
|
// Args returned by `init.minimal.args.toSlice` are `[]const [:0]const u8`
|
|
// — we can't mutate the slice. Build an owned mutable copy when the
|
|
// symbol upper-cased form differs from the raw arg.
|
|
var cmd_args_owned: ?[][]const u8 = null;
|
|
defer if (cmd_args_owned) |c| allocator.free(c);
|
|
if (symbol_cmd and cmd_args.len >= 1 and
|
|
(cmd_args[0].len == 0 or cmd_args[0][0] != '-'))
|
|
{
|
|
const upper = try allocator.dupe(u8, cmd_args[0]);
|
|
for (upper) |*c| c.* = std.ascii.toUpper(c.*);
|
|
const owned = try allocator.alloc([]const u8, cmd_args.len);
|
|
owned[0] = upper;
|
|
for (cmd_args[1..], 1..) |a, i| owned[i] = a;
|
|
cmd_args_owned = owned;
|
|
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")) {
|
|
// 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(io, "portfolio", a);
|
|
return 1;
|
|
}
|
|
}
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
const wl = resolveUserPath(io, 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(io, allocator, &svc, pf.path, wl_path, force_refresh, today, color, out);
|
|
} else if (std.mem.eql(u8, command, "enrich")) {
|
|
if (cmd_args.len < 1) {
|
|
try cli.stderrPrint(io, "Error: 'enrich' requires a portfolio file path or symbol\n");
|
|
return 1;
|
|
}
|
|
try commands.enrich.run(io, allocator, &svc, cmd_args[0], today, out);
|
|
} else if (std.mem.eql(u8, command, "audit")) {
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
try commands.audit.run(io, allocator, &svc, pf.path, cmd_args, today, now_s, color, out);
|
|
} else if (std.mem.eql(u8, command, "analysis")) {
|
|
for (cmd_args) |a| {
|
|
try reportUnexpectedArg(io, "analysis", a);
|
|
return 1;
|
|
}
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
try commands.analysis.run(io, allocator, &svc, pf.path, today, color, out);
|
|
} else if (std.mem.eql(u8, command, "projections")) {
|
|
var events_enabled = true;
|
|
var as_of: ?zfin.Date = null;
|
|
var vs_date: ?zfin.Date = null;
|
|
var overlay_actuals = false;
|
|
var convergence = false;
|
|
var return_backtest = false;
|
|
var real_mode = false;
|
|
var i: usize = 0;
|
|
while (i < cmd_args.len) : (i += 1) {
|
|
const a = cmd_args[i];
|
|
if (std.mem.eql(u8, a, "--no-events")) {
|
|
events_enabled = false;
|
|
} else if (std.mem.eql(u8, a, "--overlay-actuals")) {
|
|
overlay_actuals = true;
|
|
} else if (std.mem.eql(u8, a, "--convergence")) {
|
|
convergence = true;
|
|
} else if (std.mem.eql(u8, a, "--return-backtest")) {
|
|
return_backtest = true;
|
|
} else if (std.mem.eql(u8, a, "--real")) {
|
|
real_mode = true;
|
|
} else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) {
|
|
if (i + 1 >= cmd_args.len) {
|
|
try cli.stderrPrint(io, "Error: ");
|
|
try cli.stderrPrint(io, a);
|
|
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n");
|
|
return 1;
|
|
}
|
|
const value = cmd_args[i + 1];
|
|
const parsed = cli.parseAsOfDate(value, today) catch |err| {
|
|
var buf: [256]u8 = undefined;
|
|
const msg = cli.fmtAsOfParseError(&buf, value, err);
|
|
try cli.stderrPrint(io, msg);
|
|
try cli.stderrPrint(io, "\n");
|
|
return 1;
|
|
};
|
|
if (parsed) |d| {
|
|
if (d.days > today.days) {
|
|
try cli.stderrPrint(io, "Error: date is in the future.\n");
|
|
return 1;
|
|
}
|
|
if (std.mem.eql(u8, a, "--as-of")) {
|
|
as_of = d;
|
|
} else {
|
|
vs_date = d;
|
|
}
|
|
}
|
|
// null (= "live") is ignored — leaves flag unset, same
|
|
// as not passing the flag at all.
|
|
i += 1; // consume the value
|
|
} else {
|
|
try reportUnexpectedArg(io, "projections", a);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Mutually-exclusive view flags. Forecast-evaluation flags
|
|
// (`--convergence`, `--return-backtest`) replace the default
|
|
// bands view entirely; combining them with each other,
|
|
// `--vs`, or `--overlay-actuals` is rejected.
|
|
if (convergence and return_backtest) {
|
|
try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n");
|
|
return 1;
|
|
}
|
|
if ((convergence or return_backtest) and vs_date != null) {
|
|
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n");
|
|
return 1;
|
|
}
|
|
if ((convergence or return_backtest) and overlay_actuals) {
|
|
try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n");
|
|
return 1;
|
|
}
|
|
if (real_mode and !return_backtest) {
|
|
try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n");
|
|
return 1;
|
|
}
|
|
|
|
if (as_of != null and vs_date == null) {
|
|
// Single-date mode: view that snapshot only.
|
|
}
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
if (convergence) {
|
|
try commands.projections.runConvergence(io, allocator, pf.path, color, out);
|
|
} else if (return_backtest) {
|
|
try commands.projections.runReturnBacktest(io, allocator, pf.path, real_mode, color, out);
|
|
} else if (vs_date) |d| {
|
|
// Compare mode. `as_of` (if set) designates the "now"
|
|
// side — otherwise now is live. `--vs` alone compares
|
|
// live against a historical date; `--vs X --as-of Y`
|
|
// compares two historical dates with Y being the later
|
|
// one.
|
|
if (overlay_actuals) {
|
|
try cli.stderrPrint(io, "Note: --overlay-actuals is ignored in --vs compare mode.\n");
|
|
}
|
|
try commands.projections.runCompare(io, allocator, &svc, pf.path, events_enabled, d, as_of orelse today, as_of != null, color, out);
|
|
} else {
|
|
try commands.projections.run(io, allocator, &svc, pf.path, events_enabled, as_of orelse today, as_of != null, today, overlay_actuals, color, out);
|
|
}
|
|
} else if (std.mem.eql(u8, command, "contributions")) {
|
|
var since: ?zfin.Date = null;
|
|
var until: ?zfin.Date = null;
|
|
var before_spec: ?cli.CommitSpec = null;
|
|
var after_spec: ?cli.CommitSpec = null;
|
|
var i: usize = 0;
|
|
while (i < cmd_args.len) : (i += 1) {
|
|
const a = cmd_args[i];
|
|
if (std.mem.eql(u8, a, "--since") or std.mem.eql(u8, a, "--until")) {
|
|
if (i + 1 >= cmd_args.len) {
|
|
try cli.stderrPrint(io, "Error: ");
|
|
try cli.stderrPrint(io, a);
|
|
try cli.stderrPrint(io, " requires a value (YYYY-MM-DD or N[WMQY]).\n");
|
|
return 1;
|
|
}
|
|
const value = cmd_args[i + 1];
|
|
const parsed = cli.parseAsOfDate(value, today) catch |err| {
|
|
var buf: [256]u8 = undefined;
|
|
const msg = cli.fmtAsOfParseError(&buf, value, err);
|
|
try cli.stderrPrint(io, msg);
|
|
try cli.stderrPrint(io, "\n");
|
|
return 1;
|
|
};
|
|
// `parsed == null` means the user typed "live" or an
|
|
// empty string — meaningless for --since/--until, which
|
|
// require concrete dates.
|
|
const resolved = parsed orelse {
|
|
try cli.stderrPrint(io, "Error: ");
|
|
try cli.stderrPrint(io, a);
|
|
try cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n");
|
|
return 1;
|
|
};
|
|
if (std.mem.eql(u8, a, "--since")) {
|
|
since = resolved;
|
|
} else {
|
|
until = resolved;
|
|
}
|
|
i += 1; // consume the value
|
|
} else if (std.mem.eql(u8, a, "--commit-before") or std.mem.eql(u8, a, "--commit-after")) {
|
|
if (i + 1 >= cmd_args.len) {
|
|
try cli.stderrPrint(io, "Error: ");
|
|
try cli.stderrPrint(io, a);
|
|
try cli.stderrPrint(io, " requires a value (working, YYYY-MM-DD, 1W/1M/1Q/1Y, HEAD, HEAD~N, or SHA).\n");
|
|
return 1;
|
|
}
|
|
const value = cmd_args[i + 1];
|
|
const spec = cli.parseCommitSpec(value, today) catch |err| {
|
|
var buf: [256]u8 = undefined;
|
|
const msg = cli.fmtCommitSpecError(&buf, value, err);
|
|
try cli.stderrPrint(io, msg);
|
|
try cli.stderrPrint(io, "\n");
|
|
return 1;
|
|
};
|
|
if (std.mem.eql(u8, a, "--commit-before")) {
|
|
if (spec == .working_copy) {
|
|
try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n");
|
|
return 1;
|
|
}
|
|
before_spec = spec;
|
|
} else {
|
|
after_spec = spec;
|
|
}
|
|
i += 1; // consume the value
|
|
} else {
|
|
try reportUnexpectedArg(io, "contributions", a);
|
|
return 1;
|
|
}
|
|
}
|
|
// Conflict detection: --since and --commit-before describe the
|
|
// same axis, same for --until and --commit-after. Taking both
|
|
// would be ambiguous about which wins.
|
|
if (since != null and before_spec != null) {
|
|
try cli.stderrPrint(io, "Error: --since and --commit-before both specify the before side. Pick one.\n");
|
|
return 1;
|
|
}
|
|
if (until != null and after_spec != null) {
|
|
try cli.stderrPrint(io, "Error: --until and --commit-after both specify the after side. Pick one.\n");
|
|
return 1;
|
|
}
|
|
if (since != null and until != null and since.?.days > until.?.days) {
|
|
try cli.stderrPrint(io, "Error: --since must be on or before --until.\n");
|
|
return 1;
|
|
}
|
|
// Resolve to CommitSpec for the command. Date flags become
|
|
// `.date_at_or_before`, commit flags pass through.
|
|
const before_final: ?cli.CommitSpec = if (before_spec) |s|
|
|
s
|
|
else if (since) |d|
|
|
.{ .date_at_or_before = d }
|
|
else
|
|
null;
|
|
const after_final: ?cli.CommitSpec = if (after_spec) |s|
|
|
s
|
|
else if (until) |d|
|
|
.{ .date_at_or_before = d }
|
|
else
|
|
null;
|
|
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
try commands.contributions.run(io, allocator, &svc, pf.path, before_final, after_final, today, color, out);
|
|
} else if (std.mem.eql(u8, command, "snapshot")) {
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
commands.snapshot.run(io, allocator, &svc, pf.path, cmd_args, now_s, color, out) catch |err| switch (err) {
|
|
error.UnexpectedArg, error.PortfolioEmpty, error.WriteFailed => return 1,
|
|
else => return err,
|
|
};
|
|
} else if (std.mem.eql(u8, command, "compare")) {
|
|
const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename);
|
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
|
commands.compare.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) {
|
|
// All user-level validation errors return 1 silently — the
|
|
// command already printed a message to stderr.
|
|
error.UnexpectedArg,
|
|
error.MissingDateArg,
|
|
error.InvalidDate,
|
|
error.SameDate,
|
|
error.SnapshotNotFound,
|
|
error.PortfolioLoadFailed,
|
|
=> 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;
|
|
}
|
|
|
|
// 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(io: std.Io, command: []const u8, arg: []const u8) !void {
|
|
try cli.stderrPrint(io, "Error: unexpected argument to '");
|
|
try cli.stderrPrint(io, command);
|
|
try cli.stderrPrint(io, "': ");
|
|
try cli.stderrPrint(io, arg);
|
|
try cli.stderrPrint(io, "\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(io, "Hint: global flags must appear before the subcommand.\n");
|
|
} else {
|
|
try cli.stderrPrint(io, "Hint: the portfolio file is now a global option; use `zfin -p <FILE> ");
|
|
try cli.stderrPrint(io, command);
|
|
try cli.stderrPrint(io, "`.\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;
|
|
}
|
|
|
|
const commands = struct {
|
|
const history = @import("commands/history.zig");
|
|
const portfolio = @import("commands/portfolio.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 compare = @import("commands/compare.zig");
|
|
const version = @import("commands/version.zig");
|
|
const projections = @import("commands/projections.zig");
|
|
const milestones = @import("commands/milestones.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). `std.testing.refAllDecls(@This())` walks main.zig's top-level
|
|
// decls, which transitively pulls in every file imported (directly or
|
|
// indirectly) via a `const x = @import(...)` form. As long as a file is
|
|
// reachable that way through the import graph, its `test` blocks are
|
|
// collected by the test runner — no explicit `_ = @import(...)` lines
|
|
// required here.
|
|
//
|
|
// If a new `.zig` file's tests aren't being discovered (test count doesn't
|
|
// rise after adding a file with tests), the cause is almost always that
|
|
// the file is only referenced via a *type extraction* like
|
|
// `const T = @import("foo.zig").T;` — that form pulls in the type but
|
|
// doesn't sema-touch the file struct, so its tests are skipped. Fix the
|
|
// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md
|
|
// "Test discovery" for the canary procedure.
|
|
|
|
test {
|
|
std.testing.refAllDecls(@This());
|
|
// TEMPORARY: force test discovery for command-framework support
|
|
// modules. Nothing in main.zig sema-touches them yet, so their
|
|
// `test` blocks would otherwise be skipped. Remove these lines
|
|
// once `runCli` references the framework directly (via the
|
|
// comptime command-registry walk that replaces the legacy
|
|
// if-else dispatch chain) — at that point both TimeRange and
|
|
// framework.zig are reachable through the registry's command
|
|
// imports.
|
|
_ = @import("commands/framework.zig");
|
|
_ = @import("commands/TimeRange.zig");
|
|
}
|