ai: refactor cli and add unit tests

This commit is contained in:
Emil Lerch 2026-02-27 13:04:30 -08:00
parent 181c164394
commit 19a1403d40
Signed by: lobo
GPG key ID: A7B62D657EF764F8
15 changed files with 2904 additions and 2188 deletions

View file

@ -0,0 +1,328 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
/// CLI `analysis` command: show portfolio analysis breakdowns.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
_ = config;
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try cli.stderrPrint("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;
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;
}
}
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 cli.stderrPrint("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 cli.stderrPrint("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 cli.stderrPrint("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 cli.stderrPrint("Error computing analysis.\n");
return;
};
defer result.deinit(allocator);
try display(result, file_path, color, out);
}
pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
const label_width: usize = 24;
const bar_width: usize = 30;
try cli.setBold(out, color);
try out.print("\nPortfolio Analysis ({s})\n", .{file_path});
try cli.reset(out, color);
try out.print("========================================\n\n", .{});
// Asset Class
try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Asset Class\n", .{});
try cli.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 cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Sector (Equities)\n", .{});
try cli.reset(out, color);
try printBreakdownSection(out, result.sector, label_width, bar_width, color);
}
// Geographic
if (result.geo.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Geographic\n", .{});
try cli.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 cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" By Account\n", .{});
try cli.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 cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" By Tax Type\n", .{});
try cli.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 cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" Unclassified (not in metadata.srf)\n", .{});
try cli.reset(out, color);
for (result.unclassified) |sym| {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s}\n", .{sym});
try cli.reset(out, color);
}
}
try out.print("\n", .{});
}
/// Print a breakdown section with block-element bar charts to the CLI output.
pub fn printBreakdownSection(out: *std.Io.Writer, 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, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.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) });
}
}
// Tests
test "printBreakdownSection single item no color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const items = [_]zfin.analysis.BreakdownItem{
.{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 },
};
try printBreakdownSection(&w, &items, 24, 30, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "60.0%") != null);
// No ANSI when color=false
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "printBreakdownSection multiple items" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const items = [_]zfin.analysis.BreakdownItem{
.{ .label = "Stocks", .weight = 0.70, .value = 70000.0 },
.{ .label = "Bonds", .weight = 0.20, .value = 20000.0 },
.{ .label = "Cash", .weight = 0.10, .value = 10000.0 },
};
try printBreakdownSection(&w, &items, 24, 30, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Stocks") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Bonds") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Cash") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "70.0%") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "10.0%") != null);
}
test "printBreakdownSection zero weight" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const items = [_]zfin.analysis.BreakdownItem{
.{ .label = "Empty", .weight = 0.0, .value = 0.0 },
};
try printBreakdownSection(&w, &items, 24, 30, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "0.0%") != null);
}
test "printBreakdownSection with color emits ANSI" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const items = [_]zfin.analysis.BreakdownItem{
.{ .label = "Test", .weight = 0.50, .value = 50000.0 },
};
try printBreakdownSection(&w, &items, 24, 30, true);
const out = w.buffered();
// Should contain ANSI escape for bar color
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
}
test "display shows all sections" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const asset_class = [_]zfin.analysis.BreakdownItem{
.{ .label = "US Large Cap", .weight = 0.60, .value = 60000.0 },
.{ .label = "International", .weight = 0.40, .value = 40000.0 },
};
const sector = [_]zfin.analysis.BreakdownItem{
.{ .label = "Technology", .weight = 0.35, .value = 35000.0 },
};
const geo = [_]zfin.analysis.BreakdownItem{
.{ .label = "US", .weight = 0.80, .value = 80000.0 },
};
const empty = [_]zfin.analysis.BreakdownItem{};
const unclassified = [_][]const u8{"WEIRD"};
const result: zfin.analysis.AnalysisResult = .{
.asset_class = @constCast(&asset_class),
.sector = @constCast(&sector),
.geo = @constCast(&geo),
.account = @constCast(&empty),
.tax_type = @constCast(&empty),
.unclassified = @constCast(&unclassified),
.total_value = 100000.0,
};
try display(result, "test.srf", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Portfolio Analysis") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Asset Class") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "US Large Cap") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sector") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Technology") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Geographic") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Unclassified") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "WEIRD") != null);
// No ANSI when color=false
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

View file

@ -0,0 +1,37 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void {
if (std.mem.eql(u8, subcommand, "stats")) {
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", .{});
return;
};
var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch {
try out.print(" (empty -- no cached data)\n", .{});
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});
}
} else if (std.mem.eql(u8, subcommand, "clear")) {
var store = zfin.cache.Store.init(allocator, config.cache_dir);
try store.clearAll();
try out.writeAll("Cache cleared.\n");
} else {
try cli.stderrPrint("Unknown cache subcommand. Use 'stats' or 'clear'.\n");
}
}

147
src/cli/commands/divs.zig Normal file
View file

@ -0,0 +1,147 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getDividends(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try cli.stderrPrint("Error fetching dividend data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try cli.stderrPrint("(using cached dividend data)\n");
// 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 |_| {}
}
try display(result.data, symbol, current_price, color, out);
}
pub fn display(dividends: []const zfin.Dividend, symbol: []const u8, current_price: ?f64, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nDividend History for {s}\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
if (dividends.len == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" No dividends found.\n\n", .{});
try cli.reset(out, color);
return;
}
try cli.setFg(out, color, cli.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 cli.reset(out, color);
const today = fmt.todayDate();
const one_year_ago = today.subtractYears(1);
var total: f64 = 0;
var ttm: f64 = 0;
for (dividends) |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", .{ dividends.len, total });
try cli.setFg(out, color, cli.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 cli.reset(out, color);
try out.print("\n\n", .{});
}
// Tests
test "display shows dividend data with yield" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{
.{ .ex_date = .{ .days = 20000 }, .amount = 0.88, .distribution_type = .regular },
.{ .ex_date = .{ .days = 19900 }, .amount = 0.88, .distribution_type = .regular },
};
try display(&divs, "VTI", 250.0, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "0.8800") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "2 dividends") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "TTM") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "yield") != null);
}
test "display shows empty message" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{};
try display(&divs, "BRK.A", null, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "No dividends found") != null);
}
test "display without price omits yield" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{
.{ .ex_date = .{ .days = 20000 }, .amount = 1.50, .distribution_type = .regular },
};
try display(&divs, "T", null, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "yield") == null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 dividends") != null);
}
test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const divs = [_]zfin.Dividend{
.{ .ex_date = .{ .days = 20000 }, .amount = 0.50, .distribution_type = .regular },
};
try display(&divs, "SPY", 500.0, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

View file

@ -0,0 +1,144 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEarnings(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n");
return;
},
else => {
try cli.stderrPrint("Error fetching earnings data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try cli.stderrPrint("(using cached earnings data)\n");
try display(result.data, symbol, color, out);
}
pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nEarnings History for {s}\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
if (events.len == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" No earnings data found.\n\n", .{});
try cli.reset(out, color);
return;
}
try cli.setFg(out, color, cli.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 cli.reset(out, color);
for (events) |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 cli.setFg(out, color, cli.CLR_MUTED);
} else if (surprise_positive) {
try cli.setFg(out, color, cli.CLR_POSITIVE);
} else {
try cli.setFg(out, color, cli.CLR_NEGATIVE);
}
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 cli.reset(out, color);
try out.print("\n", .{});
}
try out.print("\n{d} earnings event(s)\n\n", .{events.len});
}
pub fn fmtEps(val: f64) [12]u8 {
var buf: [12]u8 = .{' '} ** 12;
_ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {};
return buf;
}
// Tests
test "fmtEps formats positive value" {
const result = fmtEps(1.25);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expectEqualStrings("$1.25", trimmed);
}
test "fmtEps formats negative value" {
const result = fmtEps(-0.50);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expect(std.mem.indexOf(u8, trimmed, "0.5") != null);
}
test "fmtEps formats zero" {
const result = fmtEps(0.0);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expectEqualStrings("$0.00", trimmed);
}
test "display shows earnings with beat" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const events = [_]zfin.EarningsEvent{
.{ .symbol = "AAPL", .date = .{ .days = 19000 }, .quarter = 4, .estimate = 1.50, .actual = 1.65, .surprise = 0.15, .surprise_percent = 10.0, .report_time = .amc },
};
try display(&events, "AAPL", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Q4") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1.50") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "$1.65") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 earnings event(s)") != null);
}
test "display shows empty message" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const events = [_]zfin.EarningsEvent{};
try display(&events, "XYZ", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "No earnings data found") != null);
}
test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const events = [_]zfin.EarningsEvent{
.{ .symbol = "MSFT", .date = .{ .days = 19000 }, .report_time = .bmo },
};
try display(&events, "MSFT", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

205
src/cli/commands/enrich.zig Normal file
View file

@ -0,0 +1,205 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
/// 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.
/// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8, out: *std.Io.Writer) !void {
// Check for Alpha Vantage API key
const av_key = config.alphavantage_key orelse {
try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return;
};
// Determine if arg is a symbol or a file path
const is_file = std.mem.endsWith(u8, arg, ".srf") or
std.mem.indexOfScalar(u8, arg, '/') != null or
std.mem.indexOfScalar(u8, arg, '.') != null;
if (!is_file) {
// Single symbol mode: enrich one symbol, output appendable SRF (no header)
try enrichSymbol(allocator, av_key, arg, out);
return;
}
// Portfolio file mode: enrich all symbols
try enrichPortfolio(allocator, av_key, arg, out);
}
/// Enrich a single symbol and output appendable SRF lines to stdout.
fn enrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u8, out: *std.Io.Writer) !void {
const AV = zfin.AlphaVantage;
var av = AV.init(allocator, av_key);
defer av.deinit();
{
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n";
try cli.stderrPrint(msg);
}
const overview = av.fetchCompanyOverview(allocator, sym) catch {
try cli.stderrPrint("Error: Failed to fetch data for symbol\n");
try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym});
return;
};
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;
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";
}
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";
};
if (overview.name) |name| {
try out.print("# {s}\n", .{name});
}
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{
sym, sector_str, geo_str, asset_class_str,
});
}
/// Enrich all symbols from a portfolio file.
fn enrichPortfolio(allocator: std.mem.Allocator, av_key: []const u8, file_path: []const u8, out: *std.Io.Writer) !void {
const AV = zfin.AlphaVantage;
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try cli.stderrPrint("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);
var av = AV.init(allocator, av_key);
defer av.deinit();
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
if (zfin.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 cli.stderrPrint(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", .{});
}

143
src/cli/commands/etf.zig Normal file
View file

@ -0,0 +1,143 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getEtfProfile(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n");
return;
},
else => {
try cli.stderrPrint("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 cli.stderrPrint("(using cached ETF profile)\n");
try printProfile(profile, symbol, color, out);
}
pub fn printProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nETF Profile: {s}\n", .{symbol});
try cli.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 cli.setFg(out, color, cli.CLR_NEGATIVE);
try out.print(" Leveraged: YES\n", .{});
try cli.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 cli.setBold(out, color);
try out.print("\n Sector Allocation:\n", .{});
try cli.reset(out, color);
for (sectors) |sec| {
try cli.setFg(out, color, cli.CLR_ACCENT);
try out.print(" {d:>5.1}%", .{sec.weight * 100.0});
try cli.reset(out, color);
try out.print(" {s}\n", .{sec.sector});
}
}
}
// Top holdings
if (profile.holdings) |holdings| {
if (holdings.len > 0) {
try cli.setBold(out, color);
try out.print("\n Top Holdings:\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.reset(out, color);
for (holdings) |h| {
if (h.symbol) |s| {
try cli.setFg(out, color, cli.CLR_ACCENT);
try out.print(" {s:>6}", .{s});
try cli.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", .{});
}
// Tests
test "printProfile minimal ETF no color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const profile: zfin.EtfProfile = .{
.symbol = "VTI",
.expense_ratio = 0.0003,
.dividend_yield = 0.015,
.total_holdings = 3500,
};
try printProfile(profile, "VTI", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Expense Ratio") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "0.03%") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "3500") != null);
// No ANSI when color=false
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "printProfile leveraged ETF shows warning" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const profile: zfin.EtfProfile = .{
.symbol = "TQQQ",
.expense_ratio = 0.0095,
.leveraged = true,
};
try printProfile(profile, "TQQQ", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Leveraged") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "YES") != null);
}

View file

@ -0,0 +1,85 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n");
return;
},
else => {
try cli.stderrPrint("Error fetching data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try cli.stderrPrint("(using cached data)\n");
const all = result.data;
if (all.len == 0) return try cli.stderrPrint("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 cli.stderrPrint("No data available.\n");
try display(c, symbol, color, out);
}
pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
try cli.setFg(out, color, cli.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 cli.reset(out, color);
for (candles) |candle| {
var db: [10]u8 = undefined;
var vb: [32]u8 = undefined;
try cli.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 cli.reset(out, color);
}
try out.print("\n{d} trading days\n\n", .{candles.len});
}
// Tests
test "display shows header and candle data" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_500_000 },
.{ .date = .{ .days = 20001 }, .open = 103.0, .high = 107.0, .low = 102.0, .close = 101.0, .adj_close = 101.0, .volume = 2_000_000 },
};
try display(&candles, "AAPL", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Date") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Open") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days") != null);
// No ANSI when color=false
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display empty candles" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{};
try display(&candles, "XYZ", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "XYZ") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null);
}

124
src/cli/commands/lookup.zig Normal file
View file

@ -0,0 +1,124 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (!zfin.OpenFigi.isCusipLike(cusip)) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
try cli.reset(out, color);
}
try cli.stderrPrint("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 cli.stderrPrint("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});
return;
}
try display(results[0], cusip, color, out);
// Also cache it
if (results[0].ticker) |ticker| {
svc.cacheCusipTicker(cusip, ticker);
}
}
pub fn display(result: zfin.OpenFigi.FigiResult, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (result.ticker) |ticker| {
try cli.setBold(out, color);
try out.print("{s}", .{cusip});
try cli.reset(out, color);
try out.print(" -> ", .{});
try cli.setFg(out, color, cli.CLR_ACCENT);
try out.print("{s}", .{ticker});
try cli.reset(out, color);
try out.print("\n", .{});
if (result.name) |name| {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Name: {s}\n", .{name});
try cli.reset(out, color);
}
if (result.security_type) |st| {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Type: {s}\n", .{st});
try cli.reset(out, color);
}
try out.print("\n To use in portfolio: ticker::{s}\n", .{ticker});
} else {
try out.print("No ticker found for CUSIP '{s}'\n", .{cusip});
if (result.name) |name| {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Name: {s}\n", .{name});
try cli.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});
}
}
// Tests
test "display shows ticker mapping" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: zfin.OpenFigi.FigiResult = .{
.ticker = "AAPL",
.name = "Apple Inc",
.security_type = "Common Stock",
.found = true,
};
try display(result, "037833100", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "037833100") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Apple Inc") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Common Stock") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "ticker::AAPL") != null);
}
test "display shows no-ticker message" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: zfin.OpenFigi.FigiResult = .{
.ticker = null,
.name = "Some Fund",
.security_type = null,
.found = true,
};
try display(result, "123456789", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "No ticker found") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Some Fund") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "mutual funds") != null);
}
test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: zfin.OpenFigi.FigiResult = .{
.ticker = "MSFT",
.name = null,
.security_type = null,
.found = true,
};
try display(result, "594918104", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

View file

@ -0,0 +1,164 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void {
const result = svc.getOptions(symbol) catch |err| switch (err) {
zfin.DataError.FetchFailed => {
try cli.stderrPrint("Error fetching options data from CBOE.\n");
return;
},
else => {
try cli.stderrPrint("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 cli.stderrPrint("(using cached options data)\n");
if (ch.len == 0) {
try cli.stderrPrint("No options data found.\n");
return;
}
try display(out, allocator, ch, symbol, ntm, color);
}
pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void {
if (chains.len == 0) return;
try cli.setBold(out, color);
try out.print("\nOptions Chain for {s}\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
if (chains[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), chains.len, ntm });
} else {
try out.print("{d} expiration(s) available\n", .{chains.len});
}
// Find nearest monthly expiration to auto-expand
var auto_expand_idx: ?usize = null;
for (chains, 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 chains.len > 0) auto_expand_idx = 0;
const atm_price = if (chains[0].underlying_price) |p| p else @as(f64, 0);
// List all expirations, expanding the nearest monthly
for (chains, 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 cli.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 cli.reset(out, color);
try out.print("\n", .{});
// Print calls
try printSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color);
try out.print("\n", .{});
// Print puts
try printSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color);
} else {
try cli.setFg(out, color, if (is_monthly) cli.CLR_HEADER else cli.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 cli.reset(out, color);
try out.print("\n", .{});
}
}
try out.print("\n", .{});
}
pub fn printSection(
out: *std.Io.Writer,
allocator: std.mem.Allocator,
label: []const u8,
contracts: []const zfin.OptionContract,
atm_price: f64,
ntm: usize,
is_calls: bool,
color: bool,
) !void {
try cli.setBold(out, color);
try out.print(" {s}\n", .{label});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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});
}
}
// Tests
test "printSection shows header and contracts" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const calls = [_]zfin.OptionContract{
.{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 },
.{ .contract_type = .call, .strike = 155.0, .expiration = .{ .days = 20100 }, .bid = 2.0, .ask = 2.50, .last_price = 2.25 },
};
try printSection(&w, gpa.allocator(), "CALLS", &calls, 152.0, 8, true, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "CALLS") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Strike") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display shows chain header no color" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const calls = [_]zfin.OptionContract{};
const puts = [_]zfin.OptionContract{};
const chains = [_]zfin.OptionsChain{
.{ .underlying_symbol = "SPY", .underlying_price = 500.0, .expiration = .{ .days = 20100 }, .calls = &calls, .puts = &puts },
};
try display(&w, gpa.allocator(), &chains, "SPY", 8, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Options Chain for SPY") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 expiration(s)") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

