ai: analysis tab, various fixes
This commit is contained in:
parent
0e81df90aa
commit
6e78818f1c
6 changed files with 697 additions and 31 deletions
237
README.md
237
README.md
|
|
@ -16,6 +16,8 @@ zig build run -- perf VTI # trailing returns
|
||||||
zig build run -- quote AAPL # real-time quote
|
zig build run -- quote AAPL # real-time quote
|
||||||
zig build run -- options AAPL # options chains
|
zig build run -- options AAPL # options chains
|
||||||
zig build run -- earnings MSFT # earnings history
|
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
|
# Interactive TUI
|
||||||
zig build run -- i # auto-loads portfolio.srf from cwd
|
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
|
## 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
|
### 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.
|
**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
|
### Keybindings
|
||||||
|
|
||||||
All keybindings are configurable via `~/.config/zfin/keys.srf`. Generate the default config:
|
All keybindings are configurable via `~/.config/zfin/keys.srf`. Generate the default config:
|
||||||
|
|
@ -210,15 +214,21 @@ Default keybindings:
|
||||||
|---|---|
|
|---|---|
|
||||||
| `q`, `Ctrl+c` | Quit |
|
| `q`, `Ctrl+c` | Quit |
|
||||||
| `r`, `F5` | Refresh current tab (invalidates cache) |
|
| `r`, `F5` | Refresh current tab (invalidates cache) |
|
||||||
|
| `R` | Reload portfolio from disk (no network) |
|
||||||
| `h`, Left | Previous tab |
|
| `h`, Left | Previous tab |
|
||||||
| `l`, Right, Tab | Next tab |
|
| `l`, Right, Tab | Next tab |
|
||||||
| `1`-`5` | Jump to tab |
|
| `1`-`6` | Jump to tab |
|
||||||
| `j`, Down | Select next row |
|
| `j`, Down | Select next row |
|
||||||
| `k`, Up | Select previous row |
|
| `k`, Up | Select previous row |
|
||||||
| `Enter` | Expand/collapse (positions, expirations, calls/puts) |
|
| `Enter` | Expand/collapse (positions, expirations, calls/puts) |
|
||||||
| `s` | Select symbol from portfolio for other tabs |
|
| `s` | Select symbol from portfolio for other tabs |
|
||||||
| `/` | Enter symbol search |
|
| `/` | Enter symbol search |
|
||||||
| `e` | Edit portfolio/watchlist in `$EDITOR` |
|
| `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) |
|
| `c` | Toggle all calls collapsed/expanded (options tab) |
|
||||||
| `p` | Toggle all puts 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 |
|
| `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
|
## 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
|
#!srfv1
|
||||||
symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50
|
# Stocks/ETFs
|
||||||
symbol::AAPL,shares:num:50,open_date::2024-03-01,open_price:num:170.00
|
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
|
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
|
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
|
### Lot fields
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `symbol` | string | Yes | Ticker symbol |
|
| `symbol` | string | Yes* | Ticker symbol or CUSIP. *Optional for `cash` lots. |
|
||||||
| `shares` | number | Yes | Number of shares |
|
| `shares` | number | Yes | Number of shares (or face value for cash/CDs) |
|
||||||
| `open_date` | string | Yes | Purchase date (YYYY-MM-DD) |
|
| `open_date` | string | Yes** | Purchase date (YYYY-MM-DD). **Not required for cash/watch. |
|
||||||
| `open_price` | number | Yes | Purchase price per share |
|
| `open_price` | number | Yes** | Purchase price per share. **Not required for cash/watch. |
|
||||||
| `close_date` | string | No | Sale date (null = open lot) |
|
| `close_date` | string | No | Sale date (null = open lot) |
|
||||||
| `close_price` | number | No | Sale price per share |
|
| `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") |
|
| `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
|
#!srfv1
|
||||||
symbol::NVDA
|
# Individual stock: single classification at 100%
|
||||||
symbol::TSLA
|
symbol::AMZN,sector::Technology,geo::US,asset_class::US Large Cap
|
||||||
symbol::GOOG
|
|
||||||
|
# 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
root.zig Library root, exports all public types
|
root.zig Library root, exports all public types
|
||||||
|
format.zig Shared formatters, braille engine, ANSI helpers
|
||||||
config.zig Configuration from env vars / .env files
|
config.zig Configuration from env vars / .env files
|
||||||
service.zig DataService: cache-check -> fetch -> cache -> return
|
service.zig DataService: cache-check -> fetch -> cache -> return
|
||||||
models/
|
models/
|
||||||
|
|
@ -298,6 +487,7 @@ src/
|
||||||
earnings.zig Earnings events with surprise calculation
|
earnings.zig Earnings events with surprise calculation
|
||||||
etf_profile.zig ETF profiles with holdings and sectors
|
etf_profile.zig ETF profiles with holdings and sectors
|
||||||
portfolio.zig Lots, positions, and portfolio aggregation
|
portfolio.zig Lots, positions, and portfolio aggregation
|
||||||
|
classification.zig Classification metadata parser
|
||||||
quote.zig Real-time quote data
|
quote.zig Real-time quote data
|
||||||
ticker_info.zig Security metadata
|
ticker_info.zig Security metadata
|
||||||
providers/
|
providers/
|
||||||
|
|
@ -306,10 +496,13 @@ src/
|
||||||
polygon.zig Polygon: dividends, splits
|
polygon.zig Polygon: dividends, splits
|
||||||
finnhub.zig Finnhub: earnings
|
finnhub.zig Finnhub: earnings
|
||||||
cboe.zig CBOE: options chains (no API key)
|
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/
|
analytics/
|
||||||
|
indicators.zig SMA, Bollinger Bands, RSI
|
||||||
performance.zig Trailing returns (as-of-date + month-end)
|
performance.zig Trailing returns (as-of-date + month-end)
|
||||||
risk.zig Volatility, Sharpe, drawdown, portfolio summary
|
risk.zig Volatility, Sharpe, drawdown, portfolio summary
|
||||||
|
analysis.zig Portfolio analysis engine (breakdowns by class/sector/geo/account/tax)
|
||||||
cache/
|
cache/
|
||||||
store.zig SRF file cache with TTL freshness checks
|
store.zig SRF file cache with TTL freshness checks
|
||||||
net/
|
net/
|
||||||
|
|
@ -319,10 +512,18 @@ src/
|
||||||
main.zig CLI entry point and all commands
|
main.zig CLI entry point and all commands
|
||||||
tui/
|
tui/
|
||||||
main.zig Interactive TUI application
|
main.zig Interactive TUI application
|
||||||
|
chart.zig z2d pixel chart renderer (Kitty graphics)
|
||||||
keybinds.zig Configurable keybinding system
|
keybinds.zig Configurable keybinding system
|
||||||
theme.zig Configurable color theme
|
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
|
### Dependencies
|
||||||
|
|
||||||
| Dependency | Source | Purpose |
|
| Dependency | Source | Purpose |
|
||||||
|
|
|
||||||
303
src/analytics/analysis.zig
Normal file
303
src/analytics/analysis.zig
Normal 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"));
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,8 @@ const usage =
|
||||||
\\ options <SYMBOL> Show options chain (all expirations)
|
\\ options <SYMBOL> Show options chain (all expirations)
|
||||||
\\ earnings <SYMBOL> Show earnings history and upcoming
|
\\ earnings <SYMBOL> Show earnings history and upcoming
|
||||||
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
|
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
|
||||||
\\ portfolio <FILE> Load and analyze a portfolio (.srf file)
|
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
|
||||||
\\ analysis <FILE> Show portfolio analysis (asset class, sector, geo, account, tax type)
|
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
|
||||||
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
|
||||||
\\ cache stats Show cache statistics
|
\\ cache stats Show cache statistics
|
||||||
\\ cache clear Clear all cached data
|
\\ cache clear Clear all cached data
|
||||||
|
|
@ -37,12 +37,14 @@ const usage =
|
||||||
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
|
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
|
||||||
\\
|
\\
|
||||||
\\Portfolio command options:
|
\\Portfolio command options:
|
||||||
|
\\ If no file is given, defaults to portfolio.srf in the current directory.
|
||||||
\\ -w, --watchlist <FILE> Watchlist file
|
\\ -w, --watchlist <FILE> Watchlist file
|
||||||
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
|
||||||
\\
|
\\
|
||||||
\\Analysis command:
|
\\Analysis command:
|
||||||
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
|
||||||
\\ from the same directory as the portfolio file.
|
\\ from the same directory as the portfolio file.
|
||||||
|
\\ If no file is given, defaults to portfolio.srf in the current directory.
|
||||||
\\
|
\\
|
||||||
\\Environment Variables:
|
\\Environment Variables:
|
||||||
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
|
\\ 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");
|
if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n");
|
||||||
try cmdEtf(allocator, &svc, args[2], color);
|
try cmdEtf(allocator, &svc, args[2], color);
|
||||||
} else if (std.mem.eql(u8, command, "portfolio")) {
|
} 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; file path is first non-flag arg (default: portfolio.srf)
|
||||||
// Parse -w/--watchlist and --refresh flags
|
|
||||||
var watchlist_path: ?[]const u8 = null;
|
var watchlist_path: ?[]const u8 = null;
|
||||||
var force_refresh = false;
|
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) {
|
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) {
|
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
|
||||||
pi += 1;
|
pi += 1;
|
||||||
watchlist_path = args[pi];
|
watchlist_path = args[pi];
|
||||||
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
|
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
|
||||||
force_refresh = true;
|
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")) {
|
} else if (std.mem.eql(u8, command, "lookup")) {
|
||||||
if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n");
|
if (args.len < 3) return try stderr_print("Error: 'lookup' requires a CUSIP argument\n");
|
||||||
try cmdLookup(allocator, &svc, args[2], color);
|
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");
|
if (args.len < 3) return try stderr_print("Error: 'enrich' requires a portfolio file path\n");
|
||||||
try cmdEnrich(allocator, config, args[2]);
|
try cmdEnrich(allocator, config, args[2]);
|
||||||
} else if (std.mem.eql(u8, command, "analysis")) {
|
} else if (std.mem.eql(u8, command, "analysis")) {
|
||||||
if (args.len < 3) return try stderr_print("Error: 'analysis' requires a portfolio file path\n");
|
// File path is first non-flag arg (default: portfolio.srf)
|
||||||
try cmdAnalysis(allocator, config, &svc, args[2], color);
|
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 {
|
} else {
|
||||||
try stderr_print("Unknown command. Run 'zfin help' for usage.\n");
|
try stderr_print("Unknown command. Run 'zfin help' for usage.\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
src/models/classification.zig
Normal file
128
src/models/classification.zig
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1859,6 +1859,17 @@ const App = struct {
|
||||||
self.sortPortfolioAllocations();
|
self.sortPortfolioAllocations();
|
||||||
self.rebuildPortfolioRows();
|
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) {
|
if (missing > 0) {
|
||||||
var warn_buf: [128]u8 = undefined;
|
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)";
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
for (result.asset_class) |item| {
|
for (result.asset_class) |item| {
|
||||||
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
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
|
// Sector breakdown
|
||||||
|
|
@ -3496,7 +3507,7 @@ const App = struct {
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
for (result.sector) |item| {
|
for (result.sector) |item| {
|
||||||
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
for (result.geo) |item| {
|
for (result.geo) |item| {
|
||||||
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
for (result.account) |item| {
|
for (result.account) |item| {
|
||||||
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
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() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
for (result.tax_type) |item| {
|
for (result.tax_type) |item| {
|
||||||
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ pub const Theme = struct {
|
||||||
// Border
|
// Border
|
||||||
border: Color,
|
border: Color,
|
||||||
|
|
||||||
|
// Chart / data visualization
|
||||||
|
bar_fill: Color,
|
||||||
|
|
||||||
pub fn vcolor(c: Color) vaxis.Cell.Color {
|
pub fn vcolor(c: Color) vaxis.Cell.Color {
|
||||||
return .{ .rgb = c };
|
return .{ .rgb = c };
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +118,10 @@ pub const Theme = struct {
|
||||||
pub fn warningStyle(self: Theme) vaxis.Style {
|
pub fn warningStyle(self: Theme) vaxis.Style {
|
||||||
return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) };
|
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.
|
// 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)
|
.select_fg = .{ 0xff, 0xc0, 0x9f }, // bright orange (opencode darkStep10)
|
||||||
|
|
||||||
.border = .{ 0x3c, 0x3c, 0x3c }, // subtle border (opencode darkStep6)
|
.border = .{ 0x3c, 0x3c, 0x3c }, // subtle border (opencode darkStep6)
|
||||||
|
|
||||||
|
.bar_fill = .{ 0x89, 0xb4, 0xfa }, // blue (bar chart fill, matches CLI accent)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── SRF serialization ────────────────────────────────────────
|
// ── 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_bg", .offset = @offsetOf(Theme, "select_bg") },
|
||||||
.{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") },
|
.{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") },
|
||||||
.{ .name = "border", .offset = @offsetOf(Theme, "border") },
|
.{ .name = "border", .offset = @offsetOf(Theme, "border") },
|
||||||
|
.{ .name = "bar_fill", .offset = @offsetOf(Theme, "bar_fill") },
|
||||||
};
|
};
|
||||||
|
|
||||||
fn colorPtr(theme: *Theme, offset: usize) *Color {
|
fn colorPtr(theme: *Theme, offset: usize) *Color {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue