246 lines
10 KiB
Zig
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());
|
|
}
|