add contributions command
This commit is contained in:
parent
acd5b20dbe
commit
04ea95f21f
4 changed files with 1501 additions and 151 deletions
|
|
@ -1168,12 +1168,10 @@ fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.
|
||||||
|
|
||||||
// ── CLI entry point ─────────────────────────────────────────
|
// ── CLI entry point ─────────────────────────────────────────
|
||||||
|
|
||||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
|
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: []const u8, args: []const []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||||
var fidelity_csv: ?[]const u8 = null;
|
var fidelity_csv: ?[]const u8 = null;
|
||||||
var schwab_csv: ?[]const u8 = null;
|
var schwab_csv: ?[]const u8 = null;
|
||||||
var schwab_summary = false;
|
var schwab_summary = false;
|
||||||
var portfolio_path: []const u8 = "portfolio.srf";
|
|
||||||
var explicit_portfolio = false;
|
|
||||||
|
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < args.len) : (i += 1) {
|
while (i < args.len) : (i += 1) {
|
||||||
|
|
@ -1185,32 +1183,16 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
schwab_csv = args[i];
|
schwab_csv = args[i];
|
||||||
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
|
} else if (std.mem.eql(u8, args[i], "--schwab-summary")) {
|
||||||
schwab_summary = true;
|
schwab_summary = true;
|
||||||
} else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) {
|
|
||||||
i += 1;
|
|
||||||
portfolio_path = args[i];
|
|
||||||
explicit_portfolio = true;
|
|
||||||
} else if (std.mem.eql(u8, args[i], "--no-color")) {
|
|
||||||
// handled globally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var resolved_pf: ?zfin.Config.ResolvedPath = null;
|
|
||||||
defer if (resolved_pf) |r| r.deinit(allocator);
|
|
||||||
if (!explicit_portfolio) {
|
|
||||||
if (svc.config.resolveUserFile(allocator, portfolio_path)) |r| {
|
|
||||||
resolved_pf = r;
|
|
||||||
portfolio_path = r.path;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
|
||||||
try cli.stderrPrint(
|
try cli.stderrPrint(
|
||||||
\\Usage: zfin audit [options] [-p <portfolio.srf>]
|
\\Usage: zfin [-p <portfolio.srf>] audit [options]
|
||||||
\\
|
\\
|
||||||
\\ --fidelity <csv> Fidelity positions CSV export
|
\\ --fidelity <csv> Fidelity positions CSV export
|
||||||
\\ --schwab <csv> Schwab per-account positions CSV export
|
\\ --schwab <csv> Schwab per-account positions CSV export
|
||||||
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
|
\\ --schwab-summary Schwab account summary (paste to stdin, Ctrl+D to end)
|
||||||
\\ -p, --portfolio Portfolio file (default: portfolio.srf)
|
|
||||||
\\
|
\\
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
1201
src/commands/contributions.zig
Normal file
1201
src/commands/contributions.zig
Normal file
File diff suppressed because it is too large
Load diff
402
src/main.zig
402
src/main.zig
|
|
@ -4,7 +4,7 @@ const tui = @import("tui.zig");
|
||||||
const cli = @import("commands/common.zig");
|
const cli = @import("commands/common.zig");
|
||||||
|
|
||||||
const usage =
|
const usage =
|
||||||
\\Usage: zfin <command> [options]
|
\\Usage: zfin [global options] <command> [command options]
|
||||||
\\
|
\\
|
||||||
\\Commands:
|
\\Commands:
|
||||||
\\ interactive [opts] Launch interactive TUI
|
\\ interactive [opts] Launch interactive TUI
|
||||||
|
|
@ -16,43 +16,44 @@ const usage =
|
||||||
\\ options <SYMBOL> Show options chain (all expirations)
|
\\ options <SYMBOL> Show options chain (all expirations)
|
||||||
\\ earnings <SYMBOL> Show earnings history and upcoming
|
\\ earnings <SYMBOL> Show earnings history and upcoming
|
||||||
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
|
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
|
||||||
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
|
\\ portfolio Load and analyze the portfolio
|
||||||
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
|
\\ analysis Show portfolio analysis
|
||||||
|
\\ contributions Show money added since last commit (git-based diff)
|
||||||
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
|
||||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||||
\\ audit [opts] Reconcile portfolio against brokerage export
|
\\ audit [opts] Reconcile portfolio against brokerage export
|
||||||
\\ cache stats Show cache statistics
|
\\ cache stats Show cache statistics
|
||||||
\\ cache clear Clear all cached data
|
\\ cache clear Clear all cached data
|
||||||
\\
|
\\
|
||||||
\\Global options:
|
\\Global options (must appear before the subcommand):
|
||||||
\\ --no-color Disable colored output
|
\\ --no-color Disable colored output
|
||||||
\\
|
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf; cwd then ZFIN_HOME)
|
||||||
\\Interactive mode options:
|
\\ metadata.srf and accounts.srf are loaded from the
|
||||||
\\ -p, --portfolio <FILE> Portfolio file (.srf)
|
\\ same directory as the resolved portfolio.
|
||||||
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
|
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
|
||||||
|
\\
|
||||||
|
\\Interactive command options:
|
||||||
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
|
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
|
||||||
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
|
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
|
||||||
\\ --default-keys Print default keybindings
|
\\ --default-keys Print default keybindings
|
||||||
\\ --default-theme Print default theme
|
\\ --default-theme Print default theme
|
||||||
\\
|
\\
|
||||||
\\Options command options:
|
\\Options command options:
|
||||||
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
|
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
|
||||||
\\
|
\\
|
||||||
\\Portfolio command options:
|
\\Portfolio command options:
|
||||||
\\ If no file is given, searches current directory then ZFIN_HOME.
|
|
||||||
\\ -w, --watchlist <FILE> Watchlist file
|
|
||||||
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
||||||
\\
|
\\
|
||||||
\\Audit command options:
|
\\Audit command options:
|
||||||
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
|
||||||
\\ --schwab <CSV> Schwab per-account positions CSV export
|
\\ --schwab <CSV> Schwab per-account positions CSV export
|
||||||
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
|
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
|
||||||
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
|
|
||||||
\\
|
\\
|
||||||
\\Analysis command:
|
\\Analysis & Contributions commands:
|
||||||
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
\\ Both operate on the globally-specified portfolio. They also read
|
||||||
\\ from the same directory as the portfolio file.
|
\\ metadata.srf and accounts.srf from the same directory.
|
||||||
\\ If no file is given, searches current directory then ZFIN_HOME.
|
\\ Contributions additionally requires the portfolio file to be tracked
|
||||||
|
\\ in a git repo; `git` must be on PATH.
|
||||||
\\
|
\\
|
||||||
\\Environment Variables:
|
\\Environment Variables:
|
||||||
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
||||||
|
|
@ -66,6 +67,81 @@ const usage =
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
|
|
||||||
|
/// 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 {
|
pub fn main() !u8 {
|
||||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||||
defer _ = gpa.deinit();
|
defer _ = gpa.deinit();
|
||||||
|
|
@ -84,182 +160,181 @@ pub fn main() !u8 {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan for global --no-color flag
|
// Early help handling (before global parsing so `zfin --help` works).
|
||||||
var no_color_flag = false;
|
if (std.mem.eql(u8, args[1], "help") or
|
||||||
for (args[1..]) |arg| {
|
std.mem.eql(u8, args[1], "--help") or
|
||||||
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
|
std.mem.eql(u8, args[1], "-h"))
|
||||||
}
|
{
|
||||||
const color = @import("format.zig").shouldUseColor(no_color_flag);
|
|
||||||
|
|
||||||
var config = zfin.Config.fromEnv(allocator);
|
|
||||||
defer config.deinit();
|
|
||||||
const command = args[1];
|
|
||||||
|
|
||||||
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
|
|
||||||
try out.writeAll(usage);
|
try out.writeAll(usage);
|
||||||
try out.flush();
|
try out.flush();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive TUI -- delegates to the TUI module (owns its own DataService)
|
// 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);
|
||||||
|
|
||||||
|
var config = zfin.Config.fromEnv(allocator);
|
||||||
|
defer config.deinit();
|
||||||
|
|
||||||
|
const command = args[globals.cursor];
|
||||||
|
const cmd_args = args[globals.cursor + 1 ..];
|
||||||
|
|
||||||
|
// Interactive TUI -- delegates to the TUI module (owns its own DataService).
|
||||||
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
|
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
|
||||||
try out.flush();
|
try out.flush();
|
||||||
try tui.run(allocator, config, args);
|
try tui.run(allocator, config, globals.portfolio_path, globals.watchlist_path, cmd_args);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var svc = zfin.DataService.init(allocator, config);
|
var svc = zfin.DataService.init(allocator, config);
|
||||||
defer svc.deinit();
|
defer svc.deinit();
|
||||||
|
|
||||||
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL") for commands that take a symbol.
|
// Normalize symbol argument (cmd_args[0]) to uppercase for commands
|
||||||
// Skip normalization for commands where args[2] is a subcommand or file path.
|
// that take a symbol. Skip for commands whose first arg is a subcommand
|
||||||
if (args.len >= 3 and
|
// or operand of a different kind.
|
||||||
|
const symbol_cmd =
|
||||||
!std.mem.eql(u8, command, "cache") and
|
!std.mem.eql(u8, command, "cache") and
|
||||||
!std.mem.eql(u8, command, "enrich") and
|
!std.mem.eql(u8, command, "enrich") and
|
||||||
!std.mem.eql(u8, command, "audit") and
|
!std.mem.eql(u8, command, "audit") and
|
||||||
!std.mem.eql(u8, command, "analysis") and
|
!std.mem.eql(u8, command, "analysis") and
|
||||||
!std.mem.eql(u8, command, "portfolio"))
|
!std.mem.eql(u8, command, "contributions") and
|
||||||
{
|
!std.mem.eql(u8, command, "portfolio");
|
||||||
for (args[2]) |*c| c.* = std.ascii.toUpper(c.*);
|
if (symbol_cmd and cmd_args.len >= 1) {
|
||||||
|
for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, command, "perf")) {
|
if (std.mem.eql(u8, command, "perf")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.perf.run(allocator, &svc, args[2], color, out);
|
try commands.perf.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "quote")) {
|
} else if (std.mem.eql(u8, command, "quote")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.quote.run(allocator, &svc, args[2], color, out);
|
try commands.quote.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "history")) {
|
} else if (std.mem.eql(u8, command, "history")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.history.run(allocator, &svc, args[2], color, out);
|
try commands.history.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "divs")) {
|
} else if (std.mem.eql(u8, command, "divs")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.divs.run(allocator, &svc, args[2], color, out);
|
try commands.divs.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "splits")) {
|
} else if (std.mem.eql(u8, command, "splits")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.splits.run(allocator, &svc, args[2], color, out);
|
try commands.splits.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "options")) {
|
} else if (std.mem.eql(u8, command, "options")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
// Parse --ntm flag
|
// Parse --ntm flag.
|
||||||
var ntm: usize = 8;
|
var ntm: usize = 8;
|
||||||
var ai: usize = 3;
|
var ai: usize = 1;
|
||||||
while (ai < args.len) : (ai += 1) {
|
while (ai < cmd_args.len) : (ai += 1) {
|
||||||
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
|
if (std.mem.eql(u8, cmd_args[ai], "--ntm") and ai + 1 < cmd_args.len) {
|
||||||
ai += 1;
|
ai += 1;
|
||||||
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
|
ntm = std.fmt.parseInt(usize, cmd_args[ai], 10) catch 8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try commands.options.run(allocator, &svc, args[2], ntm, color, out);
|
try commands.options.run(allocator, &svc, cmd_args[0], ntm, color, out);
|
||||||
} else if (std.mem.eql(u8, command, "earnings")) {
|
} else if (std.mem.eql(u8, command, "earnings")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.earnings.run(allocator, &svc, args[2], color, out);
|
try commands.earnings.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "etf")) {
|
} else if (std.mem.eql(u8, command, "etf")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
|
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.etf.run(allocator, &svc, args[2], color, out);
|
try commands.etf.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "portfolio")) {
|
} else if (std.mem.eql(u8, command, "portfolio")) {
|
||||||
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
|
// Parse --refresh flag; reject any other token (including old
|
||||||
var watchlist_path: ?[]const u8 = null;
|
// positional FILE, which is now a global -p).
|
||||||
var explicit_watchlist = false;
|
|
||||||
var force_refresh = false;
|
var force_refresh = false;
|
||||||
var file_path: []const u8 = "portfolio.srf";
|
for (cmd_args) |a| {
|
||||||
var explicit_file = false;
|
if (std.mem.eql(u8, a, "--refresh")) {
|
||||||
var pi: usize = 2;
|
|
||||||
while (pi < args.len) : (pi += 1) {
|
|
||||||
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
|
|
||||||
pi += 1;
|
|
||||||
watchlist_path = args[pi];
|
|
||||||
explicit_watchlist = true;
|
|
||||||
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
|
|
||||||
force_refresh = true;
|
force_refresh = true;
|
||||||
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
|
|
||||||
// already handled globally
|
|
||||||
} else {
|
} else {
|
||||||
file_path = args[pi];
|
try reportUnexpectedArg("portfolio", a);
|
||||||
explicit_file = true;
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Resolve default file paths via ZFIN_HOME when not explicitly provided
|
const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
|
||||||
var resolved_pf: ?zfin.Config.ResolvedPath = null;
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||||
defer if (resolved_pf) |r| r.deinit(allocator);
|
const wl = resolveUserPath(allocator, config, globals.watchlist_path, "watchlist.srf");
|
||||||
if (!explicit_file) {
|
defer if (wl.resolved) |r| r.deinit(allocator);
|
||||||
if (config.resolveUserFile(allocator, file_path)) |r| {
|
const wl_path: ?[]const u8 = if (globals.watchlist_path != null or wl.resolved != null) wl.path else null;
|
||||||
resolved_pf = r;
|
try commands.portfolio.run(allocator, &svc, pf.path, wl_path, force_refresh, color, out);
|
||||||
file_path = r.path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var resolved_wl: ?zfin.Config.ResolvedPath = null;
|
|
||||||
defer if (resolved_wl) |r| r.deinit(allocator);
|
|
||||||
if (!explicit_watchlist and watchlist_path == null) {
|
|
||||||
if (config.resolveUserFile(allocator, "watchlist.srf")) |r| {
|
|
||||||
resolved_wl = r;
|
|
||||||
watchlist_path = r.path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
|
|
||||||
} else if (std.mem.eql(u8, command, "lookup")) {
|
} else if (std.mem.eql(u8, command, "lookup")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
|
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.lookup.run(allocator, &svc, args[2], color, out);
|
try commands.lookup.run(allocator, &svc, cmd_args[0], color, out);
|
||||||
} else if (std.mem.eql(u8, command, "cache")) {
|
} else if (std.mem.eql(u8, command, "cache")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
|
try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.cache.run(allocator, config, args[2], out);
|
try commands.cache.run(allocator, config, cmd_args[0], out);
|
||||||
} else if (std.mem.eql(u8, command, "enrich")) {
|
} else if (std.mem.eql(u8, command, "enrich")) {
|
||||||
if (args.len < 3) {
|
if (cmd_args.len < 1) {
|
||||||
try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
|
try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.enrich.run(allocator, &svc, args[2], out);
|
try commands.enrich.run(allocator, &svc, cmd_args[0], out);
|
||||||
} else if (std.mem.eql(u8, command, "audit")) {
|
} else if (std.mem.eql(u8, command, "audit")) {
|
||||||
try commands.audit.run(allocator, &svc, args[2..], color, out);
|
const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
|
||||||
|
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")) {
|
} else if (std.mem.eql(u8, command, "analysis")) {
|
||||||
// File path is first non-flag arg (default: portfolio.srf)
|
for (cmd_args) |a| {
|
||||||
var analysis_file: []const u8 = "portfolio.srf";
|
try reportUnexpectedArg("analysis", a);
|
||||||
var explicit_analysis = false;
|
return 1;
|
||||||
for (args[2..]) |arg| {
|
|
||||||
if (!std.mem.startsWith(u8, arg, "--")) {
|
|
||||||
analysis_file = arg;
|
|
||||||
explicit_analysis = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var resolved_af: ?zfin.Config.ResolvedPath = null;
|
const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
|
||||||
defer if (resolved_af) |r| r.deinit(allocator);
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||||
if (!explicit_analysis) {
|
try commands.analysis.run(allocator, &svc, pf.path, color, out);
|
||||||
if (config.resolveUserFile(allocator, analysis_file)) |r| {
|
} else if (std.mem.eql(u8, command, "contributions")) {
|
||||||
resolved_af = r;
|
for (cmd_args) |a| {
|
||||||
analysis_file = r.path;
|
try reportUnexpectedArg("contributions", a);
|
||||||
}
|
return 1;
|
||||||
}
|
}
|
||||||
try commands.analysis.run(allocator, &svc, analysis_file, color, out);
|
const pf = resolveUserPath(allocator, config, globals.portfolio_path, "portfolio.srf");
|
||||||
|
defer if (pf.resolved) |r| r.deinit(allocator);
|
||||||
|
try commands.contributions.run(allocator, &svc, pf.path, color, out);
|
||||||
} else {
|
} else {
|
||||||
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
|
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
|
||||||
return 1;
|
return 1;
|
||||||
|
|
@ -270,6 +345,50 @@ pub fn main() !u8 {
|
||||||
return 0;
|
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 ──────────────────────────────────────────
|
// ── Command modules ──────────────────────────────────────────
|
||||||
const commands = struct {
|
const commands = struct {
|
||||||
const perf = @import("commands/perf.zig");
|
const perf = @import("commands/perf.zig");
|
||||||
|
|
@ -286,8 +405,61 @@ const commands = struct {
|
||||||
const analysis = @import("commands/analysis.zig");
|
const analysis = @import("commands/analysis.zig");
|
||||||
const audit = @import("commands/audit.zig");
|
const audit = @import("commands/audit.zig");
|
||||||
const enrich = @import("commands/enrich.zig");
|
const enrich = @import("commands/enrich.zig");
|
||||||
|
const contributions = @import("commands/contributions.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
|
// Single test binary: all source is in one module (file imports, no module
|
||||||
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
|
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
|
||||||
test {
|
test {
|
||||||
|
|
|
||||||
27
src/tui.zig
27
src/tui.zig
|
|
@ -1991,15 +1991,22 @@ comptime {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entry point for the interactive TUI.
|
/// Entry point for the interactive TUI.
|
||||||
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void {
|
/// `args` contains only command-local tokens (everything after `interactive`).
|
||||||
var portfolio_path: ?[]const u8 = null;
|
pub fn run(
|
||||||
var watchlist_path: ?[]const u8 = null;
|
allocator: std.mem.Allocator,
|
||||||
|
config: zfin.Config,
|
||||||
|
global_portfolio_path: ?[]const u8,
|
||||||
|
global_watchlist_path: ?[]const u8,
|
||||||
|
args: []const []const u8,
|
||||||
|
) !void {
|
||||||
|
var portfolio_path: ?[]const u8 = global_portfolio_path;
|
||||||
|
const watchlist_path: ?[]const u8 = global_watchlist_path;
|
||||||
var symbol: []const u8 = "";
|
var symbol: []const u8 = "";
|
||||||
var symbol_upper_buf: [32]u8 = undefined;
|
var symbol_upper_buf: [32]u8 = undefined;
|
||||||
var has_explicit_symbol = false;
|
var has_explicit_symbol = false;
|
||||||
var skip_watchlist = false;
|
var skip_watchlist = false;
|
||||||
var chart_config: chart_mod.ChartConfig = .{};
|
var chart_config: chart_mod.ChartConfig = .{};
|
||||||
var i: usize = 2;
|
var i: usize = 0;
|
||||||
while (i < args.len) : (i += 1) {
|
while (i < args.len) : (i += 1) {
|
||||||
if (std.mem.eql(u8, args[i], "--default-keys")) {
|
if (std.mem.eql(u8, args[i], "--default-keys")) {
|
||||||
try keybinds.printDefaults();
|
try keybinds.printDefaults();
|
||||||
|
|
@ -2007,18 +2014,6 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
|
||||||
} else if (std.mem.eql(u8, args[i], "--default-theme")) {
|
} else if (std.mem.eql(u8, args[i], "--default-theme")) {
|
||||||
try theme_mod.printDefaults();
|
try theme_mod.printDefaults();
|
||||||
return;
|
return;
|
||||||
} else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) {
|
|
||||||
if (i + 1 < args.len) {
|
|
||||||
i += 1;
|
|
||||||
portfolio_path = args[i];
|
|
||||||
}
|
|
||||||
} else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) {
|
|
||||||
if (i + 1 < args.len) {
|
|
||||||
i += 1;
|
|
||||||
watchlist_path = args[i];
|
|
||||||
} else {
|
|
||||||
watchlist_path = "watchlist.srf";
|
|
||||||
}
|
|
||||||
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
|
} else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) {
|
||||||
if (i + 1 < args.len) {
|
if (i + 1 < args.len) {
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue