ai: portfolio analysis
This commit is contained in:
parent
b4b31c7268
commit
0e81df90aa
5 changed files with 688 additions and 4 deletions
381
src/cli/main.zig
381
src/cli/main.zig
|
|
@ -17,6 +17,7 @@ const usage =
|
|||
\\ earnings <SYMBOL> Show earnings history and upcoming
|
||||
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
|
||||
\\ 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
|
||||
\\ cache stats Show cache statistics
|
||||
\\ cache clear Clear all cached data
|
||||
|
|
@ -39,6 +40,10 @@ const usage =
|
|||
\\ -w, --watchlist <FILE> 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 <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 ───────────────────────────────────────────
|
||||
|
||||
fn stdout_print(msg: []const u8) !void {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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' } },
|
||||
|
|
|
|||
247
src/tui/main.zig
247
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 <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 ─────────────────────────────────────────────────────
|
||||
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue