258 lines
10 KiB
Zig
258 lines
10 KiB
Zig
const std = @import("std");
|
|
const vaxis = @import("vaxis");
|
|
const zfin = @import("../root.zig");
|
|
const fmt = @import("../format.zig");
|
|
const theme_mod = @import("theme.zig");
|
|
const tui = @import("../tui.zig");
|
|
const App = tui.App;
|
|
const StyledLine = tui.StyledLine;
|
|
|
|
// ── Data loading ──────────────────────────────────────────────
|
|
|
|
pub fn loadData(app: *App) void {
|
|
app.analysis_loaded = true;
|
|
|
|
// Ensure portfolio is loaded first
|
|
if (!app.portfolio_loaded) app.loadPortfolioData();
|
|
const pf = app.portfolio orelse return;
|
|
const summary = app.portfolio_summary orelse return;
|
|
|
|
// Load classification metadata file
|
|
if (app.classification_map == null) {
|
|
// Look for metadata.srf next to the portfolio file
|
|
if (app.portfolio_path) |ppath| {
|
|
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
|
defer app.allocator.free(meta_path);
|
|
|
|
const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch {
|
|
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
|
return;
|
|
};
|
|
defer app.allocator.free(file_data);
|
|
|
|
app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
|
|
app.setStatus("Error parsing metadata.srf");
|
|
return;
|
|
};
|
|
}
|
|
}
|
|
|
|
// Load account tax type metadata file (optional)
|
|
if (app.account_map == null) {
|
|
if (app.portfolio_path) |ppath| {
|
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
|
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
|
|
loadDataFinish(app, pf, summary);
|
|
return;
|
|
};
|
|
defer app.allocator.free(acct_path);
|
|
|
|
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
|
defer app.allocator.free(acct_data);
|
|
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
|
|
} else |_| {
|
|
// accounts.srf is optional -- analysis works without it
|
|
}
|
|
}
|
|
}
|
|
|
|
loadDataFinish(app, pf, summary);
|
|
}
|
|
|
|
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
|
const cm = app.classification_map orelse {
|
|
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
|
return;
|
|
};
|
|
|
|
// Free previous result
|
|
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
|
|
|
app.analysis_result = zfin.analysis.analyzePortfolio(
|
|
app.allocator,
|
|
summary.allocations,
|
|
cm,
|
|
pf,
|
|
summary.total_value,
|
|
app.account_map,
|
|
) catch {
|
|
app.setStatus("Error computing analysis");
|
|
return;
|
|
};
|
|
}
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────
|
|
|
|
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|
return renderAnalysisLines(arena, app.theme, app.analysis_result);
|
|
}
|
|
|
|
/// Render analysis tab content. Pure function — no App dependency.
|
|
pub fn renderAnalysisLines(
|
|
arena: std.mem.Allocator,
|
|
th: theme_mod.Theme,
|
|
analysis_result: ?zfin.analysis.AnalysisResult,
|
|
) ![]const StyledLine {
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
const result = analysis_result orelse {
|
|
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
|
|
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
|
|
return lines.toOwnedSlice(arena);
|
|
};
|
|
|
|
const bar_width: usize = 30;
|
|
const label_width: usize = 24;
|
|
|
|
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
|
|
.{ .items = result.asset_class, .title = " Asset Class" },
|
|
.{ .items = result.sector, .title = " Sector (Equities)" },
|
|
.{ .items = result.geo, .title = " Geographic" },
|
|
.{ .items = result.account, .title = " By Account" },
|
|
.{ .items = result.tax_type, .title = " By Tax Type" },
|
|
};
|
|
|
|
for (sections, 0..) |sec, si| {
|
|
if (si > 0 and sec.items.len == 0) continue;
|
|
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() });
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
for (sec.items) |item| {
|
|
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
|
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
|
|
}
|
|
}
|
|
|
|
if (result.unclassified.len > 0) {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
|
|
for (result.unclassified) |sym| {
|
|
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
|
|
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
|
|
}
|
|
}
|
|
|
|
return lines.toOwnedSlice(arena);
|
|
}
|
|
|
|
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
|
|
var val_buf: [24]u8 = undefined;
|
|
const pct = item.weight * 100.0;
|
|
const bar = try buildBlockBar(arena, item.weight, bar_width);
|
|
// Build label padded to label_width
|
|
const lbl = item.label;
|
|
const lbl_len = @min(lbl.len, label_width);
|
|
const padded_label = try arena.alloc(u8, label_width);
|
|
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
|
|
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
|
|
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
|
|
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
|
|
});
|
|
}
|
|
|
|
/// Build a bar using Unicode block elements for sub-character precision.
|
|
/// Wraps fmt.buildBlockBar into arena-allocated memory.
|
|
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
|
|
var buf: [256]u8 = undefined;
|
|
const result = fmt.buildBlockBar(&buf, weight, total_chars);
|
|
return arena.dupe(u8, result);
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
const testing = std.testing;
|
|
|
|
test "buildBlockBar empty" {
|
|
const bar = try buildBlockBar(testing.allocator, 0, 10);
|
|
defer testing.allocator.free(bar);
|
|
// All spaces
|
|
try testing.expectEqual(@as(usize, 10), bar.len);
|
|
try testing.expectEqualStrings(" ", bar);
|
|
}
|
|
|
|
test "buildBlockBar full" {
|
|
const bar = try buildBlockBar(testing.allocator, 1.0, 5);
|
|
defer testing.allocator.free(bar);
|
|
// 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88)
|
|
try testing.expectEqual(@as(usize, 15), bar.len);
|
|
// Verify first block is █
|
|
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
|
|
}
|
|
|
|
test "buildBlockBar partial" {
|
|
const bar = try buildBlockBar(testing.allocator, 0.5, 10);
|
|
defer testing.allocator.free(bar);
|
|
// 50% of 10 chars = 5 full blocks (no partial)
|
|
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
|
|
try testing.expectEqual(@as(usize, 20), bar.len);
|
|
}
|
|
|
|
test "fmtBreakdownLine formats correctly" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const item = zfin.analysis.BreakdownItem{
|
|
.label = "US Stock",
|
|
.weight = 0.65,
|
|
.value = 130000,
|
|
};
|
|
const line = try fmtBreakdownLine(arena, item, 10, 12);
|
|
// Should contain the label, percentage, and dollar amount
|
|
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
|
|
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
|
|
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
|
|
}
|
|
|
|
test "renderAnalysisLines with data" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme_mod.default_theme;
|
|
|
|
var asset_class = [_]zfin.analysis.BreakdownItem{
|
|
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
|
|
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
|
|
};
|
|
const result = zfin.analysis.AnalysisResult{
|
|
.asset_class = &asset_class,
|
|
.sector = &.{},
|
|
.geo = &.{},
|
|
.account = &.{},
|
|
.tax_type = &.{},
|
|
.unclassified = &.{},
|
|
.total_value = 200000,
|
|
};
|
|
const lines = try renderAnalysisLines(arena, th, result);
|
|
// Should have header section + asset class items
|
|
try testing.expect(lines.len >= 5);
|
|
// Find "Portfolio Analysis" header
|
|
var found_header = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
|
|
}
|
|
try testing.expect(found_header);
|
|
// Find asset class data
|
|
var found_us = false;
|
|
for (lines) |l| {
|
|
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
|
|
}
|
|
try testing.expect(found_us);
|
|
}
|
|
|
|
test "renderAnalysisLines no data" {
|
|
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
const th = theme_mod.default_theme;
|
|
|
|
const lines = try renderAnalysisLines(arena, th, null);
|
|
try testing.expectEqual(@as(usize, 5), lines.len);
|
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
|
}
|