214
src/cli/commands/perf.zig Normal file
View file

@ -0,0 +1,214 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getTrailingReturns(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n");
return;
},
else => {
try cli.stderrPrint("Error fetching data.\n");
return;
},
};
defer allocator.free(result.candles);
defer if (result.dividends) |d| allocator.free(d);
if (result.source == .cached) try cli.stderrPrint("(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();
try cli.setBold(out, color);
try out.print("\nTrailing Returns for {s}\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
try cli.setFg(out, color, cli.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 cli.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 cli.setBold(out, color);
try out.print("\nAs-of {s}:\n", .{end_date.format(&db)});
try cli.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 cli.setBold(out, color);
try out.print("\nMonth-end ({s}):\n", .{month_end.format(&db)});
try cli.reset(out, color);
}
try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color);
if (!has_divs) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
pub fn printReturnsTable(
out: *std.Io.Writer,
price: zfin.performance.TrailingReturns,
total: ?zfin.performance.TrailingReturns,
color: bool,
) !void {
const has_total = total != null;
try cli.setFg(out, color, cli.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 cli.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 cli.setGainLoss(out, color, val);
try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)});
try cli.reset(out, color);
} else {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>13}", .{"N/A"});
try cli.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 cli.setGainLoss(out, color, val);
try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)});
try cli.reset(out, color);
} else {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>13}", .{"N/A"});
try cli.reset(out, color);
}
}
if (period.years > 1) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ann.", .{});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
}
// Tests
test "printReturnsTable price-only with no data" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const empty: zfin.performance.TrailingReturns = .{
.one_year = null,
.three_year = null,
.five_year = null,
.ten_year = null,
};
try printReturnsTable(&w, empty, null, false);
const out = w.buffered();
// Should contain header and N/A for all periods
try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "N/A") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "10-Year") != null);
}
test "printReturnsTable price-only no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const empty: zfin.performance.TrailingReturns = .{
.one_year = null,
.three_year = null,
.five_year = null,
.ten_year = null,
};
try printReturnsTable(&w, empty, null, false);
const out = w.buffered();
// No ANSI escape sequences when color=false
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "printReturnsTable with total return columns" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const empty: zfin.performance.TrailingReturns = .{
.one_year = null,
.three_year = null,
.five_year = null,
.ten_year = null,
};
try printReturnsTable(&w, empty, empty, false);
const out = w.buffered();
// Should contain both column headers
try std.testing.expect(std.mem.indexOf(u8, out, "Price Only") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Total Return") != null);
}
test "printReturnsTable with actual returns" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const returns: zfin.performance.TrailingReturns = .{
.one_year = .{ .total_return = 0.15, .annualized_return = null, .from = .{ .days = 0 }, .to = .{ .days = 365 } },
.three_year = null,
.five_year = null,
.ten_year = null,
};
try printReturnsTable(&w, returns, null, false);
const out = w.buffered();
// 1-Year should show a value, not N/A
// Check that the line with "1-Year" does NOT have N/A right after it
// (crude check: the output should have fewer N/A occurrences than with all nulls)
try std.testing.expect(std.mem.indexOf(u8, out, "1-Year") != null);
// 3-year should still show N/A
try std.testing.expect(std.mem.indexOf(u8, out, "ann.") != null);
}

View file

@ -0,0 +1,800 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
// Load portfolio from SRF file
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
try cli.stderrPrint("Error reading portfolio file: ");
try cli.stderrPrint(@errorName(err));
try cli.stderrPrint("\n");
return;
};
defer allocator.free(data);
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
try cli.stderrPrint("Error parsing portfolio file.\n");
return;
};
defer portfolio.deinit();
if (portfolio.lots.len == 0) {
try cli.stderrPrint("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 cli.stderrPrint("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 cli.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 cli.stderrRateLimitWait(w, color);
}
}
try cli.stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color);
const result = svc.getCandles(sym) catch {
fail_count += 1;
try cli.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 cli.stderrProgress(sym, " (cached)", loaded_count, all_syms_count, color);
continue;
}
const wait_s = svc.estimateWaitSeconds();
if (wait_s) |w| {
if (w > 0) {
try cli.stderrRateLimitWait(w, color);
}
}
try cli.stderrProgress(sym, " (fetching)", loaded_count, all_syms_count, color);
const result = svc.getCandles(sym) catch {
try cli.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 cli.stderrPrint(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 cli.stderrPrint(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 cli.stderrPrint("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;
}
}
// Header with summary
try cli.setBold(out, color);
try out.print("\nPortfolio Summary ({s})\n", .{file_path});
try cli.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 cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len });
try cli.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 cli.setFg(out, color, cli.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 cli.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 cli.reset(out, color);
try out.print("\n", .{});
}
}
// Column headers
try out.print("\n", .{});
try cli.setFg(out, color, cli.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 cli.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;
}
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
});
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)});
try cli.setGainLoss(out, color, a.unrealized_pnl);
try out.print("{s}{s:>13}", .{ sign, gl_money });
if (a.is_manual_price) {
try cli.setFg(out, color, cli.CLR_WARNING);
} else {
try cli.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});
}
}
if (a.is_manual_price) try cli.reset(out, color);
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 printLotRow(out, color, lot, a.current_price);
}
} else {
// Show non-DRIP lots individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printLotRow(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 cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.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 cli.reset(out, color);
}
}
}
}
// Totals line
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{
"", "", "", "", "", "", "",
});
try cli.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 cli.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 cli.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 cli.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 cli.reset(out, color);
}
// Options section
if (portfolio.hasType(.option)) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Options\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
try cli.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 cli.setBold(out, color);
try out.print(" Certificates of Deposit\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
try cli.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 cli.setBold(out, color);
try out.print(" Cash\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s}\n", .{fmt.fmtCashSep(&sep_buf)});
try cli.reset(out, color);
var total_buf: [80]u8 = undefined;
try cli.setBold(out, color);
try out.print("{s}\n", .{fmt.fmtCashTotal(&total_buf, portfolio.totalCash())});
try cli.reset(out, color);
}
// Illiquid assets section
if (portfolio.hasType(.illiquid)) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Illiquid Assets\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf2)});
try cli.reset(out, color);
var il_total_buf: [80]u8 = undefined;
try cli.setBold(out, color);
try out.print("{s}\n", .{fmt.fmtIlliquidTotal(&il_total_buf, portfolio.totalIlliquid())});
try cli.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 cli.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 cli.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: *std.Io.Writer, c: bool, s: *zfin.DataService, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void {
if (!any.*) {
try o.print("\n", .{});
try cli.setBold(o, c);
try o.print(" Watchlist:\n", .{});
try cli.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 cli.setBold(out, color);
try out.print(" Risk Metrics (from cached price data):\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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 cli.setFg(out, color, cli.CLR_NEGATIVE);
try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0});
try cli.reset(out, color);
if (metrics.drawdown_trough) |dt| {
var db: [10]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" (trough {s})", .{dt.format(&db)});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
}
}
}
try out.print("\n", .{});
}
pub fn printLotRow(out: *std.Io.Writer, 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 cli.setFg(out, color, cli.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 cli.reset(out, color);
try cli.setGainLoss(out, color, gl);
try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money });
try cli.reset(out, color);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col });
try cli.reset(out, color);
}

