From 6e78818f1c36cc6e7f7509d51dd819ec4fc3e564 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 27 Feb 2026 08:15:28 -0800 Subject: [PATCH] ai: analysis tab, various fixes --- README.md | 237 ++++++++++++++++++++++++-- src/analytics/analysis.zig | 303 ++++++++++++++++++++++++++++++++++ src/cli/main.zig | 29 +++- src/models/classification.zig | 128 ++++++++++++++ src/tui/main.zig | 21 ++- src/tui/theme.zig | 10 ++ 6 files changed, 697 insertions(+), 31 deletions(-) create mode 100644 src/analytics/analysis.zig create mode 100644 src/models/classification.zig diff --git a/README.md b/README.md index d031060..f7e589d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ zig build run -- perf VTI # trailing returns zig build run -- quote AAPL # real-time quote zig build run -- options AAPL # options chains zig build run -- earnings MSFT # earnings history +zig build run -- portfolio # portfolio summary (reads portfolio.srf) +zig build run -- analysis # portfolio analysis (reads portfolio.srf + metadata.srf) # Interactive TUI zig build run -- i # auto-loads portfolio.srf from cwd @@ -182,7 +184,7 @@ If no portfolio or symbol is specified and `portfolio.srf` exists in the current ## Interactive TUI -The TUI has five tabs: Portfolio, Quote, Performance, Options, and Earnings. +The TUI has six tabs: Portfolio, Quote, Performance, Options, Earnings, and Analysis. ### Tabs @@ -196,6 +198,8 @@ The TUI has five tabs: Portfolio, Quote, Performance, Options, and Earnings. **Earnings** -- historical and upcoming earnings events with EPS estimate/actual, surprise amount and percentage. Future events are dimmed. Tab is disabled for ETFs. +**Analysis** -- portfolio breakdown by asset class, sector, geographic region, account, and tax type. Uses classification data from `metadata.srf` and account tax types from `accounts.srf`. Displays horizontal bar charts with sub-character precision using Unicode block elements. + ### Keybindings All keybindings are configurable via `~/.config/zfin/keys.srf`. Generate the default config: @@ -210,15 +214,21 @@ Default keybindings: |---|---| | `q`, `Ctrl+c` | Quit | | `r`, `F5` | Refresh current tab (invalidates cache) | +| `R` | Reload portfolio from disk (no network) | | `h`, Left | Previous tab | | `l`, Right, Tab | Next tab | -| `1`-`5` | Jump to tab | +| `1`-`6` | Jump to tab | | `j`, Down | Select next row | | `k`, Up | Select previous row | | `Enter` | Expand/collapse (positions, expirations, calls/puts) | | `s` | Select symbol from portfolio for other tabs | | `/` | Enter symbol search | | `e` | Edit portfolio/watchlist in `$EDITOR` | +| `<` | Sort by previous column (portfolio tab) | +| `>` | Sort by next column (portfolio tab) | +| `o` | Reverse sort direction (portfolio tab) | +| `[` | Previous chart timeframe (quote tab) | +| `]` | Next chart timeframe (quote tab) | | `c` | Toggle all calls collapsed/expanded (options tab) | | `p` | Toggle all puts collapsed/expanded (options tab) | | `Ctrl+1`-`Ctrl+9` | Set options near-the-money filter to +/- N strikes | @@ -244,49 +254,228 @@ Colors are specified as `#rrggbb` hex values. The theme uses RGB colors (not ter ## Portfolio format -Portfolios are SRF files with one lot per line: +Portfolios are [SRF](https://github.com/lobo/srf) files with one lot per line. Each lot is a comma-separated list of `key::value` pairs (numbers use `key:num:value`). ``` #!srfv1 -symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50 -symbol::AAPL,shares:num:50,open_date::2024-03-01,open_price:num:170.00 +# Stocks/ETFs +symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50,account::Brokerage +symbol::AAPL,shares:num:50,open_date::2024-03-01,open_price:num:170.00,account::Roth IRA symbol::AAPL,shares:num:25,open_date::2023-06-15,open_price:num:155.00,account::Roth IRA + +# Closed lot (sold) symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50 + +# DRIP lots (summarized as ST/LT groups in the UI) +symbol::VTI,shares:num:0.234,open_date::2024-06-15,open_price:num:267.50,drip::true,account::Brokerage + +# CUSIP with ticker alias (401k CIT share class) +symbol::02315N600,shares:num:1200,open_date::2022-01-01,open_price:num:140.00,ticker::VTTHX,account::Fidelity 401k,note::VANGUARD TARGET 2035 + +# Manual price override (for securities without API coverage) +symbol::NON40OR52,shares:num:500,open_date::2023-01-01,open_price:num:155.00,price:num:163.636,price_date::2026-02-27,account::Fidelity 401k,note::CIT SHARE CLASS + +# Options +security_type::option,symbol::AAPL 250321C00200000,shares:num:-2,open_date::2025-01-15,open_price:num:12.50,account::Brokerage + +# CDs +security_type::cd,symbol::912797KR0,shares:num:10000,open_date::2024-06-01,open_price:num:10000,maturity_date::2025-06-01,rate:num:5.25,account::Brokerage,note::6-Month T-Bill + +# Cash +security_type::cash,shares:num:15000,account::Brokerage +security_type::cash,shares:num:5200.50,account::Roth IRA,note::Money market settlement + +# Illiquid assets +security_type::illiquid,symbol::HOME,shares:num:450000,open_date::2020-06-01,open_price:num:350000,note::Primary residence (Zillow est.) + +# Watchlist (track price only, no position) +security_type::watch,symbol::NVDA +security_type::watch,symbol::TSLA ``` ### Lot fields | Field | Type | Required | Description | |---|---|---|---| -| `symbol` | string | Yes | Ticker symbol | -| `shares` | number | Yes | Number of shares | -| `open_date` | string | Yes | Purchase date (YYYY-MM-DD) | -| `open_price` | number | Yes | Purchase price per share | +| `symbol` | string | Yes* | Ticker symbol or CUSIP. *Optional for `cash` lots. | +| `shares` | number | Yes | Number of shares (or face value for cash/CDs) | +| `open_date` | string | Yes** | Purchase date (YYYY-MM-DD). **Not required for cash/watch. | +| `open_price` | number | Yes** | Purchase price per share. **Not required for cash/watch. | | `close_date` | string | No | Sale date (null = open lot) | | `close_price` | number | No | Sale price per share | -| `note` | string | No | Tag or note | +| `security_type` | string | No | `stock` (default), `option`, `cd`, `cash`, `illiquid`, `watch` | | `account` | string | No | Account name (e.g. "Roth IRA", "Brokerage") | +| `note` | string | No | Descriptive note (shown in cash/CD/illiquid tables) | +| `ticker` | string | No | Ticker alias for price fetching (overrides `symbol` for API calls) | +| `price` | number | No | Manual price override (fallback when API has no coverage) | +| `price_date` | string | No | Date of the manual price (YYYY-MM-DD, for staleness display) | +| `drip` | string | No | `true` if lot is from dividend reinvestment (summarized as ST/LT groups) | +| `maturity_date` | string | No | CD maturity date (YYYY-MM-DD) | +| `rate` | number | No | Interest rate for CDs (e.g. 5.25 = 5.25%) | -Open lots (no `close_date`) contribute to positions. Closed lots (with `close_date` and `close_price`) show realized P&L. The `account` field is displayed in the lot detail view when a position is expanded. +### Security types -### Watchlist format +- **stock** (default) -- Stocks, ETFs, and mutual funds. Prices are fetched from TwelveData. Positions are aggregated by symbol and shown with gain/loss. +- **option** -- Option contracts. Shown in a separate "Options" section. Shares can be negative for short positions. +- **cd** -- Certificates of deposit. Shown sorted by maturity date with rate and face value. +- **cash** -- Cash, money market, and settlement balances. Shown grouped by account with optional notes. +- **illiquid** -- Illiquid assets (real estate, vehicles, etc.). Shown in a separate section. Not included in the liquid portfolio total; contributes to Net Worth. +- **watch** -- Watchlist items. No position, just tracks the price. Shown at the bottom of the portfolio tab. -A watchlist is an SRF file with just symbol fields: +### Price resolution + +For stock lots, prices are resolved in this order: + +1. **Live API** -- Latest close from TwelveData cached candles +2. **Manual price** -- `price::` field on the lot (for securities without API coverage, e.g. 401k CIT share classes) +3. **Average cost** -- Falls back to the position's `open_price` as a last resort + +Manual-priced rows are shown in warning color (yellow) so you know the price may be stale. The `price_date::` field helps you track when the price was last updated. + +### Ticker aliases + +Some securities (like 401k CIT share classes) use CUSIPs as identifiers but have a retail equivalent ticker for price fetching. Use `ticker::` to specify the API ticker: + +``` +symbol::02315N600,ticker::VTTHX,... +``` + +The `symbol::` is used as the display identifier and for classification lookups. The `ticker::` is used for API price fetching. If the CUSIP and retail ticker have different NAVs (common for CIT vs retail fund), use `price::` instead. + +### CUSIP lookup + +Use the `lookup` command to resolve CUSIPs to tickers via OpenFIGI: + +```bash +zfin lookup 459200101 # -> IWM (iShares Russell 2000 ETF) +``` + +### DRIP lots + +Lots marked with `drip::true` are summarized as ST (short-term) and LT (long-term) groups in the position detail view, rather than listing every small reinvestment lot individually. The grouping is based on the 1-year capital gains threshold. + +### Watchlist + +Watchlist symbols can be defined as `security_type::watch` lots in the portfolio file, or in a separate watchlist file (`-w` flag). They appear at the bottom of the portfolio tab showing the cached price. + +## Classification metadata (metadata.srf) + +The `metadata.srf` file provides classification data for portfolio analysis. It maps symbols to asset class, sector, and geographic region. Place it in the same directory as the portfolio file. ``` #!srfv1 -symbol::NVDA -symbol::TSLA -symbol::GOOG +# Individual stock: single classification at 100% +symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap + +# ETF: inherits sector from holdings, but classified by asset class +symbol::VTI,asset_class::US Large Cap,geo::US + +# International ETF +symbol::VXUS,asset_class::International Developed,geo::International Developed + +# Target date fund: blended allocation (percentages should sum to ~100) +symbol::02315N600,asset_class::US Large Cap,pct:num:55 +symbol::02315N600,asset_class::International Developed,pct:num:20 +symbol::02315N600,asset_class::Bonds,pct:num:15 +symbol::02315N600,asset_class::Emerging Markets,pct:num:10 + +# BDC / REIT / specialty +symbol::ARCC,sector::Financials,geo::US,asset_class::US Large Cap ``` -Watchlist symbols appear at the bottom of the portfolio tab. They show the latest cached price but no position data. Press Enter or double-click to jump to the Quote tab for that symbol. +### Classification fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `symbol` | string | Yes | Ticker symbol or CUSIP (must match `symbol::` or `ticker::` in portfolio) | +| `asset_class` | string | No | e.g. "US Large Cap", "Bonds", "Cash & CDs", "Emerging Markets" | +| `sector` | string | No | e.g. "Technology", "Healthcare", "Financials" | +| `geo` | string | No | e.g. "US", "International Developed", "Emerging Markets" | +| `pct` | number | No | Percentage weight for this entry (default 100). Use for blended funds. | + +For single-asset-class securities (individual stocks, single-focus ETFs), one line at the default 100% is sufficient. For multi-asset-class funds (target date, balanced), add multiple lines for the same symbol with `pct:num:` values that sum to approximately 100. + +Cash and CD lots are automatically classified as "Cash & CDs" without needing metadata entries. + +### Bootstrapping metadata + +Use the `enrich` command to generate a starting `metadata.srf` from Alpha Vantage company overview data: + +```bash +zfin enrich portfolio.srf > metadata.srf +``` + +This fetches sector, country, and market cap for each stock symbol and generates classification entries. CUSIPs are skipped (fill in manually). Review and edit the output -- particularly for ETFs and funds where the auto-classification may be too generic. + +## Account metadata (accounts.srf) + +The `accounts.srf` file maps account names to tax types for the tax type breakdown in portfolio analysis. Place it in the same directory as the portfolio file. + +``` +#!srfv1 +account::Brokerage,tax_type::taxable +account::Roth IRA,tax_type::roth +account::Traditional IRA,tax_type::traditional +account::Fidelity 401k,tax_type::traditional +account::HSA,tax_type::hsa +``` + +### Account fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `account` | string | Yes | Account name (must match `account::` in portfolio lots exactly) | +| `tax_type` | string | Yes | Tax classification (see below) | + +### Tax types + +| Value | Display label | +|---|---| +| `taxable` | Taxable | +| `roth` | Roth (Post-Tax) | +| `traditional` | Traditional (Pre-Tax) | +| `hsa` | HSA (Triple Tax-Free) | +| (other) | Shown as-is | + +Accounts not listed in `accounts.srf` appear as "Unknown" in the tax type breakdown. + +## CLI commands + +``` +zfin [args] + +Commands: + perf Trailing returns (1yr/3yr/5yr/10yr, price + total) + quote Real-time quote with chart + history Last 30 days price history + divs Dividend history with TTM yield + splits Split history + options Options chains (all expirations) + earnings Earnings history and upcoming events + etf ETF profile (expense ratio, holdings, sectors) + portfolio [FILE] Portfolio summary (default: portfolio.srf) + analysis [FILE] Portfolio analysis breakdowns (default: portfolio.srf) + enrich Generate metadata.srf from Alpha Vantage + lookup CUSIP to ticker lookup via OpenFIGI + cache stats Show cached symbols + cache clear Delete all cached data + interactive, i Launch interactive TUI + help Show usage + +Global options: + --no-color Disable colored output (also respects NO_COLOR env) + +Portfolio options: + --refresh Force re-fetch all prices (ignore cache) + -w, --watchlist Watchlist file +``` ## Architecture ``` src/ root.zig Library root, exports all public types + format.zig Shared formatters, braille engine, ANSI helpers config.zig Configuration from env vars / .env files service.zig DataService: cache-check -> fetch -> cache -> return models/ @@ -298,6 +487,7 @@ src/ earnings.zig Earnings events with surprise calculation etf_profile.zig ETF profiles with holdings and sectors portfolio.zig Lots, positions, and portfolio aggregation + classification.zig Classification metadata parser quote.zig Real-time quote data ticker_info.zig Security metadata providers/ @@ -306,10 +496,13 @@ src/ polygon.zig Polygon: dividends, splits finnhub.zig Finnhub: earnings cboe.zig CBOE: options chains (no API key) - alphavantage.zig Alpha Vantage: ETF profiles + alphavantage.zig Alpha Vantage: ETF profiles, company overview + openfigi.zig OpenFIGI: CUSIP to ticker lookup analytics/ + indicators.zig SMA, Bollinger Bands, RSI performance.zig Trailing returns (as-of-date + month-end) risk.zig Volatility, Sharpe, drawdown, portfolio summary + analysis.zig Portfolio analysis engine (breakdowns by class/sector/geo/account/tax) cache/ store.zig SRF file cache with TTL freshness checks net/ @@ -319,10 +512,18 @@ src/ main.zig CLI entry point and all commands tui/ main.zig Interactive TUI application + chart.zig z2d pixel chart renderer (Kitty graphics) keybinds.zig Configurable keybinding system theme.zig Configurable color theme ``` +Data files (user-managed, in project root): +``` +portfolio.srf Portfolio lots +metadata.srf Classification metadata for analysis +accounts.srf Account to tax type mapping for analysis +``` + ### Dependencies | Dependency | Source | Purpose | diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig new file mode 100644 index 0000000..0de59fe --- /dev/null +++ b/src/analytics/analysis.zig @@ -0,0 +1,303 @@ +/// Portfolio analysis engine. +/// +/// Takes portfolio allocations (with market values) and classification metadata, +/// produces breakdowns by asset class, sector, geographic region, account, and tax type. + +const std = @import("std"); +const Allocation = @import("risk.zig").Allocation; +const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; +const ClassificationMap = @import("../models/classification.zig").ClassificationMap; +const LotType = @import("../models/portfolio.zig").LotType; +const Portfolio = @import("../models/portfolio.zig").Portfolio; + +/// A single slice of a breakdown (e.g., "Technology" -> 25.3%) +pub const BreakdownItem = struct { + label: []const u8, + value: f64, // dollar amount + weight: f64, // fraction of total (0.0 - 1.0) +}; + +/// Account tax type classification entry, parsed from accounts.srf. +pub const AccountTaxEntry = struct { + account: []const u8, + tax_type: []const u8, +}; + +/// Parsed account metadata. +pub const AccountMap = struct { + entries: []AccountTaxEntry, + allocator: std.mem.Allocator, + + pub fn deinit(self: *AccountMap) void { + for (self.entries) |e| { + self.allocator.free(e.account); + self.allocator.free(e.tax_type); + } + self.allocator.free(self.entries); + } + + /// Look up the tax type label for a given account name. + pub fn taxTypeFor(self: AccountMap, account: []const u8) []const u8 { + for (self.entries) |e| { + if (std.mem.eql(u8, e.account, account)) { + return taxTypeLabel(e.tax_type); + } + } + return "Unknown"; + } +}; + +/// Map raw tax_type strings to display labels. +fn taxTypeLabel(raw: []const u8) []const u8 { + if (std.mem.eql(u8, raw, "taxable")) return "Taxable"; + if (std.mem.eql(u8, raw, "roth")) return "Roth (Post-Tax)"; + if (std.mem.eql(u8, raw, "traditional")) return "Traditional (Pre-Tax)"; + if (std.mem.eql(u8, raw, "hsa")) return "HSA (Triple Tax-Free)"; + return raw; +} + +/// Parse an accounts.srf file into an AccountMap. +/// Format: account::,tax_type:: +pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { + var entries = std.ArrayList(AccountTaxEntry).empty; + errdefer { + for (entries.items) |e| { + allocator.free(e.account); + allocator.free(e.tax_type); + } + entries.deinit(allocator); + } + + var line_iter = std.mem.splitScalar(u8, data, '\n'); + while (line_iter.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (std.mem.startsWith(u8, trimmed, "#!")) continue; + + var account: ?[]const u8 = null; + var tax_type: ?[]const u8 = null; + + var field_iter = std.mem.splitScalar(u8, trimmed, ','); + while (field_iter.next()) |field| { + const f = std.mem.trim(u8, field, &std.ascii.whitespace); + if (std.mem.startsWith(u8, f, "account::")) { + account = f["account::".len..]; + } else if (std.mem.startsWith(u8, f, "tax_type::")) { + tax_type = f["tax_type::".len..]; + } + } + + const acct = account orelse continue; + const tt = tax_type orelse continue; + try entries.append(allocator, .{ + .account = try allocator.dupe(u8, acct), + .tax_type = try allocator.dupe(u8, tt), + }); + } + + return .{ + .entries = try entries.toOwnedSlice(allocator), + .allocator = allocator, + }; +} + +/// Complete portfolio analysis result. +pub const AnalysisResult = struct { + /// Breakdown by asset class (US Large Cap, Bonds, Cash & CDs, etc.) + asset_class: []BreakdownItem, + /// Breakdown by sector (Technology, Healthcare, etc.) -- equities only + sector: []BreakdownItem, + /// Breakdown by geographic region (US, International, etc.) + geo: []BreakdownItem, + /// Breakdown by account name + account: []BreakdownItem, + /// Breakdown by tax type (Taxable, Roth, Traditional, HSA) + tax_type: []BreakdownItem, + /// Positions not covered by classification metadata + unclassified: []const []const u8, + /// Total portfolio value used as denominator + total_value: f64, + + pub fn deinit(self: *AnalysisResult, allocator: std.mem.Allocator) void { + allocator.free(self.asset_class); + allocator.free(self.sector); + allocator.free(self.geo); + allocator.free(self.account); + allocator.free(self.tax_type); + allocator.free(self.unclassified); + } +}; + +/// Compute portfolio analysis from allocations and classification metadata. +/// `allocations` are the stock/ETF positions with market values. +/// `classifications` is the metadata file data. +/// `portfolio` is the full portfolio (for cash/CD/illiquid totals). +/// `account_map` is optional account tax type metadata. +pub fn analyzePortfolio( + allocator: std.mem.Allocator, + allocations: []const Allocation, + classifications: ClassificationMap, + portfolio: Portfolio, + total_portfolio_value: f64, + account_map: ?AccountMap, +) !AnalysisResult { + // Accumulators: label -> dollar amount + var ac_map = std.StringHashMap(f64).init(allocator); + defer ac_map.deinit(); + var sector_map = std.StringHashMap(f64).init(allocator); + defer sector_map.deinit(); + var geo_map = std.StringHashMap(f64).init(allocator); + defer geo_map.deinit(); + var acct_map = std.StringHashMap(f64).init(allocator); + defer acct_map.deinit(); + var tax_map = std.StringHashMap(f64).init(allocator); + defer tax_map.deinit(); + + var unclassified_list = std.ArrayList([]const u8).empty; + errdefer unclassified_list.deinit(allocator); + + // Process each equity allocation (for asset class, sector, geo, unclassified) + for (allocations) |alloc| { + const mv = alloc.market_value; + if (mv <= 0) continue; + + // Find classification entries for this symbol + // Try both the raw symbol and display_symbol + var found = false; + for (classifications.entries) |entry| { + if (std.mem.eql(u8, entry.symbol, alloc.symbol) or + std.mem.eql(u8, entry.symbol, alloc.display_symbol)) + { + found = true; + const frac = entry.pct / 100.0; + const portion = mv * frac; + + if (entry.asset_class) |ac| { + const prev = ac_map.get(ac) orelse 0; + ac_map.put(ac, prev + portion) catch {}; + } + if (entry.sector) |s| { + const prev = sector_map.get(s) orelse 0; + sector_map.put(s, prev + portion) catch {}; + } + if (entry.geo) |g| { + const prev = geo_map.get(g) orelse 0; + geo_map.put(g, prev + portion) catch {}; + } + } + } + if (!found) { + try unclassified_list.append(allocator, alloc.display_symbol); + } + } + + // Build symbol -> current_price lookup from allocations (for lot-level valuation) + var price_lookup = std.StringHashMap(f64).init(allocator); + defer price_lookup.deinit(); + for (allocations) |alloc| { + price_lookup.put(alloc.symbol, alloc.current_price) catch {}; + } + + // Account breakdown from individual lots (avoids "Multiple" aggregation issue) + for (portfolio.lots) |lot| { + if (!lot.isOpen()) continue; + const acct = lot.account orelse continue; + const value: f64 = switch (lot.lot_type) { + .stock => blk: { + const price = price_lookup.get(lot.priceSymbol()) orelse lot.open_price; + break :blk lot.shares * price; + }, + .cash => lot.shares, + .cd => lot.shares, // face value + .option => @abs(lot.shares) * lot.open_price, + .illiquid, .watch => continue, + }; + const prev = acct_map.get(acct) orelse 0; + acct_map.put(acct, prev + value) catch {}; + } + + // Add non-stock asset classes (combine Cash + CDs) + const cash_total = portfolio.totalCash(); + const cd_total = portfolio.totalCdFaceValue(); + const cash_cd_total = cash_total + cd_total; + if (cash_cd_total > 0) { + const prev = ac_map.get("Cash & CDs") orelse 0; + ac_map.put("Cash & CDs", prev + cash_cd_total) catch {}; + const gprev = geo_map.get("US") orelse 0; + geo_map.put("US", gprev + cash_cd_total) catch {}; + } + const opt_total = portfolio.totalOptionCost(); + if (opt_total > 0) { + const prev = ac_map.get("Options") orelse 0; + ac_map.put("Options", prev + opt_total) catch {}; + } + + // Tax type breakdown: map each account's total to its tax type + if (account_map) |am| { + var acct_iter = acct_map.iterator(); + while (acct_iter.next()) |kv| { + const tt = am.taxTypeFor(kv.key_ptr.*); + const prev = tax_map.get(tt) orelse 0; + tax_map.put(tt, prev + kv.value_ptr.*) catch {}; + } + } + + // Convert maps to sorted slices + const total = if (total_portfolio_value > 0) total_portfolio_value else 1.0; + + return .{ + .asset_class = try mapToSortedBreakdown(allocator, ac_map, total), + .sector = try mapToSortedBreakdown(allocator, sector_map, total), + .geo = try mapToSortedBreakdown(allocator, geo_map, total), + .account = try mapToSortedBreakdown(allocator, acct_map, total), + .tax_type = try mapToSortedBreakdown(allocator, tax_map, total), + .unclassified = try unclassified_list.toOwnedSlice(allocator), + .total_value = total_portfolio_value, + }; +} + +/// Convert a label->value HashMap to a sorted BreakdownItem slice (descending by value). +fn mapToSortedBreakdown( + allocator: std.mem.Allocator, + map: std.StringHashMap(f64), + total: f64, +) ![]BreakdownItem { + var items = std.ArrayList(BreakdownItem).empty; + errdefer items.deinit(allocator); + + var iter = map.iterator(); + while (iter.next()) |kv| { + try items.append(allocator, .{ + .label = kv.key_ptr.*, + .value = kv.value_ptr.*, + .weight = kv.value_ptr.* / total, + }); + } + + // Sort descending by value + std.mem.sort(BreakdownItem, items.items, {}, struct { + fn f(_: void, a: BreakdownItem, b: BreakdownItem) bool { + return a.value > b.value; + } + }.f); + + return items.toOwnedSlice(allocator); +} + +test "parseAccountsFile" { + const data = + \\#!srfv1 + \\account::Emil Roth,tax_type::roth + \\account::Joint trust,tax_type::taxable + \\account::Fidelity Emil HSA,tax_type::hsa + ; + const allocator = std.testing.allocator; + var am = try parseAccountsFile(allocator, data); + defer am.deinit(); + + try std.testing.expectEqual(@as(usize, 3), am.entries.len); + try std.testing.expectEqualStrings("Roth (Post-Tax)", am.taxTypeFor("Emil Roth")); + try std.testing.expectEqualStrings("Taxable", am.taxTypeFor("Joint trust")); + try std.testing.expectEqualStrings("HSA (Triple Tax-Free)", am.taxTypeFor("Fidelity Emil HSA")); + try std.testing.expectEqualStrings("Unknown", am.taxTypeFor("Nonexistent")); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 850e2b7..d4fbbfc 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -16,8 +16,8 @@ const usage = \\ options Show options chain (all expirations) \\ 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) + \\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf) + \\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) \\ lookup Look up CUSIP to ticker via OpenFIGI \\ cache stats Show cache statistics \\ cache clear Clear all cached data @@ -37,12 +37,14 @@ const usage = \\ --ntm Show +/- N strikes near the money (default: 8) \\ \\Portfolio command options: + \\ If no file is given, defaults to portfolio.srf in the current directory. \\ -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. + \\ If no file is given, defaults to portfolio.srf in the current directory. \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) @@ -135,20 +137,24 @@ pub fn main() !void { if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n"); try cmdEtf(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "portfolio")) { - if (args.len < 3) return try stderr_print("Error: 'portfolio' requires a file path argument\n"); - // Parse -w/--watchlist and --refresh flags + // Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf) var watchlist_path: ?[]const u8 = null; var force_refresh = false; - var pi: usize = 3; + var file_path: []const u8 = "portfolio.srf"; + var pi: usize = 2; while (pi < args.len) : (pi += 1) { if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) { pi += 1; watchlist_path = args[pi]; } else if (std.mem.eql(u8, args[pi], "--refresh")) { force_refresh = true; + } else if (std.mem.eql(u8, args[pi], "--no-color")) { + // already handled globally + } else { + file_path = args[pi]; } } - try cmdPortfolio(allocator, config, &svc, args[2], watchlist_path, force_refresh, color); + try cmdPortfolio(allocator, config, &svc, file_path, watchlist_path, force_refresh, color); } else if (std.mem.eql(u8, command, "lookup")) { if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n"); try cmdLookup(allocator, &svc, args[2], color); @@ -159,8 +165,15 @@ pub fn main() !void { 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); + // File path is first non-flag arg (default: portfolio.srf) + var analysis_file: []const u8 = "portfolio.srf"; + for (args[2..]) |arg| { + if (!std.mem.startsWith(u8, arg, "--")) { + analysis_file = arg; + break; + } + } + try cmdAnalysis(allocator, config, &svc, analysis_file, color); } else { try stderr_print("Unknown command. Run 'zfin help' for usage.\n"); } diff --git a/src/models/classification.zig b/src/models/classification.zig new file mode 100644 index 0000000..4d61aa9 --- /dev/null +++ b/src/models/classification.zig @@ -0,0 +1,128 @@ +/// Classification metadata for portfolio analysis. +/// +/// Each entry maps a symbol to one or more asset class / sector / geographic allocations. +/// For individual stocks, there's typically one entry at 100%. +/// For blended funds (e.g., target date), there can be multiple entries that sum to ~100%. +/// +/// Loaded from a metadata SRF file like `metadata.srf`: +/// symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap +/// symbol::02315N600,asset_class::US Large Cap,pct:num:55 +/// symbol::02315N600,asset_class::International Developed,pct:num:20 +/// symbol::02315N600,asset_class::Bonds,pct:num:15 + +const std = @import("std"); + +/// A single classification entry for a symbol. +pub const ClassificationEntry = struct { + symbol: []const u8, + /// Sector (e.g., "Technology", "Healthcare", "Financials") + sector: ?[]const u8 = null, + /// Geographic region (e.g., "US", "International Developed", "Emerging Markets") + geo: ?[]const u8 = null, + /// Asset class (e.g., "US Large Cap", "Bonds", "Cash") + asset_class: ?[]const u8 = null, + /// Percentage weight for this entry (0-100). Default 100 for single-class assets. + pct: f64 = 100.0, +}; + +/// Parsed classification data for the entire portfolio. +pub const ClassificationMap = struct { + entries: []ClassificationEntry, + allocator: std.mem.Allocator, + + pub fn deinit(self: *ClassificationMap) void { + for (self.entries) |e| { + self.allocator.free(e.symbol); + if (e.sector) |s| self.allocator.free(s); + if (e.geo) |g| self.allocator.free(g); + if (e.asset_class) |a| self.allocator.free(a); + } + self.allocator.free(self.entries); + } +}; + +/// Parse a metadata SRF file into a ClassificationMap. +/// Each line is: symbol::,sector::,geo::,asset_class::,pct:num:

+/// All fields except symbol are optional. pct defaults to 100. +pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !ClassificationMap { + var entries = std.ArrayList(ClassificationEntry).empty; + errdefer { + for (entries.items) |e| { + allocator.free(e.symbol); + if (e.sector) |s| allocator.free(s); + if (e.geo) |g| allocator.free(g); + if (e.asset_class) |a| allocator.free(a); + } + entries.deinit(allocator); + } + + var line_iter = std.mem.splitScalar(u8, data, '\n'); + while (line_iter.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (std.mem.startsWith(u8, trimmed, "#!")) continue; + + // Parse comma-separated key::value pairs + var symbol: ?[]const u8 = null; + var sector: ?[]const u8 = null; + var geo: ?[]const u8 = null; + var asset_class: ?[]const u8 = null; + var pct: f64 = 100.0; + + var field_iter = std.mem.splitScalar(u8, trimmed, ','); + while (field_iter.next()) |field| { + const f = std.mem.trim(u8, field, &std.ascii.whitespace); + if (std.mem.startsWith(u8, f, "symbol::")) { + symbol = f["symbol::".len..]; + } else if (std.mem.startsWith(u8, f, "sector::")) { + sector = f["sector::".len..]; + } else if (std.mem.startsWith(u8, f, "geo::")) { + geo = f["geo::".len..]; + } else if (std.mem.startsWith(u8, f, "asset_class::")) { + asset_class = f["asset_class::".len..]; + } else if (std.mem.startsWith(u8, f, "pct:num:")) { + pct = std.fmt.parseFloat(f64, f["pct:num:".len..]) catch 100.0; + } + } + + const sym = symbol orelse continue; // skip lines without symbol + try entries.append(allocator, .{ + .symbol = try allocator.dupe(u8, sym), + .sector = if (sector) |s| try allocator.dupe(u8, s) else null, + .geo = if (geo) |g| try allocator.dupe(u8, g) else null, + .asset_class = if (asset_class) |a| try allocator.dupe(u8, a) else null, + .pct = pct, + }); + } + + return .{ + .entries = try entries.toOwnedSlice(allocator), + .allocator = allocator, + }; +} + +test "parse classification file" { + const data = + \\#!srfv1 + \\# Stock: single sector + \\symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap + \\ + \\# Target date fund: blended + \\symbol::TGT2035,asset_class::US Large Cap,pct:num:55 + \\symbol::TGT2035,asset_class::Bonds,pct:num:15 + \\symbol::TGT2035,asset_class::International Developed,pct:num:20 + ; + const allocator = std.testing.allocator; + var cm = try parseClassificationFile(allocator, data); + defer cm.deinit(); + + try std.testing.expectEqual(@as(usize, 4), cm.entries.len); + try std.testing.expectEqualStrings("AMZN", cm.entries[0].symbol); + try std.testing.expectEqualStrings("Technology", cm.entries[0].sector.?); + try std.testing.expectEqualStrings("US", cm.entries[0].geo.?); + try std.testing.expectApproxEqAbs(@as(f64, 100.0), cm.entries[0].pct, 0.01); + + try std.testing.expectEqualStrings("TGT2035", cm.entries[1].symbol); + try std.testing.expectEqualStrings("US Large Cap", cm.entries[1].asset_class.?); + try std.testing.expectApproxEqAbs(@as(f64, 55.0), cm.entries[1].pct, 0.01); +} diff --git a/src/tui/main.zig b/src/tui/main.zig index 113d2f0..7c756a0 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -1859,6 +1859,17 @@ const App = struct { self.sortPortfolioAllocations(); self.rebuildPortfolioRows(); + // Invalidate analysis data -- it holds pointers into old portfolio memory + if (self.analysis_result) |*ar| ar.deinit(self.allocator); + self.analysis_result = null; + self.analysis_loaded = false; + + // If currently on the analysis tab, eagerly recompute so the user + // doesn't see an error message before switching away and back. + if (self.active_tab == .analysis) { + self.loadAnalysisData(); + } + if (missing > 0) { var warn_buf: [128]u8 = undefined; const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)"; @@ -3486,7 +3497,7 @@ const App = struct { 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() }); + try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } // Sector breakdown @@ -3496,7 +3507,7 @@ const App = struct { 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() }); + try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } } @@ -3507,7 +3518,7 @@ const App = struct { 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() }); + try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } } @@ -3518,7 +3529,7 @@ const App = struct { 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() }); + try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } } @@ -3529,7 +3540,7 @@ const App = struct { 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() }); + try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width }); } } diff --git a/src/tui/theme.zig b/src/tui/theme.zig index 4a834f7..c577ef7 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -44,6 +44,9 @@ pub const Theme = struct { // Border border: Color, + // Chart / data visualization + bar_fill: Color, + pub fn vcolor(c: Color) vaxis.Cell.Color { return .{ .rgb = c }; } @@ -115,6 +118,10 @@ pub const Theme = struct { pub fn warningStyle(self: Theme) vaxis.Style { return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) }; } + + pub fn barFillStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.bar_fill), .bg = vcolor(self.bg) }; + } }; // Monokai-inspired dark theme, influenced by opencode color system. @@ -151,6 +158,8 @@ pub const default_theme = Theme{ .select_fg = .{ 0xff, 0xc0, 0x9f }, // bright orange (opencode darkStep10) .border = .{ 0x3c, 0x3c, 0x3c }, // subtle border (opencode darkStep6) + + .bar_fill = .{ 0x89, 0xb4, 0xfa }, // blue (bar chart fill, matches CLI accent) }; // ── SRF serialization ──────────────────────────────────────── @@ -179,6 +188,7 @@ const field_names = [_]struct { name: []const u8, offset: usize }{ .{ .name = "select_bg", .offset = @offsetOf(Theme, "select_bg") }, .{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") }, .{ .name = "border", .offset = @offsetOf(Theme, "border") }, + .{ .name = "bar_fill", .offset = @offsetOf(Theme, "bar_fill") }, }; fn colorPtr(theme: *Theme, offset: usize) *Color {