ai: analysis tab, various fixes

This commit is contained in:
Emil Lerch 2026-02-27 08:15:28 -08:00
parent 0e81df90aa
commit 6e78818f1c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 697 additions and 31 deletions

237
README.md
View file

@ -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 <command> [args]
Commands:
perf <SYMBOL> Trailing returns (1yr/3yr/5yr/10yr, price + total)
quote <SYMBOL> Real-time quote with chart
history <SYMBOL> Last 30 days price history
divs <SYMBOL> Dividend history with TTM yield
splits <SYMBOL> Split history
options <SYMBOL> Options chains (all expirations)
earnings <SYMBOL> Earnings history and upcoming events
etf <SYMBOL> ETF profile (expense ratio, holdings, sectors)
portfolio [FILE] Portfolio summary (default: portfolio.srf)
analysis [FILE] Portfolio analysis breakdowns (default: portfolio.srf)
enrich <FILE> Generate metadata.srf from Alpha Vantage
lookup <CUSIP> 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 <FILE> 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 |

303
src/analytics/analysis.zig Normal file
View file

@ -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::<NAME>,tax_type::<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"));
}

View file

@ -16,8 +16,8 @@ const usage =
\\ options <SYMBOL> Show options chain (all expirations)
\\ 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)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ lookup <CUSIP> 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 <N> 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 <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.
\\ 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");
}

View file

@ -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::<SYM>,sector::<S>,geo::<G>,asset_class::<A>,pct:num:<P>
/// 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);
}

View file

@ -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 });
}
}

View file

@ -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 {