203
src/cli/commands/quote.zig Normal file
View file

@ -0,0 +1,203 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
/// Quote data extracted from the real-time API (or synthesized from candles).
pub const QuoteData = struct {
price: f64,
open: f64,
high: f64,
low: f64,
volume: u64,
prev_close: f64,
date: zfin.Date,
};
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
// Fetch candle data for chart and history
const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n");
return;
},
else => {
try cli.stderrPrint("Error fetching candle data.\n");
return;
},
};
defer allocator.free(candle_result.data);
const candles = candle_result.data;
// Fetch real-time quote
var quote: ?QuoteData = null;
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();
quote = .{
.price = q.close(),
.open = q.open(),
.high = q.high(),
.low = q.low(),
.volume = q.volume(),
.prev_close = q.previous_close(),
.date = if (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate(),
};
} else |_| {}
} else |_| {}
}
try display(allocator, candles, quote, symbol, color, out);
}
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const has_quote = quote != null;
// Header
try cli.setBold(out, color);
if (quote) |q| {
var price_buf: [24]u8 = undefined;
try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q.price) });
} 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 cli.reset(out, color);
try out.print("========================================\n", .{});
// Quote details
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
const prev_close = if (quote) |q| 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 (quote) |q| q.date else if (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate();
const open_val = if (quote) |q| q.open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0);
const high_val = if (quote) |q| q.high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0);
const low_val = if (quote) |q| q.low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0);
const vol_val = if (quote) |q| 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 cli.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 cli.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, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (chart) |*ch| {
defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED);
}
}
// Recent history table (last 20 candles)
if (candles.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Recent History:\n", .{});
try cli.reset(out, color);
try cli.setFg(out, color, cli.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 cli.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 cli.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 cli.reset(out, color);
}
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
}
try out.print("\n", .{});
}
// Tests
test "display with candles only" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
};
try display(gpa.allocator(), &candles, null, "AAPL", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Recent History") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null);
}
test "display with quote data" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const candles = [_]zfin.Candle{};
const quote: QuoteData = .{
.price = 175.50,
.open = 174.00,
.high = 176.00,
.low = 173.50,
.volume = 60_000_000,
.prev_close = 172.00,
.date = .{ .days = 20001 },
};
try display(gpa.allocator(), &candles, quote, "AAPL", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
// Should NOT have "(close)" since we have a real quote
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
}
test "display no ANSI without color" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
};
try display(gpa.allocator(), &candles, null, "SPY", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

View file

@ -0,0 +1,84 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getSplits(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
return;
},
else => {
try cli.stderrPrint("Error fetching split data.\n");
return;
},
};
defer allocator.free(result.data);
if (result.source == .cached) try cli.stderrPrint("(using cached split data)\n");
try display(result.data, symbol, color, out);
}
pub fn display(splits: []const zfin.Split, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.setBold(out, color);
try out.print("\nSplit History for {s}\n", .{symbol});
try cli.reset(out, color);
try out.print("========================================\n", .{});
if (splits.len == 0) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" No splits found.\n\n", .{});
try cli.reset(out, color);
return;
}
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("{s:>12} {s:>10}\n", .{ "Date", "Ratio" });
try out.print("{s:->12} {s:->10}\n", .{ "", "" });
try cli.reset(out, color);
for (splits) |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", .{splits.len});
}
// Tests
test "display shows split data" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const splits = [_]zfin.Split{
.{ .date = .{ .days = 18000 }, .numerator = 4, .denominator = 1 },
.{ .date = .{ .days = 15000 }, .numerator = 7, .denominator = 1 },
};
try display(&splits, "AAPL", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "4:1") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "7:1") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "2 split(s)") != null);
}
test "display shows empty message" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const splits = [_]zfin.Split{};
try display(&splits, "BRK.A", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "No splits found") != null);
}
test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const splits = [_]zfin.Split{
.{ .date = .{ .days = 18000 }, .numerator = 2, .denominator = 1 },
};
try display(&splits, "GOOG", false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}

