ai: portfolio analysis

This commit is contained in:
Emil Lerch 2026-02-26 18:43:07 -08:00
parent b4b31c7268
commit 0e81df90aa
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 688 additions and 4 deletions

View file

@ -17,6 +17,7 @@ const usage =
\\ earnings <SYMBOL> Show earnings history and upcoming \\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio) \\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio <FILE> Load and analyze a portfolio (.srf file) \\ portfolio <FILE> Load and analyze a portfolio (.srf file)
\\ analysis <FILE> Show portfolio analysis (asset class, sector, geo, account, tax type)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
@ -39,6 +40,10 @@ const usage =
\\ -w, --watchlist <FILE> Watchlist file \\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices) \\ --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: \\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ 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")) { } else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n");
try cmdCache(allocator, config, args[2]); 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 { } else {
try stderr_print("Unknown command. Run 'zfin help' for usage.\n"); 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 <portfolio.srf> > metadata.srf\n");
return;
};
defer allocator.free(meta_data);
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
try stderr_print("Error: Cannot parse metadata.srf\n");
return;
};
defer cm.deinit();
// Load account tax type metadata (optional)
const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return;
defer allocator.free(acct_path);
var acct_map_opt: ?zfin.analysis.AccountMap = null;
const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch null;
if (acct_data) |ad| {
defer allocator.free(ad);
acct_map_opt = zfin.analysis.parseAccountsFile(allocator, ad) catch null;
}
defer if (acct_map_opt) |*am| am.deinit();
var result = zfin.analysis.analyzePortfolio(
allocator,
summary.allocations,
cm,
portfolio,
summary.total_value,
acct_map_opt,
) catch {
try stderr_print("Error computing analysis.\n");
return;
};
defer result.deinit(allocator);
// Output
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
const label_width: usize = 24;
const bar_width: usize = 30;
try setBold(out, color);
try out.print("\nPortfolio Analysis ({s})\n", .{file_path});
try reset(out, color);
try out.print("========================================\n\n", .{});
// Asset Class
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" Asset Class\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.asset_class, label_width, bar_width, color);
// Sector
if (result.sector.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" Sector (Equities)\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.sector, label_width, bar_width, color);
}
// Geographic
if (result.geo.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" Geographic\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.geo, label_width, bar_width, color);
}
// By Account
if (result.account.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" By Account\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.account, label_width, bar_width, color);
}
// Tax Type
if (result.tax_type.len > 0) {
try out.print("\n", .{});
try setBold(out, color);
try setFg(out, color, CLR_HEADER);
try out.print(" By Tax Type\n", .{});
try reset(out, color);
try printBreakdownSection(out, result.tax_type, label_width, bar_width, color);
}
// Unclassified
if (result.unclassified.len > 0) {
try out.print("\n", .{});
try setFg(out, color, CLR_YELLOW);
try out.print(" Unclassified (not in metadata.srf)\n", .{});
try reset(out, color);
for (result.unclassified) |sym| {
try setFg(out, color, CLR_MUTED);
try out.print(" {s}\n", .{sym});
try reset(out, color);
}
}
try out.print("\n", .{});
try out.flush();
}
/// Print a breakdown section with block-element bar charts to the CLI output.
fn printBreakdownSection(out: anytype, items: []const zfin.analysis.BreakdownItem, label_width: usize, bar_width: usize, color: bool) !void {
// Unicode block elements: U+2588 full, U+2589..U+258F partials (7/8..1/8)
const full_block = "\xE2\x96\x88";
// partial_blocks[0]=7/8, [1]=3/4, ..., [6]=1/8
const partial_blocks = [7][]const u8{
"\xE2\x96\x89", // 7/8
"\xE2\x96\x8A", // 3/4
"\xE2\x96\x8B", // 5/8
"\xE2\x96\x8C", // 1/2
"\xE2\x96\x8D", // 3/8
"\xE2\x96\x8E", // 1/4
"\xE2\x96\x8F", // 1/8
};
for (items) |item| {
var val_buf: [24]u8 = undefined;
const pct = item.weight * 100.0;
// Compute filled eighths
const total_eighths: f64 = @as(f64, @floatFromInt(bar_width)) * 8.0;
const filled_eighths_f = item.weight * total_eighths;
const filled_eighths: usize = @intFromFloat(@min(@max(filled_eighths_f, 0), total_eighths));
const full_count = filled_eighths / 8;
const partial = filled_eighths % 8;
// Padded label
const lbl_len = @min(item.label.len, label_width);
try out.print(" ", .{});
try out.writeAll(item.label[0..lbl_len]);
if (lbl_len < label_width) {
for (0..label_width - lbl_len) |_| try out.writeAll(" ");
}
try out.writeAll(" ");
if (color) try fmt.ansiSetFg(out, CLR_ACCENT[0], CLR_ACCENT[1], CLR_ACCENT[2]);
for (0..full_count) |_| try out.writeAll(full_block);
if (partial > 0) try out.writeAll(partial_blocks[8 - partial - 1]);
const used = full_count + @as(usize, if (partial > 0) 1 else 0);
if (used < bar_width) {
for (0..bar_width - used) |_| try out.writeAll(" ");
}
if (color) try fmt.ansiReset(out);
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) });
}
}
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
/// and outputs a metadata SRF file to stdout.
fn cmdEnrich(allocator: std.mem.Allocator, config: zfin.Config, file_path: []const u8) !void {
const AV = @import("zfin").AlphaVantage;
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try stderr_print("Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
try stderr_print("Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Get unique stock symbols (using display-oriented names)
const positions = try portfolio.positions(allocator);
defer allocator.free(positions);
// Get unique price symbols (raw API symbols)
const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
// Check for Alpha Vantage API key
const av_key = config.alphavantage_key orelse {
try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return;
};
var av = AV.init(allocator, av_key);
defer av.deinit();
var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface;
try out.print("#!srfv1\n", .{});
try out.print("# Portfolio classification metadata\n", .{});
try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{});
try out.print("# Edit as needed: sector, geo, asset_class, pct:num:N\n", .{});
try out.print("#\n", .{});
try out.print("# For ETFs/funds with multi-class exposure, add multiple lines\n", .{});
try out.print("# with pct:num: values that sum to ~100\n\n", .{});
var success: usize = 0;
var skipped: usize = 0;
var failed: usize = 0;
for (syms, 0..) |sym, i| {
// Skip CUSIPs and known non-stock symbols
const OpenFigi = @import("zfin").OpenFigi;
if (OpenFigi.isCusipLike(sym)) {
// Find the display name for this CUSIP
const display: []const u8 = sym;
var note: ?[]const u8 = null;
for (positions) |pos| {
if (std.mem.eql(u8, pos.symbol, sym)) {
if (pos.note) |n| {
note = n;
}
break;
}
}
try out.print("# CUSIP {s}", .{sym});
if (note) |n| try out.print(" ({s})", .{n});
try out.print(" -- fill in manually\n", .{});
try out.print("# symbol::{s},asset_class::TODO,geo::TODO\n\n", .{display});
skipped += 1;
continue;
}
// Progress to stderr
{
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n";
try stderr_print(msg);
}
const overview = av.fetchCompanyOverview(allocator, sym) catch {
try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n\n", .{sym});
failed += 1;
continue;
};
// Free allocated strings from overview when done
defer {
if (overview.name) |n| allocator.free(n);
if (overview.sector) |s| allocator.free(s);
if (overview.industry) |ind| allocator.free(ind);
if (overview.country) |c| allocator.free(c);
if (overview.market_cap) |mc| allocator.free(mc);
if (overview.asset_type) |at| allocator.free(at);
}
const sector_str = overview.sector orelse "Unknown";
const country_str = overview.country orelse "US";
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
// Determine asset_class from asset type + market cap
const asset_class_str = blk: {
if (overview.asset_type) |at| {
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
}
// For common stocks, infer from market cap
if (overview.market_cap) |mc_str| {
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
if (mc >= 10_000_000_000) break :blk "US Large Cap";
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
break :blk "US Small Cap";
}
break :blk "US Large Cap";
};
// Comment with the name for readability
if (overview.name) |name| {
try out.print("# {s}\n", .{name});
}
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{
sym, sector_str, geo_str, asset_class_str,
});
success += 1;
}
// Summary comment
try out.print("# ---\n", .{});
try out.print("# Enriched {d} symbols ({d} success, {d} skipped, {d} failed)\n", .{
syms.len, success, skipped, failed,
});
try out.print("# Review and edit this file, then save as metadata.srf\n", .{});
try out.flush();
}
// Output helpers // Output helpers
fn stdout_print(msg: []const u8) !void { fn stdout_print(msg: []const u8) !void {

View file

@ -17,6 +17,17 @@ const provider = @import("provider.zig");
const base_url = "https://www.alphavantage.co/query"; 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 { pub const AlphaVantage = struct {
api_key: []const u8, api_key: []const u8,
client: http.Client, client: http.Client,
@ -36,6 +47,27 @@ pub const AlphaVantage = struct {
self.client.deinit(); 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. /// Fetch ETF profile data: expense ratio, holdings, sectors, etc.
pub fn fetchEtfProfile( pub fn fetchEtfProfile(
self: *AlphaVantage, self: *AlphaVantage,
@ -214,3 +246,29 @@ fn mapHttpError(err: http.HttpError) provider.ProviderError {
else => provider.ProviderError.RequestFailed, 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,
};
}

View file

@ -38,6 +38,10 @@ pub const cache = @import("cache/store.zig");
pub const performance = @import("analytics/performance.zig"); pub const performance = @import("analytics/performance.zig");
pub const risk = @import("analytics/risk.zig"); pub const risk = @import("analytics/risk.zig");
pub const indicators = @import("analytics/indicators.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) -- // -- Formatting (shared between CLI and TUI) --
pub const format = @import("format.zig"); pub const format = @import("format.zig");

View file

@ -12,6 +12,7 @@ pub const Action = enum {
tab_3, tab_3,
tab_4, tab_4,
tab_5, tab_5,
tab_6,
scroll_down, scroll_down,
scroll_up, scroll_up,
scroll_top, scroll_top,
@ -91,6 +92,7 @@ const default_bindings = [_]Binding{
.{ .action = .tab_3, .key = .{ .codepoint = '3' } }, .{ .action = .tab_3, .key = .{ .codepoint = '3' } },
.{ .action = .tab_4, .key = .{ .codepoint = '4' } }, .{ .action = .tab_4, .key = .{ .codepoint = '4' } },
.{ .action = .tab_5, .key = .{ .codepoint = '5' } }, .{ .action = .tab_5, .key = .{ .codepoint = '5' } },
.{ .action = .tab_6, .key = .{ .codepoint = '6' } },
.{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } },
.{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } },
.{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } },

View file

@ -77,6 +77,7 @@ const Tab = enum {
performance, performance,
options, options,
earnings, earnings,
analysis,
fn label(self: Tab) []const u8 { fn label(self: Tab) []const u8 {
return switch (self) { return switch (self) {
@ -85,11 +86,12 @@ const Tab = enum {
.performance => " 3:Performance ", .performance => " 3:Performance ",
.options => " 4:Options ", .options => " 4:Options ",
.earnings => " 5:Earnings ", .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 { const InputMode = enum {
normal, normal,
@ -276,6 +278,11 @@ const App = struct {
etf_loaded: bool = false, etf_loaded: bool = false,
// Signal to the run loop to launch $EDITOR then restart // Signal to the run loop to launch $EDITOR then restart
wants_edit: bool = false, 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 state (Kitty graphics)
chart_config: chart_mod.ChartConfig = .{}, chart_config: chart_mod.ChartConfig = .{},
@ -548,7 +555,7 @@ const App = struct {
self.loadTabData(); self.loadTabData();
return ctx.consumeAndRedraw(); 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); const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
if (idx < tabs.len) { if (idx < tabs.len) {
const target = tabs[idx]; const target = tabs[idx];
@ -942,7 +949,7 @@ const App = struct {
.options => { .options => {
self.svc.invalidate(self.symbol, .options); self.svc.invalidate(self.symbol, .options);
}, },
.portfolio => {}, .portfolio, .analysis => {},
} }
} }
switch (self.active_tab) { switch (self.active_tab) {
@ -964,6 +971,13 @@ const App = struct {
self.options_loaded = false; self.options_loaded = false;
self.freeOptions(); 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(); self.loadTabData();
@ -1000,6 +1014,9 @@ const App = struct {
if (self.symbol.len == 0) return; if (self.symbol.len == 0) return;
if (!self.options_loaded) self.loadOptionsData(); 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.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator);
if (self.watchlist_prices) |*wp| wp.deinit(); 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 { 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)), .performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)),
.options => try self.drawOptionsContent(ctx.arena, buf, width, height), .options => try self.drawOptionsContent(ctx.arena, buf, width, height),
.earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), .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); 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 <portfolio.srf> > 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 <portfolio.srf> > 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 <portfolio.srf> > 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 // Help
fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -3380,7 +3619,7 @@ const App = struct {
const action_labels = [_][]const u8{ const action_labels = [_][]const u8{
"Quit", "Refresh", "Previous tab", "Next tab", "Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4", "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", "Scroll to bottom", "Page down", "Page up", "Select next",
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)", "Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)",
"This help", "Edit portfolio/watchlist", "This help", "Edit portfolio/watchlist",