2336 lines
90 KiB
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();
|
|
}
|