diff --git a/src/cli/main.zig b/src/cli/main.zig index 74ea3a5..850e2b7 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -17,6 +17,7 @@ const usage = \\ earnings Show earnings history and upcoming \\ etf Show ETF profile (holdings, sectors, expense ratio) \\ portfolio Load and analyze a portfolio (.srf file) + \\ analysis Show portfolio analysis (asset class, sector, geo, account, tax type) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ cache stats Show cache statistics \\ cache clear Clear all cached data @@ -39,6 +40,10 @@ const usage = \\ -w, --watchlist Watchlist file \\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ + \\Analysis command: + \\ Reads metadata.srf (classification) and accounts.srf (tax types) + \\ from the same directory as the portfolio file. + \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) @@ -150,6 +155,12 @@ pub fn main() !void { } else if (std.mem.eql(u8, command, "cache")) { if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); try cmdCache(allocator, config, args[2]); + } else if (std.mem.eql(u8, command, "enrich")) { + if (args.len < 3) return try stderr_print("Error: 'enrich' requires a portfolio file path\n"); + try cmdEnrich(allocator, config, args[2]); + } else if (std.mem.eql(u8, command, "analysis")) { + if (args.len < 3) return try stderr_print("Error: 'analysis' requires a portfolio file path\n"); + try cmdAnalysis(allocator, config, &svc, args[2], color); } else { try stderr_print("Unknown command. Run 'zfin help' for usage.\n"); } @@ -1887,6 +1898,376 @@ fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []con } } +/// CLI `analysis` command: show portfolio analysis breakdowns. +fn cmdAnalysis(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool) !void { + _ = config; + + // Load portfolio + const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { + try stderr_print("Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(file_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { + try stderr_print("Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + // Build prices map from cache + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + var manual_price_set = std.StringHashMap(void).init(allocator); + defer manual_price_set.deinit(); + + // First pass: try cached candle prices + manual prices from lots + for (positions) |pos| { + if (pos.shares <= 0) continue; + // Try cached candles (latest close) + if (svc.getCachedCandles(pos.symbol)) |cs| { + defer allocator.free(cs); + if (cs.len > 0) { + try prices.put(pos.symbol, cs[cs.len - 1].close); + continue; + } + } + // Try manual price from lots + for (portfolio.lots) |lot| { + if (lot.lot_type == .stock and std.mem.eql(u8, lot.priceSymbol(), pos.symbol)) { + if (lot.price) |mp| { + try prices.put(pos.symbol, mp); + try manual_price_set.put(pos.symbol, {}); + break; + } + } + } + } + // Fallback to avg_cost + for (positions) |pos| { + if (!prices.contains(pos.symbol) and pos.shares > 0) { + try prices.put(pos.symbol, pos.avg_cost); + try manual_price_set.put(pos.symbol, {}); + } + } + + var summary = zfin.risk.portfolioSummary(allocator, positions, prices, manual_price_set) catch { + try stderr_print("Error computing portfolio summary.\n"); + return; + }; + defer summary.deinit(allocator); + + // Include non-stock assets in grand total (same as portfolio command) + const cash_total = portfolio.totalCash(); + const cd_total = portfolio.totalCdFaceValue(); + const opt_total = portfolio.totalOptionCost(); + const non_stock = cash_total + cd_total + opt_total; + summary.total_value += non_stock; + + // Load classification metadata + const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0; + const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return; + defer allocator.free(meta_path); + + const meta_data = std.fs.cwd().readFileAlloc(allocator, meta_path, 1024 * 1024) catch { + try stderr_print("Error: No metadata.srf found. Run: zfin enrich > metadata.srf\n"); + return; + }; + defer allocator.free(meta_data); + + var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch { + try stderr_print("Error: Cannot parse metadata.srf\n"); + return; + }; + defer cm.deinit(); + + // Load account tax type metadata (optional) + const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return; + defer allocator.free(acct_path); + + var acct_map_opt: ?zfin.analysis.AccountMap = null; + const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch null; + if (acct_data) |ad| { + defer allocator.free(ad); + acct_map_opt = zfin.analysis.parseAccountsFile(allocator, ad) catch null; + } + defer if (acct_map_opt) |*am| am.deinit(); + + var result = zfin.analysis.analyzePortfolio( + allocator, + summary.allocations, + cm, + portfolio, + summary.total_value, + acct_map_opt, + ) catch { + try stderr_print("Error computing analysis.\n"); + return; + }; + defer result.deinit(allocator); + + // Output + var buf: [32768]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + const label_width: usize = 24; + const bar_width: usize = 30; + + try setBold(out, color); + try out.print("\nPortfolio Analysis ({s})\n", .{file_path}); + try reset(out, color); + try out.print("========================================\n\n", .{}); + + // Asset Class + try setBold(out, color); + try setFg(out, color, CLR_HEADER); + try out.print(" Asset Class\n", .{}); + try reset(out, color); + try printBreakdownSection(out, result.asset_class, label_width, bar_width, color); + + // Sector + if (result.sector.len > 0) { + try out.print("\n", .{}); + try setBold(out, color); + try setFg(out, color, CLR_HEADER); + try out.print(" Sector (Equities)\n", .{}); + try reset(out, color); + try printBreakdownSection(out, result.sector, label_width, bar_width, color); + } + + // Geographic + if (result.geo.len > 0) { + try out.print("\n", .{}); + try setBold(out, color); + try setFg(out, color, CLR_HEADER); + try out.print(" Geographic\n", .{}); + try reset(out, color); + try printBreakdownSection(out, result.geo, label_width, bar_width, color); + } + + // By Account + if (result.account.len > 0) { + try out.print("\n", .{}); + try setBold(out, color); + try setFg(out, color, CLR_HEADER); + try out.print(" By Account\n", .{}); + try reset(out, color); + try printBreakdownSection(out, result.account, label_width, bar_width, color); + } + + // Tax Type + if (result.tax_type.len > 0) { + try out.print("\n", .{}); + try setBold(out, color); + try setFg(out, color, CLR_HEADER); + try out.print(" By Tax Type\n", .{}); + try reset(out, color); + try printBreakdownSection(out, result.tax_type, label_width, bar_width, color); + } + + // Unclassified + if (result.unclassified.len > 0) { + try out.print("\n", .{}); + try setFg(out, color, CLR_YELLOW); + try out.print(" Unclassified (not in metadata.srf)\n", .{}); + try reset(out, color); + for (result.unclassified) |sym| { + try setFg(out, color, CLR_MUTED); + try out.print(" {s}\n", .{sym}); + try reset(out, color); + } + } + + try out.print("\n", .{}); + try out.flush(); +} + +/// Print a breakdown section with block-element bar charts to the CLI output. +fn printBreakdownSection(out: anytype, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void { + // Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8) + const full_block = "\xE2\x96\x88"; + // partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8 + const partial_blocks = [7][]const u8{ + "\xE2\x96\x89", // 7/8 + "\xE2\x96\x8A", // 3/4 + "\xE2\x96\x8B", // 5/8 + "\xE2\x96\x8C", // 1/2 + "\xE2\x96\x8D", // 3/8 + "\xE2\x96\x8E", // 1/4 + "\xE2\x96\x8F", // 1/8 + }; + + for (items) |item| { + var val_buf: [24]u8 = undefined; + const pct = item.weight * 100.0; + + // Compute filled eighths + const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0; + const filled_eighths_f = item.weight * total_eighths; + const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); + const full_count = filled_eighths / 8; + const partial = filled_eighths % 8; + + // Padded label + const lbl_len = @min(item.label.len, label_width); + try out.print(" ", .{}); + try out.writeAll(item.label[0..lbl_len]); + if (lbl_len < label_width) { + for (0..label_width - lbl_len) |_| try out.writeAll(" "); + } + try out.writeAll(" "); + if (color) try fmt.ansiSetFg(out, CLR_ACCENT[0], CLR_ACCENT[1], CLR_ACCENT[2]); + for (0..full_count) |_| try out.writeAll(full_block); + if (partial > 0) try out.writeAll(partial_blocks[8 - partial - 1]); + const used = full_count + @as(usize, if (partial > 0) 1 else 0); + if (used < bar_width) { + for (0..bar_width - used) |_| try out.writeAll(" "); + } + if (color) try fmt.ansiReset(out); + try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) }); + } +} + +/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data. +/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, +/// and outputs a metadata SRF file to stdout. +fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, file_path: []const u8) !void { + const AV = @import("zfin").AlphaVantage; + + // Load portfolio + const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { + try stderr_print("Error: Cannot read portfolio file\n"); + return; + }; + defer allocator.free(file_data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { + try stderr_print("Error: Cannot parse portfolio file\n"); + return; + }; + defer portfolio.deinit(); + + // Get unique stock symbols (using display-oriented names) + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + // Get unique price symbols (raw API symbols) + const syms = try portfolio.stockSymbols(allocator); + defer allocator.free(syms); + + // Check for Alpha Vantage API key + const av_key = config.alphavantage_key orelse { + try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n"); + return; + }; + var av = AV.init(allocator, av_key); + defer av.deinit(); + + var buf: [32768]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("#!srfv1\n", .{}); + try out.print("# Portfolio classification metadata\n", .{}); + try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{}); + try out.print("# Edit as needed: sector, geo, asset_class, pct:num:N\n", .{}); + try out.print("#\n", .{}); + try out.print("# For ETFs/funds with multi-class exposure, add multiple lines\n", .{}); + try out.print("# with pct:num: values that sum to ~100\n\n", .{}); + + var success: usize = 0; + var skipped: usize = 0; + var failed: usize = 0; + + for (syms, 0..) |sym, i| { + // Skip CUSIPs and known non-stock symbols + const OpenFigi = @import("zfin").OpenFigi; + if (OpenFigi.isCusipLike(sym)) { + // Find the display name for this CUSIP + const display: []const u8 = sym; + var note: ?[]const u8 = null; + for (positions) |pos| { + if (std.mem.eql(u8, pos.symbol, sym)) { + if (pos.note) |n| { + note = n; + } + break; + } + } + try out.print("# CUSIP {s}", .{sym}); + if (note) |n| try out.print(" ({s})", .{n}); + try out.print(" -- fill in manually\n", .{}); + try out.print("# symbol::{s},asset_class::TODO,geo::TODO\n\n", .{display}); + skipped += 1; + continue; + } + + // Progress to stderr + { + var msg_buf: [128]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n"; + try stderr_print(msg); + } + + const overview = av.fetchCompanyOverview(allocator, sym) catch { + try out.print("# {s} -- fetch failed\n", .{sym}); + try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n\n", .{sym}); + failed += 1; + continue; + }; + // Free allocated strings from overview when done + defer { + if (overview.name) |n| allocator.free(n); + if (overview.sector) |s| allocator.free(s); + if (overview.industry) |ind| allocator.free(ind); + if (overview.country) |c| allocator.free(c); + if (overview.market_cap) |mc| allocator.free(mc); + if (overview.asset_type) |at| allocator.free(at); + } + + const sector_str = overview.sector orelse "Unknown"; + const country_str = overview.country orelse "US"; + const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str; + + // Determine asset_class from asset type + market cap + const asset_class_str = blk: { + if (overview.asset_type) |at| { + if (std.mem.eql(u8, at, "ETF")) break :blk "ETF"; + if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund"; + } + // For common stocks, infer from market cap + if (overview.market_cap) |mc_str| { + const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0; + if (mc >= 10_000_000_000) break :blk "US Large Cap"; + if (mc >= 2_000_000_000) break :blk "US Mid Cap"; + break :blk "US Small Cap"; + } + break :blk "US Large Cap"; + }; + + // Comment with the name for readability + if (overview.name) |name| { + try out.print("# {s}\n", .{name}); + } + try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{ + sym, sector_str, geo_str, asset_class_str, + }); + success += 1; + } + + // Summary comment + try out.print("# ---\n", .{}); + try out.print("# Enriched {d} symbols ({d} success, {d} skipped, {d} failed)\n", .{ + syms.len, success, skipped, failed, + }); + try out.print("# Review and edit this file, then save as metadata.srf\n", .{}); + try out.flush(); +} + // ── Output helpers ─────────────────────────────────────────── fn stdout_print(msg: []const u8) !void { diff --git a/src/providers/alphavantage.zig b/src/providers/alphavantage.zig index cac9e79..3afb7c1 100644 --- a/src/providers/alphavantage.zig +++ b/src/providers/alphavantage.zig @@ -17,6 +17,17 @@ const provider = @import("provider.zig"); const base_url = "https://www.alphavantage.co/query"; +/// Company overview data from Alpha Vantage OVERVIEW endpoint. +pub const CompanyOverview = struct { + symbol: []const u8, + name: ?[]const u8 = null, + sector: ?[]const u8 = null, + industry: ?[]const u8 = null, + country: ?[]const u8 = null, + market_cap: ?[]const u8 = null, + asset_type: ?[]const u8 = null, +}; + pub const AlphaVantage = struct { api_key: []const u8, client: http.Client, @@ -36,6 +47,27 @@ pub const AlphaVantage = struct { self.client.deinit(); } + /// Fetch company overview (sector, industry, country) for a stock symbol. + pub fn fetchCompanyOverview( + self: *AlphaVantage, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError!CompanyOverview { + self.rate_limiter.acquire(); + + const url = http.buildUrl(allocator, base_url, &.{ + .{ "function", "OVERVIEW" }, + .{ "symbol", symbol }, + .{ "apikey", self.api_key }, + }) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseCompanyOverview(allocator, response.body, symbol); + } + /// Fetch ETF profile data: expense ratio, holdings, sectors, etc. pub fn fetchEtfProfile( self: *AlphaVantage, @@ -214,3 +246,29 @@ fn mapHttpError(err: http.HttpError) provider.ProviderError { else => provider.ProviderError.RequestFailed, }; } + +fn parseCompanyOverview( + allocator: std.mem.Allocator, + body: []const u8, + symbol: []const u8, +) provider.ProviderError!CompanyOverview { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + if (root.get("Error Message")) |_| return provider.ProviderError.RequestFailed; + if (root.get("Note")) |_| return provider.ProviderError.RateLimited; + if (root.get("Information")) |_| return provider.ProviderError.RateLimited; + + return .{ + .symbol = symbol, + .name = if (jsonStr(root.get("Name"))) |s| allocator.dupe(u8, s) catch null else null, + .sector = if (jsonStr(root.get("Sector"))) |s| allocator.dupe(u8, s) catch null else null, + .industry = if (jsonStr(root.get("Industry"))) |s| allocator.dupe(u8, s) catch null else null, + .country = if (jsonStr(root.get("Country"))) |s| allocator.dupe(u8, s) catch null else null, + .market_cap = if (jsonStr(root.get("MarketCapitalization"))) |s| allocator.dupe(u8, s) catch null else null, + .asset_type = if (jsonStr(root.get("AssetType"))) |s| allocator.dupe(u8, s) catch null else null, + }; +} diff --git a/src/root.zig b/src/root.zig index fb484a6..e2d6346 100644 --- a/src/root.zig +++ b/src/root.zig @@ -38,6 +38,10 @@ pub const cache = @import("cache/store.zig"); pub const performance = @import("analytics/performance.zig"); pub const risk = @import("analytics/risk.zig"); pub const indicators = @import("analytics/indicators.zig"); +pub const analysis = @import("analytics/analysis.zig"); + +// -- Classification -- +pub const classification = @import("models/classification.zig"); // -- Formatting (shared between CLI and TUI) -- pub const format = @import("format.zig"); diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 83a54e5..709ad2b 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -12,6 +12,7 @@ pub const Action = enum { tab_3, tab_4, tab_5, + tab_6, scroll_down, scroll_up, scroll_top, @@ -91,6 +92,7 @@ const default_bindings = [_]Binding{ .{ .action = .tab_3, .key = .{ .codepoint = '3' } }, .{ .action = .tab_4, .key = .{ .codepoint = '4' } }, .{ .action = .tab_5, .key = .{ .codepoint = '5' } }, + .{ .action = .tab_6, .key = .{ .codepoint = '6' } }, .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, diff --git a/src/tui/main.zig b/src/tui/main.zig index d13b9e9..113d2f0 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -77,6 +77,7 @@ const Tab = enum { performance, options, earnings, + analysis, fn label(self: Tab) []const u8 { return switch (self) { @@ -85,11 +86,12 @@ const Tab = enum { .performance => " 3:Performance ", .options => " 4:Options ", .earnings => " 5:Earnings ", + .analysis => " 6:Analysis ", }; } }; -const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings }; +const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings, .analysis }; const InputMode = enum { normal, @@ -276,6 +278,11 @@ const App = struct { etf_loaded: bool = false, // Signal to the run loop to launch $EDITOR then restart wants_edit: bool = false, + // Analysis tab state + analysis_result: ?zfin.analysis.AnalysisResult = null, + analysis_loaded: bool = false, + classification_map: ?zfin.classification.ClassificationMap = null, + account_map: ?zfin.analysis.AccountMap = null, // Chart state (Kitty graphics) chart_config: chart_mod.ChartConfig = .{}, @@ -548,7 +555,7 @@ const App = struct { self.loadTabData(); return ctx.consumeAndRedraw(); }, - .tab_1, .tab_2, .tab_3, .tab_4, .tab_5 => { + .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6 => { const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1); if (idx < tabs.len) { const target = tabs[idx]; @@ -942,7 +949,7 @@ const App = struct { .options => { self.svc.invalidate(self.symbol, .options); }, - .portfolio => {}, + .portfolio, .analysis => {}, } } switch (self.active_tab) { @@ -964,6 +971,13 @@ const App = struct { self.options_loaded = false; self.freeOptions(); }, + .analysis => { + self.analysis_loaded = false; + if (self.analysis_result) |*ar| ar.deinit(self.allocator); + self.analysis_result = null; + if (self.account_map) |*am| am.deinit(); + self.account_map = null; + }, } self.loadTabData(); @@ -1000,6 +1014,9 @@ const App = struct { if (self.symbol.len == 0) return; if (!self.options_loaded) self.loadOptionsData(); }, + .analysis => { + if (!self.analysis_loaded) self.loadAnalysisData(); + }, } } @@ -1661,6 +1678,9 @@ const App = struct { self.portfolio_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator); if (self.watchlist_prices) |*wp| wp.deinit(); + if (self.analysis_result) |*ar| ar.deinit(self.allocator); + if (self.classification_map) |*cm| cm.deinit(); + if (self.account_map) |*am| am.deinit(); } fn reloadFiles(self: *App) void { @@ -1940,6 +1960,7 @@ const App = struct { .performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)), .options => try self.drawOptionsContent(ctx.arena, buf, width, height), .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), + .analysis => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildAnalysisStyledLines(ctx.arena)), } } @@ -3366,6 +3387,224 @@ const App = struct { return lines.toOwnedSlice(arena); } + // ── Analysis tab ──────────────────────────────────────────── + + fn loadAnalysisData(self: *App) void { + self.analysis_loaded = true; + + // Ensure portfolio is loaded first + if (!self.portfolio_loaded) self.loadPortfolioData(); + const pf = self.portfolio orelse return; + const summary = self.portfolio_summary orelse return; + + // Load classification metadata file + if (self.classification_map == null) { + // Look for metadata.srf next to the portfolio file + if (self.portfolio_path) |ppath| { + // Derive metadata path: same directory as portfolio, named "metadata.srf" + const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0; + const meta_path = std.fmt.allocPrint(self.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; + defer self.allocator.free(meta_path); + + const file_data = std.fs.cwd().readFileAlloc(self.allocator, meta_path, 1024 * 1024) catch { + self.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); + return; + }; + defer self.allocator.free(file_data); + + self.classification_map = zfin.classification.parseClassificationFile(self.allocator, file_data) catch { + self.setStatus("Error parsing metadata.srf"); + return; + }; + } + } + + // Load account tax type metadata file (optional) + if (self.account_map == null) { + if (self.portfolio_path) |ppath| { + const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0; + const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch { + self.loadAnalysisDataFinish(pf, summary); + return; + }; + defer self.allocator.free(acct_path); + + if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| { + defer self.allocator.free(acct_data); + self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null; + } else |_| { + // accounts.srf is optional -- analysis works without it + } + } + } + + self.loadAnalysisDataFinish(pf, summary); + } + + fn loadAnalysisDataFinish(self: *App, pf: zfin.Portfolio, summary: zfin.risk.PortfolioSummary) void { + const cm = self.classification_map orelse { + self.setStatus("No classification data. Run: zfin enrich > metadata.srf"); + return; + }; + + // Free previous result + if (self.analysis_result) |*ar| ar.deinit(self.allocator); + + self.analysis_result = zfin.analysis.analyzePortfolio( + self.allocator, + summary.allocations, + cm, + pf, + summary.total_value, + self.account_map, + ) catch { + self.setStatus("Error computing analysis"); + return; + }; + } + + fn buildAnalysisStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = self.theme; + 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 = self.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 > metadata.srf", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + + // Helper: render a breakdown section with horizontal bar chart + const bar_width: usize = 30; + const label_width: usize = 24; // wide enough for "International Developed" + + // Asset Class breakdown + try lines.append(arena, .{ .text = " Asset Class", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + for (result.asset_class) |item| { + const text = try fmtBreakdownLine(arena, item, bar_width, label_width); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + + // Sector breakdown + if (result.sector.len > 0) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Sector (Equities)", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + for (result.sector) |item| { + const text = try fmtBreakdownLine(arena, item, bar_width, label_width); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + } + + // Geographic breakdown + if (result.geo.len > 0) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Geographic", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + for (result.geo) |item| { + const text = try fmtBreakdownLine(arena, item, bar_width, label_width); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + } + + // Account breakdown + if (result.account.len > 0) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " By Account", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + for (result.account) |item| { + const text = try fmtBreakdownLine(arena, item, bar_width, label_width); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + } + + // Tax type breakdown + if (result.tax_type.len > 0) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " By Tax Type", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + for (result.tax_type) |item| { + const text = try fmtBreakdownLine(arena, item, bar_width, label_width); + try lines.append(arena, .{ .text = text, .style = th.contentStyle() }); + } + } + + // Unclassified positions + 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); + } + + 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.fmtMoney(&val_buf, item.value), + }); + } + + /// Build a bar using Unicode block elements for sub-character precision. + /// U+2588 █ full, U+2589 ▉ 7/8, U+258A ▊ 3/4, U+258B ▋ 5/8, + /// U+258C ▌ 1/2, U+258D ▍ 3/8, U+258E ▎ 1/4, U+258F ▏ 1/8 + fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 { + // Each character has 8 sub-positions + const total_eighths: f64 = @as(f64, @floatFromInt(total_chars)) * 8.0; + const filled_eighths_f = weight * total_eighths; + const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths)); + const full_blocks = filled_eighths / 8; + const partial = filled_eighths % 8; + + // Each full block is 3 bytes UTF-8, partial is 3 bytes, spaces are 1 byte + const has_partial: usize = if (partial > 0) 1 else 0; + const empty_blocks = total_chars - full_blocks - has_partial; + const byte_len = full_blocks * 3 + has_partial * 3 + empty_blocks; + var buf = try arena.alloc(u8, byte_len); + var pos: usize = 0; + + // Full blocks: U+2588 = E2 96 88 + for (0..full_blocks) |_| { + buf[pos] = 0xE2; + buf[pos + 1] = 0x96; + buf[pos + 2] = 0x88; + pos += 3; + } + + // Partial block (if any) + // U+2588..U+258F: full=0x88, 7/8=0x89, 3/4=0x8A, 5/8=0x8B, + // 1/2=0x8C, 3/8=0x8D, 1/4=0x8E, 1/8=0x8F + // partial eighths: 7->0x89, 6->0x8A, 5->0x8B, 4->0x8C, 3->0x8D, 2->0x8E, 1->0x8F + if (partial > 0) { + const code: u8 = 0x88 + @as(u8, @intCast(8 - partial)); + buf[pos] = 0xE2; + buf[pos + 1] = 0x96; + buf[pos + 2] = code; + pos += 3; + } + + // Empty spaces + @memset(buf[pos..], ' '); + + return buf; + } + // ── Help ───────────────────────────────────────────────────── fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { @@ -3380,7 +3619,7 @@ const App = struct { const action_labels = [_][]const u8{ "Quit", "Refresh", "Previous tab", "Next tab", "Tab 1", "Tab 2", "Tab 3", "Tab 4", - "Tab 5", "Scroll down", "Scroll up", "Scroll to top", + "Tab 5", "Tab 6", "Scroll down", "Scroll up", "Scroll to top", "Scroll to bottom", "Page down", "Page up", "Select next", "Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)", "This help", "Edit portfolio/watchlist",