zfin/src/commands/analysis.zig

287 lines
11 KiB
Zig

const std = @import("std");
const zfin = @import("../root.zig");
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();
// First pass: try cached candle prices
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);
}
}
}
// Build fallback prices for symbols without cached candle data
var manual_price_set = try zfin.risk.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
defer manual_price_set.deinit();
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)
summary.adjustForNonStockAssets(portfolio);
// 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 = fmt.analysis_label_width;
const bar_width = fmt.analysis_bar_width;
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 {
for (items) |item| {
var val_buf: [24]u8 = undefined;
const pct = item.weight * 100.0;
// Build bar using shared function
var bar_buf: [256]u8 = undefined;
const bar = fmt.buildBlockBar(&bar_buf, item.weight, bar_width);
// 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]);
try out.writeAll(bar);
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);
}