zfin/src/main.zig
2026-03-20 08:40:04 -07:00

246 lines
10 KiB
Zig

const std = @import("std");
const zfin = @import("root.zig");
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const usage =
\\Usage: zfin <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> Show recent price history
\\ 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 [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\
\\Global options:
\\ --no-color Disable colored output
\\
\\Interactive mode options:
\\ -p, --portfolio <FILE> Portfolio file (.srf)
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ -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:
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\
\\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)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
pub fn main() !u8 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, 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;
}
// Scan for global --no-color flag
var no_color_flag = false;
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
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.flush();
return 0;
}
// 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")) {
try out.flush();
try tui.run(allocator, config, args);
return 0;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL")
if (args.len >= 3) {
for (args[2]) |*c| c.* = std.ascii.toUpper(c.*);
}
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
return 1;
}
try commands.perf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
return 1;
}
try commands.quote.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
return 1;
}
try commands.history.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
return 1;
}
try commands.divs.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
return 1;
}
try commands.splits.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
return 1;
}
// Parse --ntm flag
var ntm: usize = 8;
var ai: usize = 3;
while (ai < args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
}
}
try commands.options.run(allocator, &svc, args[2], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
return 1;
}
try commands.earnings.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
return 1;
}
try commands.etf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
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];
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
}
}
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
return 1;
}
try commands.lookup.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
return 1;
}
try commands.cache.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
return 1;
}
try commands.enrich.run(allocator, &svc, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
break;
}
}
try commands.analysis.run(allocator, &svc, analysis_file, color, out);
} 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;
}
// ── 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 enrich = @import("commands/enrich.zig");
};
// 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());
}