zfin/src/cli/main.zig

2336 lines
90 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 (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ 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)
\\
;
// ── 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")) {
// 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 cmdPortfolio(allocator, config, &svc, file_path, 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 if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) return try stderr_print("Error: 'enrich' requires a portfolio file path\n");
try cmdEnrich(allocator, config, args[2]);
} 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 cmdAnalysis(allocator, config, &svc, analysis_file, color);
} 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);
// Historical portfolio value snapshots
{
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| allocator.free(v.*);
candle_map.deinit();
}
const stock_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(stock_syms);
for (stock_syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| {
try candle_map.put(sym, cs);
}
}
if (candle_map.count() > 0) {
const snapshots = zfin.risk.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
try out.print(" Historical: ", .{});
try setFg(out, color, CLR_MUTED);
for (zfin.risk.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
if (snap.position_count == 0) {
try out.print(" {s}: --", .{period.label()});
} else {
const pct = snap.changePct();
try setGainLoss(out, color, pct);
if (pct >= 0) {
try out.print(" {s}: +{d:.1}%", .{ period.label(), pct });
} else {
try out.print(" {s}: {d:.1}%", .{ period.label(), pct });
}
}
if (pi < zfin.risk.HistoricalPeriod.all.len - 1) try out.print(" ", .{});
}
try reset(out, color);
try out.print("\n", .{});
}
}
// 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);
}
// Illiquid assets section
if (portfolio.hasType(.illiquid)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Illiquid Assets\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
var il_hdr_buf: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidHeader(&il_hdr_buf)});
var il_sep_buf1: [80]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
try reset(out, color);
for (portfolio.lots) |lot| {
if (lot.lot_type != .illiquid) continue;
var il_row_buf: [160]u8 = undefined;
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.symbol, lot.shares, lot.note)});
}
// Illiquid total
var il_sep_buf2: [80]u8 = undefined;
try setFg(out, color, CLR_MUTED);
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)});
try reset(out, color);
var il_total_buf: [80]u8 = undefined;
try setBold(out, color);
try out.print("{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid())});
try reset(out, color);
}
// Net Worth (if illiquid assets exist)
if (portfolio.hasType(.illiquid)) {
const illiquid_total = portfolio.totalIlliquid();
const net_worth = summary.total_value + illiquid_total;
var nw_buf: [24]u8 = undefined;
var liq_buf: [24]u8 = undefined;
var il_buf: [24]u8 = undefined;
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{
fmt.fmtMoney(&nw_buf, net_worth),
fmt.fmtMoney(&liq_buf, summary.total_value),
fmt.fmtMoney(&il_buf, illiquid_total),
});
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");
}
}
/// CLI `analysis` command: show portfolio analysis breakdowns.
fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool) !void {
_ = config;
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try stderr_print("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try stderr_print("Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
const positions = try portfolio.positions(allocator);
defer allocator.free(positions);
// Build prices map from cache
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
var manual_price_set = std.StringHashMap(void).init(allocator);
defer manual_price_set.deinit();
// First pass: try cached candle prices + manual prices from lots
for (positions) |pos| {
if (pos.shares <= 0) continue;
// Try cached candles (latest close)
if (svc.getCachedCandles(pos.symbol)) |cs| {
defer allocator.free(cs);
if (cs.len > 0) {
try prices.put(pos.symbol, cs[cs.len - 1].close);
continue;
}
}
// Try manual price from lots
for (portfolio.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), pos.symbol)) {
if (lot.price) |mp| {
try prices.put(pos.symbol, mp);
try manual_price_set.put(pos.symbol, {});
break;
}
}
}
}
// Fallback to avg_cost
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);
// Include non-stock assets in grand total (same as portfolio command)
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;
// Load classification metadata
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
defer allocator.free(meta_path);
const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch {
try stderr_print("Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
return;
};
defer allocator.free(meta_data);
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
try stderr_print("Error: Cannot parse metadata.srf\n");
return;
};
defer cm.deinit();
// Load account tax type metadata (optional)
const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return;
defer allocator.free(acct_path);
var acct_map_opt: ?zfin.analysis.AccountMap = null;
const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch null;
if (acct_data) |ad| {
defer allocator.free(ad);
acct_map_opt = zfin.analysis.parseAccountsFile(allocator, ad) catch null;
}
defer if (acct_map_opt) |*am| am.deinit();
var result = zfin.analysis.analyzePortfolio(
allocator,
summary.allocations,
cm,
portfolio,
summary.total_value,
acct_map_opt,
) catch {
try stderr_print("Error computing analysis.\n");
return;
};
defer result.deinit(allocator);
// Output
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
const label_width: usize = 24;
const bar_width: usize = 30;
try setBold(out, color);
try out.print("\nPortfolio Analysis ({s})\n", .{file_path});
try reset(out, color);
try out.print("========================================\n\n", .{});
// Asset Class
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" Asset Class\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.asset_class, label_width, bar_width, color);
// Sector
if (result.sector.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" Sector (Equities)\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.sector, label_width, bar_width, color);
}
// Geographic
if (result.geo.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" Geographic\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.geo, label_width, bar_width, color);
}
// By Account
if (result.account.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" By Account\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.account, label_width, bar_width, color);
}
// Tax Type
if (result.tax_type.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" By Tax Type\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.tax_type, label_width, bar_width, color);
}
// Unclassified
if (result.unclassified.len > 0) {
try out.print("\n", .{});
try setFg(out, color, CLR_YELLOW);
try out.print(" Unclassified (not in metadata.srf)\n", .{});
try reset(out, color);
for (result.unclassified) |sym| {
try setFg(out, color, CLR_MUTED);
try out.print(" {s}\n", .{sym});
try reset(out, color);
}
}
try out.print("\n", .{});
try out.flush();
}
/// Print a breakdown section with block-element bar charts to the CLI output.
fn printBreakdownSection(out: anytype, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void {
// Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8)
const full_block = "\xE2\x96\x88";
// partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8
const partial_blocks = [7][]const u8{
"\xE2\x96\x89", // 7/8
"\xE2\x96\x8A", // 3/4
"\xE2\x96\x8B", // 5/8
"\xE2\x96\x8C", // 1/2
"\xE2\x96\x8D", // 3/8
"\xE2\x96\x8E", // 1/4
"\xE2\x96\x8F", // 1/8
};
for (items) |item| {
var val_buf: [24]u8 = undefined;
const pct = item.weight * 100.0;
// Compute filled eighths
const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0;
const filled_eighths_f = item.weight * total_eighths;
const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths));
const full_count = filled_eighths / 8;
const partial = filled_eighths % 8;
// Padded label
const lbl_len = @min(item.label.len, label_width);
try out.print(" ", .{});
try out.writeAll(item.label[0..lbl_len]);
if (lbl_len < label_width) {
for (0..label_width - lbl_len) |_| try out.writeAll(" ");
}
try out.writeAll(" ");
if (color) try fmt.ansiSetFg(out, CLR_ACCENT[0], CLR_ACCENT[1], CLR_ACCENT[2]);
for (0..full_count) |_| try out.writeAll(full_block);
if (partial > 0) try out.writeAll(partial_blocks[8 - partial - 1]);
const used = full_count + @as(usize, if (partial > 0) 1 else 0);
if (used < bar_width) {
for (0..bar_width - used) |_| try out.writeAll(" ");
}
if (color) try fmt.ansiReset(out);
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) });
}
}
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
/// and outputs a metadata SRF file to stdout.
fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, file_path: []const u8) !void {
const AV = @import("zfin").AlphaVantage;
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try stderr_print("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try stderr_print("Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Get unique stock symbols (using display-oriented names)
const positions = try portfolio.positions(allocator);
defer allocator.free(positions);
// Get unique price symbols (raw API symbols)
const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
// Check for Alpha Vantage API key
const av_key = config.alphavantage_key orelse {
try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return;
};
var av = AV.init(allocator, av_key);
defer av.deinit();
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.print("#!srfv1\n", .{});
try out.print("# Portfolio classification metadata\n", .{});
try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{});
try out.print("# Edit as needed: sector, geo, asset_class, pct:num:N\n", .{});
try out.print("#\n", .{});
try out.print("# For ETFs/funds with multi-class exposure, add multiple lines\n", .{});
try out.print("# with pct:num: values that sum to ~100\n\n", .{});
var success: usize = 0;
var skipped: usize = 0;
var failed: usize = 0;
for (syms, 0..) |sym, i| {
// Skip CUSIPs and known non-stock symbols
const OpenFigi = @import("zfin").OpenFigi;
if (OpenFigi.isCusipLike(sym)) {
// Find the display name for this CUSIP
const display: []const u8 = sym;
var note: ?[]const u8 = null;
for (positions) |pos| {
if (std.mem.eql(u8, pos.symbol, sym)) {
if (pos.note) |n| {
note = n;
}
break;
}
}
try out.print("# CUSIP {s}", .{sym});
if (note) |n| try out.print(" ({s})", .{n});
try out.print(" -- fill in manually\n", .{});
try out.print("# symbol::{s},asset_class::TODO,geo::TODO\n\n", .{display});
skipped += 1;
continue;
}
// Progress to stderr
{
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n";
try stderr_print(msg);
}
const overview = av.fetchCompanyOverview(allocator, sym) catch {
try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n\n", .{sym});
failed += 1;
continue;
};
// Free allocated strings from overview when done
defer {
if (overview.name) |n| allocator.free(n);
if (overview.sector) |s| allocator.free(s);
if (overview.industry) |ind| allocator.free(ind);
if (overview.country) |c| allocator.free(c);
if (overview.market_cap) |mc| allocator.free(mc);
if (overview.asset_type) |at| allocator.free(at);
}
const sector_str = overview.sector orelse "Unknown";
const country_str = overview.country orelse "US";
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
// Determine asset_class from asset type + market cap
const asset_class_str = blk: {
if (overview.asset_type) |at| {
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
}
// For common stocks, infer from market cap
if (overview.market_cap) |mc_str| {
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
if (mc >= 10_000_000_000) break :blk "US Large Cap";
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
break :blk "US Small Cap";
}
break :blk "US Large Cap";
};
// Comment with the name for readability
if (overview.name) |name| {
try out.print("# {s}\n", .{name});
}
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{
sym, sector_str, geo_str, asset_class_str,
});
success += 1;
}
// Summary comment
try out.print("# ---\n", .{});
try out.print("# Enriched {d} symbols ({d} success, {d} skipped, {d} failed)\n", .{
syms.len, success, skipped, failed,
});
try out.print("# Review and edit this file, then save as metadata.srf\n", .{});
try out.flush();
}
// ── 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();
}