167
src/cli/common.zig Normal file
View file

@ -0,0 +1,167 @@
const std = @import("std");
const zfin = @import("zfin");
pub const fmt = zfin.format;
// Default CLI colors (match TUI default Monokai theme)
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)
pub const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; // losses (TUI .negative)
pub const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; // dim/secondary text (TUI .text_muted)
pub const CLR_HEADER = [3]u8{ 0x9d, 0x7c, 0xd8 }; // section headers (TUI .accent)
pub const CLR_ACCENT = [3]u8{ 0x89, 0xb4, 0xfa }; // info highlights, bar fills (TUI .bar_fill)
pub const CLR_WARNING = [3]u8{ 0xe5, 0xc0, 0x7b }; // stale/manual price indicator (TUI .warning)
// ANSI color helpers
pub fn setFg(out: *std.Io.Writer, c: bool, rgb: [3]u8) !void {
if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]);
}
pub fn setBold(out: *std.Io.Writer, c: bool) !void {
if (c) try fmt.ansiBold(out);
}
pub fn reset(out: *std.Io.Writer, c: bool) !void {
if (c) try fmt.ansiReset(out);
}
pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void {
if (c) {
if (value >= 0)
try fmt.ansiSetFg(out, CLR_POSITIVE[0], CLR_POSITIVE[1], CLR_POSITIVE[2])
else
try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]);
}
}
// Stderr helpers
pub fn stderrPrint(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();
}
/// Print progress line to stderr: " [N/M] SYMBOL (status)"
pub 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
pub 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_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[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();
}
// Tests
test "setFg emits ANSI when color enabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setFg(&w, true, CLR_POSITIVE);
const out = w.buffered();
// Should contain ESC[ sequence with RGB values
try std.testing.expect(out.len > 0);
try std.testing.expect(std.mem.startsWith(u8, out, "\x1b["));
}
test "setFg is no-op when color disabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setFg(&w, false, CLR_POSITIVE);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
test "setBold emits ANSI when color enabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setBold(&w, true);
const out = w.buffered();
try std.testing.expect(out.len > 0);
try std.testing.expect(std.mem.startsWith(u8, out, "\x1b["));
}
test "setBold is no-op when color disabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setBold(&w, false);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
test "reset emits ANSI when color enabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try reset(&w, true);
const out = w.buffered();
try std.testing.expect(out.len > 0);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
}
test "reset is no-op when color disabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try reset(&w, false);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
test "setGainLoss uses positive color for gains" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setGainLoss(&w, true, 10.0);
const out = w.buffered();
try std.testing.expect(out.len > 0);
// Should contain the positive green color RGB
try std.testing.expect(std.mem.indexOf(u8, out, "127") != null);
}
test "setGainLoss uses negative color for losses" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setGainLoss(&w, true, -5.0);
const out = w.buffered();
try std.testing.expect(out.len > 0);
// Should contain the negative red color RGB
try std.testing.expect(std.mem.indexOf(u8, out, "224") != null);
}
test "setGainLoss is no-op when color disabled" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setGainLoss(&w, false, 10.0);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
test "setGainLoss treats zero as positive" {
var buf: [256]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try setGainLoss(&w, true, 0.0);
const out = w.buffered();
// Should use positive (green) color for zero
try std.testing.expect(std.mem.indexOf(u8, out, "127") != null);
}

File diff suppressed because it is too large Load diff