zfin/src/cli/main.zig

1852 lines
71 KiB
Zig

const std = @import("std");
const zfin = @import("zfin");
const fmt = zfin.format;
const tui = @import("tui");
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 (.srf file)
\\ 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:
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\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)
\\
;
// ── Default CLI colors (match TUI default theme) ─────────────
const CLR_GREEN = [3]u8{ 166, 227, 161 }; // positive
const CLR_RED = [3]u8{ 243, 139, 168 }; // negative
const CLR_MUTED = [3]u8{ 128, 128, 128 }; // muted/dim
const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers
const CLR_ACCENT = [3]u8{ 137, 180, 250 }; // info/accent
const CLR_YELLOW = [3]u8{ 249, 226, 175 }; // stale/manual price indicator
pub fn main() !void {
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);
if (args.len < 2) {
try stdout_print(usage);
return;
}
// 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 = fmt.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 stdout_print(usage);
return;
}
// 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 tui.run(allocator, config, args);
return;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) return try stderr_print("Error: 'perf' requires a symbol argument\n");
try cmdPerf(allocator, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) return try stderr_print("Error: 'quote' requires a symbol argument\n");
try cmdQuote(allocator, config, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) return try stderr_print("Error: 'history' requires a symbol argument\n");
try cmdHistory(allocator, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) return try stderr_print("Error: 'divs' requires a symbol argument\n");
try cmdDivs(allocator, &svc, config, args[2], color);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) return try stderr_print("Error: 'splits' requires a symbol argument\n");
try cmdSplits(allocator, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) return try stderr_print("Error: 'options' requires a symbol argument\n");
// 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 cmdOptions(allocator, &svc, args[2], ntm, color);
} else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) return try stderr_print("Error: 'earnings' requires a symbol argument\n");
try cmdEarnings(allocator, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n");
try cmdEtf(allocator, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "portfolio")) {
if (args.len < 3) return try stderr_print("Error: 'portfolio' requires a file path argument\n");
// Parse -w/--watchlist and --refresh flags
var watchlist_path: ?[]const u8 = null;
var force_refresh = false;
var pi: usize = 3;
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;
}
}
try cmdPortfolio(allocator, config, &svc, args[2], watchlist_path, force_refresh, color);
} else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n");
try cmdLookup(allocator, &svc, args[2], color);
} else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n");
try cmdCache(allocator, config, args[2]);
} else {
try stderr_print("Unknown command. Run 'zfin help' for usage.\n");
}
}
// ── ANSI color helpers ───────────────────────────────────────
fn setFg(out: anytype, c: bool, rgb: [3]u8) !void {
if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]);
}
fn setBold(out: anytype, c: bool) !void {
if (c) try fmt.ansiBold(out);
}
fn reset(out: anytype, c: bool) !void {
if (c) try fmt.ansiReset(out);
}
fn setGainLoss(out: anytype, c: bool, value: f64) !void {
if (c) {
if (value >= 0)
try fmt.ansiSetFg(out, CLR_GREEN[0], CLR_GREEN[1], CLR_GREEN[2])
else
try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]);
}
}
// ── Commands ─────────────────────────────────────────────────
fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void {
const result = svc.getTrailingReturns(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n");
return;
},
else => {
try stderr_print("Error fetching data.\n");
return;
},
};
defer allocator.free(result.candles);
defer if (result.dividends) |d| allocator.free(d);
if (result.source == .cached) try stderr_print("(using cached data)\n");
const c = result.candles;
const end_date = c[c.len - 1].date;
const today = fmt.todayDate();
const month_end = today.lastDayOfPriorMonth();
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nTrailing Returns for {s}\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
try setFg(out, color, CLR_MUTED);
try out.print("Data points: {d} (", .{c.len});
{
var db: [10]u8 = undefined;
try out.print("{s}", .{c[0].date.format(&db)});
}
try out.print(" to ", .{});
{
var db: [10]u8 = undefined;
try out.print("{s}", .{end_date.format(&db)});
}
try reset(out, color);
var close_buf: [24]u8 = undefined;
try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoney(&close_buf, c[c.len - 1].close)});
const has_divs = result.asof_total != null;
// -- As-of-date returns --
{
var db: [10]u8 = undefined;
try setBold(out, color);
try out.print("\nAs-of {s}:\n", .{end_date.format(&db)});
try reset(out, color);
}
try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null, color);
// -- Month-end returns --
{
var db: [10]u8 = undefined;
try setBold(out, color);
try out.print("\nMonth-end ({s}):\n", .{month_end.format(&db)});
try reset(out, color);
}
try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color);
if (!has_divs) {
try setFg(out, color, CLR_MUTED);
try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{});
try reset(out, color);
}
try out.print("\n", .{});
try out.flush();
}
fn printReturnsTable(
out: anytype,
price: zfin.performance.TrailingReturns,
total: ?zfin.performance.TrailingReturns,
color: bool,
) !void {
const has_total = total != null;
try setFg(out, color, CLR_MUTED);
if (has_total) {
try out.print("{s:>22} {s:>14} {s:>14}\n", .{ "", "Price Only", "Total Return" });
try out.print("{s:->22} {s:->14} {s:->14}\n", .{ "", "", "" });
} else {
try out.print("{s:>22} {s:>14}\n", .{ "", "Price Only" });
try out.print("{s:->22} {s:->14}\n", .{ "", "" });
}
try reset(out, color);
const periods = [_]struct { label: []const u8, years: u16 }{
.{ .label = "1-Year Return:", .years = 1 },
.{ .label = "3-Year Return:", .years = 3 },
.{ .label = "5-Year Return:", .years = 5 },
.{ .label = "10-Year Return:", .years = 10 },
};
const price_arr = [_]?zfin.performance.PerformanceResult{
price.one_year, price.three_year, price.five_year, price.ten_year,
};
const total_arr: [4]?zfin.performance.PerformanceResult = if (total) |t|
.{ t.one_year, t.three_year, t.five_year, t.ten_year }
else
.{ null, null, null, null };
for (periods, 0..) |period, i| {
try out.print(" {s:<20}", .{period.label});
if (price_arr[i]) |r| {
var rb: [32]u8 = undefined;
const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return;
try setGainLoss(out, color, val);
try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)});
try reset(out, color);
} else {
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>13}", .{"N/A"});
try reset(out, color);
}
if (has_total) {
if (total_arr[i]) |r| {
var rb: [32]u8 = undefined;
const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return;
try setGainLoss(out, color, val);
try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)});
try reset(out, color);
} else {
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>13}", .{"N/A"});
try reset(out, color);
}
}
if (period.years > 1) {
try setFg(out, color, CLR_MUTED);
try out.print(" ann.", .{});
try reset(out, color);
}
try out.print("\n", .{});
}
}
fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool) !void {
// Fetch candle data for chart and history
const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: TWELVEDATA_API_KEY not set.\n");
return;
},
else => {
try stderr_print("Error fetching candle data.\n");
return;
},
};
defer allocator.free(candle_result.data);
const candles = candle_result.data;
// Fetch real-time quote
var q_close: f64 = 0;
var q_open: f64 = 0;
var q_high: f64 = 0;
var q_low: f64 = 0;
var q_volume: u64 = 0;
var q_prev_close: f64 = 0;
var has_quote = false;
if (config.twelvedata_key) |key| {
var td = zfin.TwelveData.init(allocator, key);
defer td.deinit();
if (td.fetchQuote(allocator, symbol)) |qr_val| {
var qr = qr_val;
defer qr.deinit();
if (qr.parse(allocator)) |q_val| {
var q = q_val;
defer q.deinit();
q_close = q.close();
q_open = q.open();
q_high = q.high();
q_low = q.low();
q_volume = q.volume();
q_prev_close = q.previous_close();
has_quote = true;
} else |_| {}
} else |_| {}
}
var buf: [16384]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
// Header
try setBold(out, color);
if (has_quote) {
var price_buf: [24]u8 = undefined;
try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q_close) });
} else if (candles.len > 0) {
var price_buf: [24]u8 = undefined;
try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoney(&price_buf, candles[candles.len - 1].close) });
} else {
try out.print("\n{s}\n", .{symbol});
}
try reset(out, color);
try out.print("========================================\n", .{});
// Quote details
const price = if (has_quote) q_close else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
const prev_close = if (has_quote) q_prev_close else if (candles.len >= 2) candles[candles.len - 2].close else @as(f64, 0);
if (candles.len > 0 or has_quote) {
const latest_date = if (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate();
const open_val = if (has_quote) q_open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0);
const high_val = if (has_quote) q_high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0);
const low_val = if (has_quote) q_low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0);
const vol_val = if (has_quote) q_volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0);
var date_buf: [10]u8 = undefined;
var vol_buf: [32]u8 = undefined;
try out.print(" Date: {s}\n", .{latest_date.format(&date_buf)});
try out.print(" Open: ${d:.2}\n", .{open_val});
try out.print(" High: ${d:.2}\n", .{high_val});
try out.print(" Low: ${d:.2}\n", .{low_val});
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
if (prev_close > 0) {
const change = price - prev_close;
const pct = (change / prev_close) * 100.0;
try setGainLoss(out, color, change);
if (change >= 0) {
try out.print(" Change: +${d:.2} (+{d:.2}%)\n", .{ change, pct });
} else {
try out.print(" Change: -${d:.2} ({d:.2}%)\n", .{ -change, pct });
}
try reset(out, color);
}
}
// Braille chart (60 columns, 10 rows)
if (candles.len >= 2) {
try out.print("\n", .{});
const chart_days: usize = @min(candles.len, 60);
const chart_data = candles[candles.len - chart_days ..];
var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, CLR_GREEN, CLR_RED) catch null;
if (chart) |*ch| {
defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, ch, color, CLR_MUTED);
}
}
// Recent history table (last 20 candles)
if (candles.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Recent History:\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
"Date", "Open", "High", "Low", "Close", "Volume",
});
try reset(out, color);
const start_idx = if (candles.len > 20) candles.len - 20 else 0;
for (candles[start_idx..]) |candle| {
var db: [10]u8 = undefined;
var vb: [32]u8 = undefined;
const day_gain = candle.close >= candle.open;
try setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1));
try out.print(" {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
});
try reset(out, color);
}
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
}
try out.print("\n", .{});
try out.flush();
}
fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void {
const result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: TWELVEDATA_API_KEY not set.\n");
return;
},
else => {
try stderr_print("Error fetching data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached data)\n");
const all = result.data;
if (all.len == 0) return try stderr_print("No data available.\n");
const today = fmt.todayDate();
const one_month_ago = today.addDays(-30);
const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return try stderr_print("No data available.\n");
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
try setFg(out, color, CLR_MUTED);
try out.print("{s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
"Date", "Open", "High", "Low", "Close", "Volume",
});
try out.print("{s:->12} {s:->10} {s:->10} {s:->10} {s:->10} {s:->12}\n", .{
"", "", "", "", "", "",
});
try reset(out, color);
for (c) |candle| {
var db: [10]u8 = undefined;
var vb: [32]u8 = undefined;
try setGainLoss(out, color, if (candle.close >= candle.open) @as(f64, 1) else @as(f64, -1));
try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
});
try reset(out, color);
}
try out.print("\n{d} trading days\n\n", .{c.len});
try out.flush();
}
fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool) !void {
const result = svc.getDividends(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try stderr_print("Error fetching dividend data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached dividend data)\n");
const d = result.data;
// Fetch current price for yield calculation
var current_price: ?f64 = null;
if (config.twelvedata_key) |td_key| {
var td = zfin.TwelveData.init(allocator, td_key);
defer td.deinit();
if (td.fetchQuote(allocator, symbol)) |qr_val| {
var qr = qr_val;
defer qr.deinit();
if (qr.parse(allocator)) |q_val| {
var q = q_val;
defer q.deinit();
current_price = q.close();
} else |_| {}
} else |_| {}
}
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nDividend History for {s}\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
if (d.len == 0) {
try setFg(out, color, CLR_MUTED);
try out.print(" No dividends found.\n\n", .{});
try reset(out, color);
try out.flush();
return;
}
try setFg(out, color, CLR_MUTED);
try out.print("{s:>12} {s:>10} {s:>12} {s:>6} {s:>10}\n", .{
"Ex-Date", "Amount", "Pay Date", "Freq", "Type",
});
try out.print("{s:->12} {s:->10} {s:->12} {s:->6} {s:->10}\n", .{
"", "", "", "", "",
});
try reset(out, color);
const today = fmt.todayDate();
const one_year_ago = today.subtractYears(1);
var total: f64 = 0;
var ttm: f64 = 0;
for (d) |div| {
var ex_buf: [10]u8 = undefined;
try out.print("{s:>12} {d:>10.4}", .{ div.ex_date.format(&ex_buf), div.amount });
if (div.pay_date) |pd| {
var pay_buf: [10]u8 = undefined;
try out.print(" {s:>12}", .{pd.format(&pay_buf)});
} else {
try out.print(" {s:>12}", .{"--"});
}
if (div.frequency) |f| {
try out.print(" {d:>6}", .{f});
} else {
try out.print(" {s:>6}", .{"--"});
}
try out.print(" {s:>10}\n", .{@tagName(div.distribution_type)});
total += div.amount;
if (!div.ex_date.lessThan(one_year_ago)) ttm += div.amount;
}
try out.print("\n{d} dividends, total: ${d:.4}\n", .{ d.len, total });
try setFg(out, color, CLR_ACCENT);
try out.print("TTM dividends: ${d:.4}", .{ttm});
if (current_price) |cp| {
if (cp > 0) {
const yield = (ttm / cp) * 100.0;
try out.print(" (yield: {d:.2}% at ${d:.2})", .{ yield, cp });
}
}
try reset(out, color);
try out.print("\n\n", .{});
try out.flush();
}
fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void {
const result = svc.getSplits(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try stderr_print("Error fetching split data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached split data)\n");
const sp = result.data;
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nSplit History for {s}\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
if (sp.len == 0) {
try setFg(out, color, CLR_MUTED);
try out.print(" No splits found.\n\n", .{});
try reset(out, color);
try out.flush();
return;
}
try setFg(out, color, CLR_MUTED);
try out.print("{s:>12} {s:>10}\n", .{ "Date", "Ratio" });
try out.print("{s:->12} {s:->10}\n", .{ "", "" });
try reset(out, color);
for (sp) |s| {
var db: [10]u8 = undefined;
try out.print("{s:>12} {d:.0}:{d:.0}\n", .{ s.date.format(&db), s.numerator, s.denominator });
}
try out.print("\n{d} split(s)\n\n", .{sp.len});
try out.flush();
}
fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool) !void {
const result = svc.getOptions(symbol) catch |err| switch (err) {
zfin.DataError.FetchFailed => {
try stderr_print("Error fetching options data from CBOE.\n");
return;
},
else => {
try stderr_print("Error loading options data.\n");
return;
},
};
const ch = result.data;
defer {
for (ch) |chain| {
allocator.free(chain.underlying_symbol);
allocator.free(chain.calls);
allocator.free(chain.puts);
}
allocator.free(ch);
}
if (result.source == .cached) try stderr_print("(using cached options data)\n");
if (ch.len == 0) {
try stderr_print("No options data found.\n");
return;
}
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nOptions Chain for {s}\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
if (ch[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined;
try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoney(&price_buf, price), ch.len, ntm });
} else {
try out.print("{d} expiration(s) available\n", .{ch.len});
}
// Find nearest monthly expiration to auto-expand
var auto_expand_idx: ?usize = null;
for (ch, 0..) |chain, ci| {
if (fmt.isMonthlyExpiration(chain.expiration)) {
auto_expand_idx = ci;
break;
}
}
// If no monthly found, expand the first one
if (auto_expand_idx == null and ch.len > 0) auto_expand_idx = 0;
const atm_price = if (ch[0].underlying_price) |p| p else @as(f64, 0);
// List all expirations, expanding the nearest monthly
for (ch, 0..) |chain, ci| {
var db: [10]u8 = undefined;
const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
const is_expanded = auto_expand_idx != null and ci == auto_expand_idx.?;
try out.print("\n", .{});
if (is_expanded) {
try setBold(out, color);
try out.print("{s} ({d} calls, {d} puts)", .{
chain.expiration.format(&db), chain.calls.len, chain.puts.len,
});
if (is_monthly) try out.print(" [monthly]", .{});
try reset(out, color);
try out.print("\n", .{});
// Print calls
try printOptionsSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color);
try out.print("\n", .{});
// Print puts
try printOptionsSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color);
} else {
try setFg(out, color, if (is_monthly) CLR_HEADER else CLR_MUTED);
try out.print("{s} ({d} calls, {d} puts)", .{
chain.expiration.format(&db), chain.calls.len, chain.puts.len,
});
if (is_monthly) try out.print(" [monthly]", .{});
try reset(out, color);
try out.print("\n", .{});
}
}
try out.print("\n", .{});
try out.flush();
}
fn printOptionsSection(
out: anytype,
allocator: std.mem.Allocator,
label: []const u8,
contracts: []const zfin.OptionContract,
atm_price: f64,
ntm: usize,
is_calls: bool,
color: bool,
) !void {
try setBold(out, color);
try out.print(" {s}\n", .{label});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}\n", .{
"Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
});
try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8} {s:->8}\n", .{
"", "", "", "", "", "", "",
});
try reset(out, color);
const filtered = fmt.filterNearMoney(contracts, atm_price, ntm);
for (filtered) |c| {
const itm = if (is_calls) c.strike <= atm_price else c.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " ";
const line = try fmt.fmtContractLine(allocator, prefix, c);
defer allocator.free(line);
try out.print("{s}\n", .{line});
}
}
fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void {
const result = svc.getEarnings(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n");
return;
},
else => {
try stderr_print("Error fetching earnings data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try stderr_print("(using cached earnings data)\n");
const ev = result.data;
var buf: [8192]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nEarnings History for {s}\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
if (ev.len == 0) {
try setFg(out, color, CLR_MUTED);
try out.print(" No earnings data found.\n\n", .{});
try reset(out, color);
try out.flush();
return;
}
try setFg(out, color, CLR_MUTED);
try out.print("{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10} {s:>5}\n", .{
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", "When",
});
try out.print("{s:->12} {s:->4} {s:->12} {s:->12} {s:->12} {s:->10} {s:->5}\n", .{
"", "", "", "", "", "", "",
});
try reset(out, color);
for (ev) |e| {
var db: [10]u8 = undefined;
const is_future = e.isFuture();
const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true;
if (is_future) {
try setFg(out, color, CLR_MUTED);
} else if (surprise_positive) {
try setFg(out, color, CLR_GREEN);
} else {
try setFg(out, color, CLR_RED);
}
try out.print("{s:>12}", .{e.date.format(&db)});
if (e.quarter) |q| try out.print(" Q{d}", .{q}) else try out.print(" {s:>4}", .{"--"});
if (e.estimate) |est| try out.print(" {s:>12}", .{fmtEps(est)}) else try out.print(" {s:>12}", .{"--"});
if (e.actual) |act| try out.print(" {s:>12}", .{fmtEps(act)}) else try out.print(" {s:>12}", .{"--"});
if (e.surpriseAmount()) |s| {
var surp_buf: [12]u8 = undefined;
const surp_str = if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?";
try out.print(" {s:>12}", .{surp_str});
} else {
try out.print(" {s:>12}", .{"--"});
}
if (e.surprisePct()) |sp| {
var pct_buf: [12]u8 = undefined;
const pct_str = if (sp >= 0) std.fmt.bufPrint(&pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&pct_buf, "{d:.1}%", .{sp}) catch "?";
try out.print(" {s:>10}", .{pct_str});
} else {
try out.print(" {s:>10}", .{"--"});
}
try out.print(" {s:>5}", .{@tagName(e.report_time)});
try reset(out, color);
try out.print("\n", .{});
}
try out.print("\n{d} earnings event(s)\n\n", .{ev.len});
try out.flush();
}
fn fmtEps(val: f64) [12]u8 {
var buf: [12]u8 = .{' '} ** 12;
_ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {};
return buf;
}
fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void {
const result = svc.getEtfProfile(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
return;
},
else => {
try stderr_print("Error fetching ETF profile.\n");
return;
},
};
const profile = result.data;
defer {
if (profile.holdings) |h| {
for (h) |holding| {
if (holding.symbol) |s| allocator.free(s);
allocator.free(holding.name);
}
allocator.free(h);
}
if (profile.sectors) |s| {
for (s) |sec| allocator.free(sec.sector);
allocator.free(s);
}
}
if (result.source == .cached) try stderr_print("(using cached ETF profile)\n");
try printEtfProfile(profile, symbol, color);
}
fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool) !void {
var buf: [16384]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try setBold(out, color);
try out.print("\nETF Profile: {s}\n", .{symbol});
try reset(out, color);
try out.print("========================================\n", .{});
if (profile.expense_ratio) |er| {
try out.print(" Expense Ratio: {d:.2}%\n", .{er * 100.0});
}
if (profile.net_assets) |na| {
try out.print(" Net Assets: ${s}\n", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})});
}
if (profile.dividend_yield) |dy| {
try out.print(" Dividend Yield: {d:.2}%\n", .{dy * 100.0});
}
if (profile.portfolio_turnover) |pt| {
try out.print(" Portfolio Turnover: {d:.1}%\n", .{pt * 100.0});
}
if (profile.inception_date) |d| {
var db: [10]u8 = undefined;
try out.print(" Inception Date: {s}\n", .{d.format(&db)});
}
if (profile.leveraged) {
try setFg(out, color, CLR_RED);
try out.print(" Leveraged: YES\n", .{});
try reset(out, color);
}
if (profile.total_holdings) |th| {
try out.print(" Total Holdings: {d}\n", .{th});
}
// Sectors
if (profile.sectors) |sectors| {
if (sectors.len > 0) {
try setBold(out, color);
try out.print("\n Sector Allocation:\n", .{});
try reset(out, color);
for (sectors) |sec| {
try setFg(out, color, CLR_ACCENT);
try out.print(" {d:>5.1}%", .{sec.weight * 100.0});
try reset(out, color);
try out.print(" {s}\n", .{sec.sector});
}
}
}
// Top holdings
if (profile.holdings) |holdings| {
if (holdings.len > 0) {
try setBold(out, color);
try out.print("\n Top Holdings:\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>6} {s:>7} {s}\n", .{ "Symbol", "Weight", "Name" });
try out.print(" {s:->6} {s:->7} {s:->30}\n", .{ "", "", "" });
try reset(out, color);
for (holdings) |h| {
if (h.symbol) |s| {
try setFg(out, color, CLR_ACCENT);
try out.print(" {s:>6}", .{s});
try reset(out, color);
try out.print(" {d:>6.2}% {s}\n", .{ h.weight * 100.0, h.name });
} else {
try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ "--", h.weight * 100.0, h.name });
}
}
}
}
try out.print("\n", .{});
try out.flush();
}
fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool) !void {
// Load portfolio from SRF file
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
try stderr_print("Error reading portfolio file: ");
try stderr_print(@errorName(err));
try stderr_print("\n");
return;
};
defer allocator.free(data);
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
try stderr_print("Error parsing portfolio file.\n");
return;
};
defer portfolio.deinit();
if (portfolio.lots.len == 0) {
try stderr_print("Portfolio is empty.\n");
return;
}
// Get stock/ETF positions (excludes options, CDs, cash)
const positions = try portfolio.positions(allocator);
defer allocator.free(positions);
// Get unique stock/ETF symbols and fetch current prices
const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var fail_count: usize = 0;
// Also collect watch symbols that need fetching
var watch_syms: std.ArrayList([]const u8) = .empty;
defer watch_syms.deinit(allocator);
{
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (syms) |s| try seen.put(s, {});
for (portfolio.lots) |lot| {
if (lot.lot_type == .watch and !seen.contains(lot.priceSymbol())) {
try seen.put(lot.priceSymbol(), {});
try watch_syms.append(allocator, lot.priceSymbol());
}
}
}
// All symbols to fetch (stock positions + watch)
const all_syms_count = syms.len + watch_syms.items.len;
if (all_syms_count > 0) {
if (config.twelvedata_key == null) {
try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
}
var loaded_count: usize = 0;
var cached_count: usize = 0;
// Fetch stock/ETF prices via DataService (respects cache TTL)
for (syms) |sym| {
loaded_count += 1;
// If --refresh, invalidate cache for this symbol
if (force_refresh) {
svc.invalidate(sym, .candles_daily);
}
// Check if cached and fresh (will be a fast no-op)
const is_fresh = svc.isCandleCacheFresh(sym);
if (is_fresh and !force_refresh) {
// Load from cache (no network)
if (svc.getCachedCandles(sym)) |cs| {
defer allocator.free(cs);
if (cs.len > 0) {
try prices.put(sym, cs[cs.len - 1].close);
cached_count += 1;
try stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color);
continue;
}
}
}
// Need to fetch from API
const wait_s = svc.estimateWaitSeconds();
if (wait_s) |w| {
if (w > 0) {
try stderrRateLimitWait(w, color);
}
}
try stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color);
const result = svc.getCandles(sym) catch {
fail_count += 1;
try stderrProgress(sym, " FAILED", loaded_count, all_syms_count, color);
continue;
};
defer allocator.free(result.data);
if (result.data.len > 0) {
try prices.put(sym, result.data[result.data.len - 1].close);
}
}
// Fetch watch symbol candles (for watchlist display)
for (watch_syms.items) |sym| {
loaded_count += 1;
if (force_refresh) {
svc.invalidate(sym, .candles_daily);
}
const is_fresh = svc.isCandleCacheFresh(sym);
if (is_fresh and !force_refresh) {
cached_count += 1;
try stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color);
continue;
}
const wait_s = svc.estimateWaitSeconds();
if (wait_s) |w| {
if (w > 0) {
try stderrRateLimitWait(w, color);
}
}
try stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color);
const result = svc.getCandles(sym) catch {
try stderrProgress(sym, " FAILED", loaded_count, all_syms_count, color);
continue;
};
allocator.free(result.data);
}
// Summary line
{
var msg_buf: [256]u8 = undefined;
if (cached_count == all_syms_count) {
const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n";
try stderr_print(msg);
} else {
const fetched_count = all_syms_count - cached_count - fail_count;
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n";
try stderr_print(msg);
}
}
}
// Compute summary
// Build fallback prices for symbols that failed API fetch:
// 1. Use manual price:: from SRF if available
// 2. Otherwise use position avg_cost (open_price) so the position still appears
var manual_price_set = std.StringHashMap(void).init(allocator);
defer manual_price_set.deinit();
// First pass: manual price:: overrides
for (portfolio.lots) |lot| {
if (lot.lot_type != .stock) continue;
const sym = lot.priceSymbol();
if (lot.price) |p| {
if (!prices.contains(sym)) {
try prices.put(sym, p);
try manual_price_set.put(sym, {});
}
}
}
// Second pass: fall back to avg_cost for anything still missing
for (positions) |pos| {
if (!prices.contains(pos.symbol) and pos.shares > 0) {
try prices.put(pos.symbol, pos.avg_cost);
try manual_price_set.put(pos.symbol, {});
}
}
var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch {
try stderr_print("Error computing portfolio summary.\n");
return;
};
defer summary.deinit(allocator);
// Sort allocations alphabetically by symbol
std.mem.sort(zfin.risk.Allocation, summary.allocations, {}, struct {
fn f(_: void, a: zfin.risk.Allocation, b: zfin.risk.Allocation) bool {
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
}
}.f);
// Include non-stock assets in the grand total
const cash_total = portfolio.totalCash();
const cd_total = portfolio.totalCdFaceValue();
const opt_total = portfolio.totalOptionCost();
const non_stock = cash_total + cd_total + opt_total;
summary.total_value += non_stock;
summary.total_cost += non_stock;
if (summary.total_cost > 0) {
summary.unrealized_return = summary.unrealized_pnl / summary.total_cost;
}
// Reweight allocations against grand total
if (summary.total_value > 0) {
for (summary.allocations) |*a| {
a.weight = a.market_value / summary.total_value;
}
}
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
// Header with summary
try setBold(out, color);
try out.print("\nPortfolio Summary ({s})\n", .{file_path});
try reset(out, color);
try out.print("========================================\n", .{});
// Summary bar
{
var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl;
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) });
try setGainLoss(out, color, summary.unrealized_pnl);
if (summary.unrealized_pnl >= 0) {
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
} else {
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
}
try reset(out, color);
try out.print("\n", .{});
}
// Lot counts (stocks/ETFs only)
var open_lots: u32 = 0;
var closed_lots: u32 = 0;
for (portfolio.lots) |lot| {
if (lot.lot_type != .stock) continue;
if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
}
try setFg(out, color, CLR_MUTED);
try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
try reset(out, color);
// Column headers
try out.print("\n", .{});
try setFg(out, color, CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{
"Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account",
});
try out.print(" " ++ std.fmt.comptimePrint("{{s:->{d}}}", .{fmt.sym_col_width}) ++ " {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{
"", "", "", "", "", "", "", "", "",
});
try reset(out, color);
// Position rows with lot detail
for (summary.allocations) |a| {
// Count stock lots for this symbol
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
defer lots_for_sym.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
try lots_for_sym.append(allocator, lot);
}
}
std.mem.sort(zfin.Lot, lots_for_sym.items, {}, fmt.lotSortFn);
const is_multi = lots_for_sym.items.len > 1;
// Position summary row
{
var mv_buf: [24]u8 = undefined;
var cost_buf2: [24]u8 = undefined;
var price_buf2: [24]u8 = undefined;
var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl;
const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
const sign: []const u8 = if (a.unrealized_pnl >= 0) "+" else "-";
// Date + ST/LT for single-lot positions
var date_col: [24]u8 = .{' '} ** 24;
var date_col_len: usize = 0;
if (!is_multi and lots_for_sym.items.len == 1) {
const lot = lots_for_sym.items[0];
var pos_date_buf: [10]u8 = undefined;
const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
const written = std.fmt.bufPrint(&date_col, "{s} {s}", .{ ds, indicator }) catch "";
date_col_len = written.len;
}
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
});
if (a.is_manual_price) try setFg(out, color, CLR_YELLOW);
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
if (a.is_manual_price) try reset(out, color);
try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)});
try setGainLoss(out, color, a.unrealized_pnl);
try out.print("{s}{s:>13}", .{ sign, gl_money });
try reset(out, color);
try out.print(" {d:>7.1}%", .{a.weight * 100.0});
if (date_col_len > 0) {
try out.print(" {s}", .{date_col[0..date_col_len]});
}
// Account for single-lot
if (!is_multi and lots_for_sym.items.len == 1) {
if (lots_for_sym.items[0].account) |acct| {
try out.print(" {s}", .{acct});
}
}
try out.print("\n", .{});
}
// Lot detail rows (always expanded for CLI)
if (is_multi) {
// Check if any lots are DRIP
var has_drip = false;
for (lots_for_sym.items) |lot| {
if (lot.drip) { has_drip = true; break; }
}
if (!has_drip) {
// No DRIP: show all individually
for (lots_for_sym.items) |lot| {
try printCliLotRow(out, color, lot, a.current_price);
}
} else {
// Show non-DRIP lots individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printCliLotRow(out, color, lot, a.current_price);
}
}
// Summarize DRIP lots as ST/LT
var st_lots: usize = 0;
var st_shares: f64 = 0;
var st_cost: f64 = 0;
var st_first: ?zfin.Date = null;
var st_last: ?zfin.Date = null;
var lt_lots: usize = 0;
var lt_shares: f64 = 0;
var lt_cost: f64 = 0;
var lt_first: ?zfin.Date = null;
var lt_last: ?zfin.Date = null;
for (lots_for_sym.items) |lot| {
if (!lot.drip) continue;
const is_lt = std.mem.eql(u8, fmt.capitalGainsIndicator(lot.open_date), "LT");
if (is_lt) {
lt_lots += 1;
lt_shares += lot.shares;
lt_cost += lot.costBasis();
if (lt_first == null or lot.open_date.days < lt_first.?.days) lt_first = lot.open_date;
if (lt_last == null or lot.open_date.days > lt_last.?.days) lt_last = lot.open_date;
} else {
st_lots += 1;
st_shares += lot.shares;
st_cost += lot.costBasis();
if (st_first == null or lot.open_date.days < st_first.?.days) st_first = lot.open_date;
if (st_last == null or lot.open_date.days > st_last.?.days) st_last = lot.open_date;
}
}
if (st_lots > 0) {
var avg_buf: [24]u8 = undefined;
var d1_buf: [10]u8 = undefined;
var d2_buf: [10]u8 = undefined;
try setFg(out, color, CLR_MUTED);
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
st_lots,
st_shares,
fmt.fmtMoney2(&avg_buf, if (st_shares > 0) st_cost / st_shares else 0),
if (st_first) |d| d.format(&d1_buf)[0..7] else "?",
if (st_last) |d| d.format(&d2_buf)[0..7] else "?",
});
try reset(out, color);
}
if (lt_lots > 0) {
var avg_buf2: [24]u8 = undefined;
var d1_buf2: [10]u8 = undefined;
var d2_buf2: [10]u8 = undefined;
try setFg(out, color, CLR_MUTED);
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
lt_lots,
lt_shares,
fmt.fmtMoney2(&avg_buf2, if (lt_shares > 0) lt_cost / lt_shares else 0),
if (lt_first) |d| d.format(&d1_buf2)[0..7] else "?",
if (lt_last) |d| d.format(&d2_buf2)[0..7] else "?",
});
try reset(out, color);
}
}
}
}
// Totals line
try setFg(out, color, CLR_MUTED);
try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
"", "", "", "", "", "", "",
});
try reset(out, color);
{
var total_mv_buf: [24]u8 = undefined;
var total_gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl;
try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{
"", "", "", "TOTAL", fmt.fmtMoney(&total_mv_buf, summary.total_value),
});
try setGainLoss(out, color, summary.unrealized_pnl);
if (summary.unrealized_pnl >= 0) {
try out.print("+{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
} else {
try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
}
try reset(out, color);
try out.print(" {s:>7}\n", .{"100.0%"});
}
if (summary.realized_pnl != 0) {
var rpl_buf: [24]u8 = undefined;
const rpl_abs = if (summary.realized_pnl >= 0) summary.realized_pnl else -summary.realized_pnl;
try setGainLoss(out, color, summary.realized_pnl);
if (summary.realized_pnl >= 0) {
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
} else {
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
}
try reset(out, color);
}
// Options section
if (portfolio.hasType(.option)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Options\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
});
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
"", "", "", "", "",
});
try reset(out, color);
var opt_total_cost: f64 = 0;
for (portfolio.lots) |lot| {
if (lot.lot_type != .option) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const total_cost_opt = @abs(qty) * cost_per;
opt_total_cost += total_cost_opt;
var cost_per_buf: [24]u8 = undefined;
var total_cost_buf: [24]u8 = undefined;
const acct: []const u8 = lot.account orelse "";
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
lot.symbol,
qty,
fmt.fmtMoney2(&cost_per_buf, cost_per),
fmt.fmtMoney(&total_cost_buf, total_cost_opt),
acct,
});
}
// Options total
try setFg(out, color, CLR_MUTED);
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
try reset(out, color);
var opt_total_buf: [24]u8 = undefined;
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
"", "", "TOTAL", fmt.fmtMoney(&opt_total_buf, opt_total_cost),
});
}
// CDs section
if (portfolio.hasType(.cd)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Certificates of Deposit\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
});
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
"", "", "", "", "",
});
try reset(out, color);
// Collect and sort CDs by maturity date (earliest first)
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.lot_type == .cd) {
try cd_lots.append(allocator, lot);
}
}
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
_ = ctx;
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
return ad < bd;
}
}.f);
var cd_section_total: f64 = 0;
for (cd_lots.items) |lot| {
cd_section_total += lot.shares;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
lot.symbol,
fmt.fmtMoney(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
});
}
// CD total
try setFg(out, color, CLR_MUTED);
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
try reset(out, color);
var cd_total_buf: [24]u8 = undefined;
try out.print(" {s:>12} {s:>14}\n", .{
"TOTAL", fmt.fmtMoney(&cd_total_buf, cd_section_total),
});
}
// Cash section
if (portfolio.hasType(.cash)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Cash\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
var cash_hdr_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashHeader(&cash_hdr_buf)});
var cash_sep_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
try reset(out, color);
for (portfolio.lots) |lot| {
if (lot.lot_type != .cash) continue;
const acct2: []const u8 = lot.account orelse "Unknown";
var row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
}
// Cash total
var sep_buf: [80]u8 = undefined;
try setFg(out, color, CLR_MUTED);
try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)});
try reset(out, color);
var total_buf: [80]u8 = undefined;
try setBold(out, color);
try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())});
try reset(out, color);
}
// Watchlist (from watch lots in portfolio + separate watchlist file)
{
var any_watch = false;
var watch_seen = std.StringHashMap(void).init(allocator);
defer watch_seen.deinit();
// Mark portfolio position symbols as seen
for (summary.allocations) |a| {
try watch_seen.put(a.symbol, {});
}
// Helper to render a watch symbol
const renderWatch = struct {
fn f(o: anytype, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void {
if (!any.*) {
try o.print("\n", .{});
try setBold(o, c);
try o.print(" Watchlist:\n", .{});
try reset(o, c);
any.* = true;
}
var price_str2: [16]u8 = undefined;
var ps2: []const u8 = "--";
if (s.getCachedCandles(sym)) |candles2| {
defer a2.free(candles2);
if (candles2.len > 0) {
ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close);
}
}
try o.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps2 });
}
}.f;
// Watch lots from portfolio
for (portfolio.lots) |lot| {
if (lot.lot_type == .watch) {
if (watch_seen.contains(lot.priceSymbol())) continue;
try watch_seen.put(lot.priceSymbol(), {});
try renderWatch(out, color, svc, allocator, lot.priceSymbol(), &any_watch);
}
}
// Separate watchlist file (backward compat)
if (watchlist_path) |wl_path| {
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
if (wl_data) |wd| {
defer allocator.free(wd);
var wl_lines = std.mem.splitScalar(u8, wd, '\n');
while (wl_lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
const rest = trimmed[idx + "symbol::".len ..];
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
if (sym.len > 0 and sym.len <= 10) {
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
try renderWatch(out, color, svc, allocator, sym, &any_watch);
}
}
}
}
}
}
// Risk metrics
{
var any_risk = false;
for (summary.allocations) |a| {
if (svc.getCachedCandles(a.symbol)) |candles| {
defer allocator.free(candles);
if (zfin.risk.computeRisk(candles)) |metrics| {
if (!any_risk) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Risk Metrics (from cached price data):\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{
"Symbol", "Volatility", "Sharpe", "Max DD",
});
try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{
"", "", "", "",
});
try reset(out, color);
any_risk = true;
}
try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{
a.symbol, metrics.volatility * 100.0, metrics.sharpe,
});
try setFg(out, color, CLR_RED);
try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0});
try reset(out, color);
if (metrics.drawdown_trough) |dt| {
var db: [10]u8 = undefined;
try setFg(out, color, CLR_MUTED);
try out.print(" (trough {s})", .{dt.format(&db)});
try reset(out, color);
}
try out.print("\n", .{});
}
}
}
}
try out.print("\n", .{});
try out.flush();
}
fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64) !void {
var lot_price_buf: [24]u8 = undefined;
var lot_date_buf: [10]u8 = undefined;
const date_str = lot.open_date.format(&lot_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
const acct_col: []const u8 = lot.account orelse "";
const use_price = lot.close_price orelse current_price;
const gl = lot.shares * (use_price - lot.open_price);
var lot_gl_buf: [24]u8 = undefined;
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs);
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
try setFg(out, color, CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "",
});
try reset(out, color);
try setGainLoss(out, color, gl);
try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money });
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col });
try reset(out, color);
}
fn cmdLookup(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool) !void {
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
if (!zfin.OpenFigi.isCusipLike(cusip)) {
try setFg(out, color, CLR_MUTED);
try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
try reset(out, color);
}
try stderr_print("Looking up via OpenFIGI...\n");
// Try full batch lookup for richer output
const results = zfin.OpenFigi.lookupCusips(allocator, &.{cusip}, svc.config.openfigi_key) catch {
try stderr_print("Error: OpenFIGI request failed (network error)\n");
return;
};
defer {
for (results) |r| {
if (r.ticker) |t| allocator.free(t);
if (r.name) |n| allocator.free(n);
if (r.security_type) |s| allocator.free(s);
}
allocator.free(results);
}
if (results.len == 0 or !results[0].found) {
try out.print("No result from OpenFIGI for '{s}'\n", .{cusip});
try out.flush();
return;
}
const r = results[0];
if (r.ticker) |ticker| {
try setBold(out, color);
try out.print("{s}", .{cusip});
try reset(out, color);
try out.print(" -> ", .{});
try setFg(out, color, CLR_ACCENT);
try out.print("{s}", .{ticker});
try reset(out, color);
try out.print("\n", .{});
if (r.name) |name| {
try setFg(out, color, CLR_MUTED);
try out.print(" Name: {s}\n", .{name});
try reset(out, color);
}
if (r.security_type) |st| {
try setFg(out, color, CLR_MUTED);
try out.print(" Type: {s}\n", .{st});
try reset(out, color);
}
try out.print("\n To use in portfolio: ticker::{s}\n", .{ticker});
// Also cache it
svc.cacheCusipTicker(cusip, ticker);
} else {
try out.print("No ticker found for CUSIP '{s}'\n", .{cusip});
if (r.name) |name| {
try setFg(out, color, CLR_MUTED);
try out.print(" Name: {s}\n", .{name});
try reset(out, color);
}
try out.print("\n Tip: For mutual funds, OpenFIGI often has no coverage.\n", .{});
try out.print(" Add manually: symbol::{s},ticker::XXXX,...\n", .{cusip});
}
try out.flush();
}
fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void {
if (std.mem.eql(u8, subcommand, "stats")) {
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.print("Cache directory: {s}\n", .{config.cache_dir});
std.fs.cwd().access(config.cache_dir, .{}) catch {
try out.print(" (empty -- no cached data)\n", .{});
try out.flush();
return;
};
var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch {
try out.print(" (empty -- no cached data)\n", .{});
try out.flush();
return;
};
defer dir.close();
var count: usize = 0;
var iter = dir.iterate();
while (iter.next() catch null) |entry| {
if (entry.kind == .directory) {
try out.print(" {s}/\n", .{entry.name});
count += 1;
}
}
if (count == 0) {
try out.print(" (empty -- no cached data)\n", .{});
} else {
try out.print("\n {d} symbol(s) cached\n", .{count});
}
try out.flush();
} else if (std.mem.eql(u8, subcommand, "clear")) {
var store = zfin.cache.Store.init(allocator, config.cache_dir);
try store.clearAll();
try stdout_print("Cache cleared.\n");
} else {
try stderr_print("Unknown cache subcommand. Use 'stats' or 'clear'.\n");
}
}
// ── Output helpers ───────────────────────────────────────────
fn stdout_print(msg: []const u8) !void {
var buf: [4096]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.writeAll(msg);
try out.flush();
}
/// Print progress line to stderr: " [N/M] SYMBOL (status)"
fn stderrProgress(symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void {
var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface;
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try out.print(" [{d}/{d}] ", .{ current, total });
if (color) try fmt.ansiReset(out);
try out.print("{s}", .{symbol});
if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]);
try out.print("{s}\n", .{status});
if (color) try fmt.ansiReset(out);
try out.flush();
}
/// Print rate-limit wait message to stderr
fn stderrRateLimitWait(wait_seconds: u64, color: bool) !void {
var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface;
if (color) try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]);
if (wait_seconds >= 60) {
const mins = wait_seconds / 60;
const secs = wait_seconds % 60;
if (secs > 0) {
try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs });
} else {
try out.print(" (rate limit -- waiting {d}m)\n", .{mins});
}
} else {
try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds});
}
if (color) try fmt.ansiReset(out);
try out.flush();
}
fn stderr_print(msg: []const u8) !void {
var buf: [1024]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface;
try out.writeAll(msg);
try out.flush();
}