Compare commits

...

9 commits

Author SHA1 Message Date
5ee2151a47
better heuristics on cash-like thingies
All checks were successful
Generic zig build / build (push) Successful in 1m7s
2026-04-10 09:50:22 -07:00
ddd47dad66
better account filter for interactive mode 2026-04-10 09:39:26 -07:00
c3c9c21556
make ux more useful 2026-04-10 08:38:51 -07:00
6fb582e3da
add schwab summary and account level audit 2026-04-10 08:02:57 -07:00
9337c198f4
reuse common account filtering in portfolio 2026-04-09 17:41:25 -07:00
f55af318f2
add todo 2026-04-09 17:33:55 -07:00
ddf89f926f
file path seperators 2026-04-09 17:33:41 -07:00
f3f9a20824
add audit command (fidelity only for now) 2026-04-09 17:33:02 -07:00
d7a4aa6f63
add international etfs to metadata 2026-04-09 17:04:00 -07:00
10 changed files with 2067 additions and 120 deletions

24
TODO.md
View file

@ -52,6 +52,30 @@ previously selected one (like `cd -` in bash or `Ctrl+^` in vim). Store
key swaps current and last. Works on any tab — particularly useful for
eyeball-comparing performance/risk data between two symbols.
## Fix `enrich` command for international funds
`deriveMetadata` in `src/commands/enrich.zig` misclassifies international ETFs:
1. **`geo`** uses Alpha Vantage's `Country` field, which is the *fund issuer's*
domicile (USA for all US-listed ETFs), not the fund's investment geography.
Every US-domiciled international fund gets `geo::US`.
2. **`asset_class`** short-circuits to `"ETF"` when `asset_type == "ETF"`, or
falls through to a US-market-cap heuristic that always produces
`"US Large Cap"` / `"US Mid Cap"` / `"US Small Cap"`.
Known misclassified tickers (all came back as `geo::US, asset_class::US Large Cap`):
- **FRDM** — Freedom 100 Emerging Markets ETF → should be `geo::Emerging Markets, asset_class::Emerging Markets`
- **HFXI** — NYLI FTSE International Equity Currency Neutral ETF → should be `geo::International Developed, asset_class::International Developed`
- **IDMO** — Invesco S&P International Developed Momentum ETF → should be `geo::International Developed, asset_class::International Developed`
- **IVLU** — iShares MSCI International Developed Value Factor ETF → should be `geo::International Developed, asset_class::International Developed`
The Alpha Vantage OVERVIEW endpoint doesn't provide fund geography data.
Options: use the ETF_PROFILE holdings/country data to infer geography, parse
the fund name for keywords ("International", "Emerging", "ex-US"), or accept
that `enrich` is a scaffold and emit a `# TODO` comment for ETFs instead of
silently misclassifying.
## Market-aware cache TTL for daily candles
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful

View file

@ -110,3 +110,16 @@ symbol::ORC42,asset_class::Bonds,geo::US,pct:num:8.33
symbol::VFORX,geo::US,asset_class::US Large Cap
symbol::VTTHX,geo::US,asset_class::US Large Cap
# ── International ETFs ───────────────────────────────────────
# Freedom 100 Emerging Markets ETF
symbol::FRDM,sector::Diversified,geo::Emerging Markets,asset_class::Emerging Markets
# NYLI FTSE International Equity Currency Neutral ETF (50% hedged)
symbol::HFXI,sector::Diversified,geo::International Developed,asset_class::International Developed
# Invesco S&P International Developed Momentum ETF
symbol::IDMO,sector::Diversified,geo::International Developed,asset_class::International Developed
# iShares MSCI International Developed Value Factor ETF
symbol::IVLU,sector::Diversified,geo::International Developed,asset_class::International Developed

View file

@ -38,6 +38,8 @@ pub const TaxType = enum {
pub const AccountTaxEntry = struct {
account: []const u8,
tax_type: TaxType,
institution: ?[]const u8 = null,
account_number: ?[]const u8 = null,
};
/// Parsed account metadata.
@ -48,6 +50,8 @@ pub const AccountMap = struct {
pub fn deinit(self: *AccountMap) void {
for (self.entries) |e| {
self.allocator.free(e.account);
if (e.institution) |s| self.allocator.free(s);
if (e.account_number) |s| self.allocator.free(s);
}
self.allocator.free(self.entries);
}
@ -61,15 +65,42 @@ pub const AccountMap = struct {
}
return "Unknown";
}
/// Find the portfolio account name for a given institution + account number.
pub fn findByInstitutionAccount(self: AccountMap, institution: []const u8, account_number: []const u8) ?[]const u8 {
for (self.entries) |e| {
if (e.institution) |inst| {
if (e.account_number) |num| {
if (std.mem.eql(u8, inst, institution) and std.mem.eql(u8, num, account_number))
return e.account;
}
}
}
return null;
}
/// Return all entries matching a given institution.
pub fn entriesForInstitution(self: AccountMap, institution: []const u8) []const AccountTaxEntry {
var count: usize = 0;
for (self.entries) |e| {
if (e.institution) |inst| {
if (std.mem.eql(u8, inst, institution)) count += 1;
}
}
if (count == 0) return &.{};
return self.entries;
}
};
/// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
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);
if (e.institution) |s| allocator.free(s);
if (e.account_number) |s| allocator.free(s);
}
entries.deinit(allocator);
}
@ -83,6 +114,8 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
try entries.append(allocator, .{
.account = try allocator.dupe(u8, entry.account),
.tax_type = entry.tax_type,
.institution = if (entry.institution) |s| try allocator.dupe(u8, s) else null,
.account_number = if (entry.account_number) |s| try allocator.dupe(u8, s) else null,
});
}

View file

@ -48,7 +48,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
defer pf_data.deinit(allocator);
// Load classification metadata
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
defer allocator.free(meta_path);

1515
src/commands/audit.zig Normal file

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@ const usage =
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\
@ -42,6 +43,12 @@ const usage =
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\Audit command options:
\\ --fidelity <CSV> Fidelity positions CSV export (download from "All accounts" positions tab)
\\ --schwab <CSV> Schwab per-account positions CSV export
\\ --schwab-summary Schwab account summary (copy from accounts summary page, paste to stdin)
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
@ -108,6 +115,7 @@ pub fn main() !u8 {
if (args.len >= 3 and
!std.mem.eql(u8, command, "cache") and
!std.mem.eql(u8, command, "enrich") and
!std.mem.eql(u8, command, "audit") and
!std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "portfolio"))
{
@ -214,6 +222,8 @@ pub fn main() !u8 {
return 1;
}
try commands.enrich.run(allocator, &svc, args[2], out);
} else if (std.mem.eql(u8, command, "audit")) {
try commands.audit.run(allocator, &svc, args[2..], color, out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
@ -248,6 +258,7 @@ const commands = struct {
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig");
};

View file

@ -126,6 +126,9 @@ pub const Lot = struct {
/// Aggregated position for a single symbol across multiple lots.
pub const Position = struct {
symbol: []const u8,
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
/// Same as `symbol` when no ticker alias is set.
lot_symbol: []const u8 = "",
/// Total open shares
shares: f64,
/// Weighted average cost basis per share (open lots only)
@ -252,6 +255,7 @@ pub const Portfolio = struct {
if (!entry.found_existing) {
entry.value_ptr.* = .{
.symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0,
.avg_cost = 0,
.total_cost = 0,
@ -302,6 +306,78 @@ pub const Portfolio = struct {
return result.toOwnedSlice(allocator);
}
/// Aggregate stock/ETF lots into positions for a single account.
/// Same logic as positions() but filtered to lots matching `account_name`.
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
var map = std.StringHashMap(Position).init(allocator);
defer map.deinit();
for (self.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
const sym = lot.priceSymbol();
const entry = try map.getOrPut(sym);
if (!entry.found_existing) {
entry.value_ptr.* = .{
.symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 0,
.closed_lots = 0,
.realized_gain_loss = 0,
.account = lot_acct,
.note = lot.note,
.price_ratio = lot.price_ratio,
};
} else {
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
entry.value_ptr.price_ratio = lot.price_ratio;
}
}
if (lot.isOpen()) {
entry.value_ptr.shares += lot.shares;
entry.value_ptr.total_cost += lot.costBasis();
entry.value_ptr.open_lots += 1;
} else {
entry.value_ptr.closed_lots += 1;
entry.value_ptr.realized_gain_loss += lot.realizedGainLoss() orelse 0;
}
}
var iter = map.valueIterator();
while (iter.next()) |pos| {
if (pos.shares > 0) {
pos.avg_cost = pos.total_cost / pos.shares;
}
}
var result = std.ArrayList(Position).empty;
errdefer result.deinit(allocator);
var viter = map.valueIterator();
while (viter.next()) |pos| {
if (pos.open_lots == 0) continue;
try result.append(allocator, pos.*);
}
return result.toOwnedSlice(allocator);
}
/// Total cash for a single account.
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.security_type != .cash) continue;
const lot_acct = lot.account orelse continue;
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
}
return total;
}
/// Total cost basis of all open stock lots.
pub fn totalCostBasis(self: Portfolio) f64 {
var total: f64 = 0;
@ -564,3 +640,34 @@ test "positions propagates price_ratio from lot" {
}
}
}
test "positionsForAccount excludes closed-only symbols" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
// Open lot in account A
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
// Closed lot in account A (was sold)
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
// Open lot for same symbol in a different account
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Account A: should only see AAPL (XLV is fully closed there)
const pos_a = try portfolio.positionsForAccount(allocator, "Acct A");
defer allocator.free(pos_a);
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
// Account B: should see XLV with 50 shares
const pos_b = try portfolio.positionsForAccount(allocator, "Acct B");
defer allocator.free(pos_b);
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
}

View file

@ -95,6 +95,7 @@ pub const InputMode = enum {
symbol_input,
help,
account_picker,
account_search,
};
pub const StyledLine = struct {
@ -309,8 +310,15 @@ pub const App = struct {
// Account filter state
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio)
filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount)
account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (ordered by accounts.srf)
account_numbers: std.ArrayList(?[]const u8) = .empty, // account_number from accounts.srf (parallel to account_list)
account_shortcut_keys: std.ArrayList(u8) = .empty, // auto-assigned shortcut key per account (parallel to account_list)
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
account_search_buf: [64]u8 = undefined,
account_search_len: usize = 0,
account_search_matches: std.ArrayList(usize) = .empty, // indices into account_list matching search
account_search_cursor: usize = 0, // cursor within search_matches
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
@ -387,6 +395,9 @@ pub const App = struct {
if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key);
}
if (self.mode == .account_search) {
return self.handleAccountSearchKey(ctx, key);
}
if (self.mode == .help) {
self.mode = .normal;
return ctx.consumeAndRedraw();
@ -619,19 +630,44 @@ pub const App = struct {
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
// Cancel: return to normal mode without changing the filter
if (key.codepoint == vaxis.Key.escape) {
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') {
self.mode = .normal;
return ctx.consumeAndRedraw();
}
// Confirm: apply the selected account filter
if (key.codepoint == vaxis.Key.enter) {
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Use the keymap for navigation actions
// '/' enters search mode
if (key.matches('/', .{})) {
self.mode = .account_search;
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// 'A' selects "All accounts" instantly
if (key.matches('A', .{})) {
self.account_picker_cursor = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Check shortcut keys for instant selection
if (key.codepoint < std.math.maxInt(u7) and key.matches(key.codepoint, .{})) {
const ch: u8 = @intCast(key.codepoint);
for (self.account_shortcut_keys.items, 0..) |shortcut, i| {
if (shortcut == ch) {
self.account_picker_cursor = i + 1; // +1 for "All accounts" at 0
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
}
// Navigation via keymap
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.select_next => {
@ -657,6 +693,111 @@ pub const App = struct {
}
}
/// Handles keypresses in account_search mode (/ search within picker).
fn handleAccountSearchKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: cancel search, return to picker
if (key.codepoint == vaxis.Key.escape) {
self.mode = .account_picker;
self.account_search_len = 0;
return ctx.consumeAndRedraw();
}
// Enter: select the first match (or current search cursor)
if (key.codepoint == vaxis.Key.enter) {
if (self.account_search_matches.items.len > 0) {
const match_idx = self.account_search_matches.items[self.account_search_cursor];
self.account_picker_cursor = match_idx + 1; // +1 for "All accounts"
}
self.account_search_len = 0;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
// Ctrl+N / Ctrl+P or arrow keys to cycle through matches
if (key.matches('n', .{ .ctrl = true }) or key.codepoint == vaxis.Key.down) {
if (self.account_search_matches.items.len > 0 and
self.account_search_cursor < self.account_search_matches.items.len - 1)
self.account_search_cursor += 1;
return ctx.consumeAndRedraw();
}
if (key.matches('p', .{ .ctrl = true }) or key.codepoint == vaxis.Key.up) {
if (self.account_search_cursor > 0)
self.account_search_cursor -= 1;
return ctx.consumeAndRedraw();
}
// Backspace
if (key.codepoint == vaxis.Key.backspace) {
if (self.account_search_len > 0) {
self.account_search_len -= 1;
self.updateAccountSearchMatches();
}
return ctx.consumeAndRedraw();
}
// Ctrl+U: clear search
if (key.matches('u', .{ .ctrl = true })) {
self.account_search_len = 0;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
// Printable ASCII
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and self.account_search_len < self.account_search_buf.len) {
self.account_search_buf[self.account_search_len] = @intCast(key.codepoint);
self.account_search_len += 1;
self.updateAccountSearchMatches();
return ctx.consumeAndRedraw();
}
}
/// Update search match indices based on current search string.
fn updateAccountSearchMatches(self: *App) void {
self.account_search_matches.clearRetainingCapacity();
const query = self.account_search_buf[0..self.account_search_len];
if (query.len == 0) return;
var lower_query: [64]u8 = undefined;
for (query, 0..) |c, i| lower_query[i] = std.ascii.toLower(c);
const lq = lower_query[0..query.len];
for (self.account_list.items, 0..) |acct, i| {
if (containsLower(acct, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
} else if (i < self.account_numbers.items.len) {
if (self.account_numbers.items[i]) |num| {
if (containsLower(num, lq)) {
self.account_search_matches.append(self.allocator, i) catch continue;
}
}
}
}
if (self.account_search_cursor >= self.account_search_matches.items.len) {
self.account_search_cursor = if (self.account_search_matches.items.len > 0)
self.account_search_matches.items.len - 1
else
0;
}
}
fn containsLower(haystack: []const u8, needle_lower: []const u8) bool {
if (needle_lower.len == 0) return true;
if (haystack.len < needle_lower.len) return false;
const end = haystack.len - needle_lower.len + 1;
for (0..end) |start| {
var matched = true;
for (0..needle_lower.len) |j| {
if (std.ascii.toLower(haystack[start + j]) != needle_lower[j]) {
matched = false;
break;
}
}
if (matched) return true;
}
return false;
}
/// Apply the current account picker selection and return to normal mode.
fn applyAccountPickerSelection(self: *App) void {
if (self.account_picker_cursor == 0) {
@ -682,12 +823,31 @@ pub const App = struct {
}
}
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
pub fn ensureAccountMap(self: *App) void {
if (self.account_map != null) return;
const ppath = self.portfolio_path orelse return;
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch return;
defer self.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer self.allocator.free(acct_data);
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
} else |_| {}
}
/// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void {
// Free the old owned copy
if (self.account_filter) |old| self.allocator.free(old);
if (self.filtered_positions) |fp| self.allocator.free(fp);
self.filtered_positions = null;
if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null;
if (self.portfolio) |pf| {
self.filtered_positions = pf.positionsForAccount(self.allocator, n) catch null;
}
} else {
self.account_filter = null;
}
@ -1346,7 +1506,11 @@ pub const App = struct {
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);
self.account_numbers.deinit(self.allocator);
self.account_shortcut_keys.deinit(self.allocator);
self.account_search_matches.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af);
if (self.filtered_positions) |fp| self.allocator.free(fp);
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit();
@ -1448,7 +1612,7 @@ pub const App = struct {
if (self.mode == .help) {
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
} else if (self.mode == .account_picker) {
} else if (self.mode == .account_picker or self.mode == .account_search) {
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
} else {
switch (self.active_tab) {

View file

@ -22,7 +22,7 @@ pub fn loadData(app: *App) void {
// Look for metadata.srf next to the portfolio file
if (app.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf"
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer app.allocator.free(meta_path);
@ -40,23 +40,7 @@ pub fn loadData(app: *App) void {
}
// Load account tax type metadata file (optional)
if (app.account_map == null) {
if (app.portfolio_path) |ppath| {
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
loadDataFinish(app, pf, summary);
return;
};
defer app.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer app.allocator.free(acct_data);
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
} else |_| {
// accounts.srf is optional -- analysis works without it
}
}
}
app.ensureAccountMap();
loadDataFinish(app, pf, summary);
}

View file

@ -199,6 +199,7 @@ pub fn loadPortfolioData(app: *App) void {
sortPortfolioAllocations(app);
buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app);
const summary = pf_data.summary;
@ -288,10 +289,14 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Count lots for this symbol (filtered by account when filter is active)
var lcount: usize = 0;
if (app.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) lcount += 1;
if (findFilteredPosition(app, a.symbol)) |pos| {
lcount = pos.open_lots + pos.closed_lots;
} else if (app.account_filter == null) {
if (app.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
lcount += 1;
}
}
}
}
@ -543,33 +548,73 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
/// Build the sorted list of distinct account names from portfolio lots.
/// Called after portfolio data is loaded or reloaded.
/// Build the ordered list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
pub fn buildAccountList(app: *App) void {
app.account_list.clearRetainingCapacity();
app.account_numbers.clearRetainingCapacity();
app.account_shortcut_keys.clearRetainingCapacity();
const pf = app.portfolio orelse return;
// Use a set to deduplicate
// Collect distinct account names from portfolio lots
var seen = std.StringHashMap(void).init(app.allocator);
defer seen.deinit();
var lot_accounts = std.ArrayList([]const u8).empty;
defer lot_accounts.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.account) |acct| {
if (acct.len > 0 and !seen.contains(acct)) {
seen.put(acct, {}) catch continue;
app.account_list.append(app.allocator, acct) catch continue;
lot_accounts.append(app.allocator, acct) catch continue;
}
}
}
// Sort alphabetically
std.mem.sort([]const u8, app.account_list.items, {}, struct {
app.ensureAccountMap();
// Phase 1: add accounts in accounts.srf order (if available)
if (app.account_map) |am| {
for (am.entries) |entry| {
if (seen.contains(entry.account)) {
app.account_list.append(app.allocator, entry.account) catch continue;
app.account_numbers.append(app.allocator, entry.account_number) catch continue;
}
}
}
// Phase 2: add accounts not in accounts.srf, sorted alphabetically
var extras = std.ArrayList([]const u8).empty;
defer extras.deinit(app.allocator);
for (lot_accounts.items) |acct| {
var found = false;
for (app.account_list.items) |existing| {
if (std.mem.eql(u8, acct, existing)) {
found = true;
break;
}
}
if (!found) extras.append(app.allocator, acct) catch continue;
}
std.mem.sort([]const u8, extras.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
for (extras.items) |acct| {
app.account_list.append(app.allocator, acct) catch continue;
app.account_numbers.append(app.allocator, null) catch continue;
}
// Assign shortcut keys: 1-9, 0, then b-z (skipping conflict keys)
assignShortcutKeys(app);
// If the current filter no longer exists in the new list, clear it
if (app.account_filter) |af| {
var found = false;
@ -583,6 +628,41 @@ pub fn buildAccountList(app: *App) void {
}
}
const shortcut_key_order = "1234567890bcdefhimnoptuvwxyz";
fn assignShortcutKeys(app: *App) void {
app.account_shortcut_keys.clearRetainingCapacity();
var key_idx: usize = 0;
for (0..app.account_list.items.len) |_| {
if (key_idx < shortcut_key_order.len) {
app.account_shortcut_keys.append(app.allocator, shortcut_key_order[key_idx]) catch continue;
key_idx += 1;
} else {
app.account_shortcut_keys.append(app.allocator, 0) catch continue;
}
}
}
/// Recompute filtered_positions when portfolio or account filter changes.
fn recomputeFilteredPositions(app: *App) void {
if (app.filtered_positions) |fp| app.allocator.free(fp);
app.filtered_positions = null;
const filter = app.account_filter orelse return;
const pf = app.portfolio orelse return;
app.filtered_positions = pf.positionsForAccount(app.allocator, filter) catch null;
}
/// Look up a symbol in the pre-computed filtered positions.
/// Returns null if no filter is active or the symbol isn't in the filtered account.
fn findFilteredPosition(app: *const App, symbol: []const u8) ?zfin.Position {
const fps = app.filtered_positions orelse return null;
for (fps) |pos| {
if (std.mem.eql(u8, pos.symbol, symbol) or std.mem.eql(u8, pos.lot_symbol, symbol))
return pos;
}
return null;
}
/// Check if a lot matches the active account filter.
/// Returns true if no filter is active or the lot's account matches.
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
@ -592,25 +672,11 @@ fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
}
/// Check if an allocation matches the active account filter.
/// Uses the allocation's account field (which is "Multiple" for mixed-account positions).
/// For "Multiple" accounts, we need to check if any lot with this symbol belongs to the filtered account.
/// When filtered, checks against pre-computed filtered_positions.
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
const filter = app.account_filter orelse return true;
// Simple case: allocation has a single account
if (!std.mem.eql(u8, a.account, "Multiple")) {
return std.mem.eql(u8, a.account, filter);
}
// "Multiple" account: check if any stock lot for this symbol belongs to the filtered account
if (app.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (lot.account) |la| {
if (std.mem.eql(u8, la, filter)) return true;
}
}
}
}
return false;
if (app.account_filter == null) return true;
if (app.filtered_positions == null) return false;
return findFilteredPosition(app, a.symbol) != null;
}
/// Account-filtered view of an allocation. When a position spans multiple accounts,
@ -624,41 +690,24 @@ const FilteredAlloc = struct {
/// Compute account-filtered values for an allocation.
/// For single-account positions (or no filter), returns the allocation's own values.
/// For "Multiple"-account positions with a filter, sums only the matching lots.
/// For filtered views, uses pre-computed filtered_positions.
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
const filter = app.account_filter orelse return .{
if (app.account_filter == null) return .{
.shares = a.shares,
.cost_basis = a.cost_basis,
.market_value = a.market_value,
.unrealized_gain_loss = a.unrealized_gain_loss,
};
if (!std.mem.eql(u8, a.account, "Multiple")) return .{
.shares = a.shares,
.cost_basis = a.cost_basis,
.market_value = a.market_value,
.unrealized_gain_loss = a.unrealized_gain_loss,
};
// Sum values from only the lots matching the filter
var shares: f64 = 0;
var cost: f64 = 0;
if (app.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.security_type != .stock) continue;
if (!std.mem.eql(u8, lot.priceSymbol(), a.symbol)) continue;
if (!lot.isOpen()) continue;
const la = lot.account orelse "";
if (!std.mem.eql(u8, la, filter)) continue;
shares += lot.shares;
cost += lot.shares * lot.open_price;
}
if (findFilteredPosition(app, a.symbol)) |pos| {
const mv = pos.shares * a.current_price * pos.price_ratio;
return .{
.shares = pos.shares,
.cost_basis = pos.total_cost,
.market_value = mv,
.unrealized_gain_loss = mv - pos.total_cost,
};
}
const mv = shares * a.current_price * a.price_ratio;
return .{
.shares = shares,
.cost_basis = cost,
.market_value = mv,
.unrealized_gain_loss = mv - cost,
};
return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 };
}
/// Totals for the filtered account view (stocks + cash + CDs + options).
@ -896,38 +945,20 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
if (app.portfolio) |pf| {
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
acct_col = lot.account orelse "";
break;
}
}
}
} else {
// Multi-lot: show account if all lots share the same one
if (app.portfolio) |pf| {
var common_acct: ?[]const u8 = null;
var mixed = false;
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (common_acct) |ca| {
const la = lot.account orelse "";
if (!std.mem.eql(u8, ca, la)) {
mixed = true;
break;
}
} else {
common_acct = lot.account orelse "";
if (matchesAccountFilter(app, lot.account)) {
const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
acct_col = lot.account orelse "";
break;
}
}
}
if (!mixed) {
acct_col = common_acct orelse "";
} else {
acct_col = "Multiple";
}
}
} else if (app.account_filter) |af| {
acct_col = af;
} else {
acct_col = a.account;
}
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
@ -1254,6 +1285,7 @@ pub fn reloadPortfolioFile(app: *App) void {
sortPortfolioAllocations(app);
buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory
@ -1288,28 +1320,92 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell,
const th = app.theme;
var lines: std.ArrayList(tui.StyledLine) = .empty;
const is_searching = app.mode == .account_search;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a set of search-highlighted indices for fast lookup
var search_highlight = std.AutoHashMap(usize, void).init(arena);
var search_cursor_idx: ?usize = null;
if (is_searching and app.account_search_matches.items.len > 0) {
for (app.account_search_matches.items, 0..) |match_idx, si| {
search_highlight.put(match_idx, {}) catch {};
if (si == app.account_search_cursor) search_cursor_idx = match_idx;
}
}
// Item 0 = "All accounts" (clears filter)
const total_items = app.account_list.items.len + 1;
for (0..total_items) |i| {
const is_selected = i == app.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " ";
const label: []const u8 = if (i == 0) "All accounts" else app.account_list.items[i - 1];
const text = try std.fmt.allocPrint(arena, "{s}{s}", .{ marker, label });
const style = if (is_selected) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style });
const is_selected = if (is_searching)
(if (search_cursor_idx) |sci| i == sci + 1 else false)
else
i == app.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " ";
if (i == 0) {
const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
const style = if (is_selected) th.selectStyle() else th.contentStyle();
const dimmed = is_searching and app.account_search_len > 0;
try lines.append(arena, .{ .text = text, .style = if (dimmed) th.mutedStyle() else style });
} else {
const acct_idx = i - 1;
const label = app.account_list.items[acct_idx];
const shortcut: u8 = if (acct_idx < app.account_shortcut_keys.items.len) app.account_shortcut_keys.items[acct_idx] else 0;
const acct_num: ?[]const u8 = if (acct_idx < app.account_numbers.items.len) app.account_numbers.items[acct_idx] else null;
const text = if (acct_num) |num|
(if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s} ({s})", .{ marker, shortcut, label, num })
else
try std.fmt.allocPrint(arena, "{s} {s} ({s})", .{ marker, label, num }))
else if (shortcut != 0)
try std.fmt.allocPrint(arena, "{s}{c}: {s}", .{ marker, shortcut, label })
else
try std.fmt.allocPrint(arena, "{s} {s}", .{ marker, label });
var style = if (is_selected) th.selectStyle() else th.contentStyle();
if (is_searching and app.account_search_len > 0) {
if (search_highlight.contains(acct_idx)) {
if (!is_selected) style = th.headerStyle();
} else {
style = th.mutedStyle();
}
}
try lines.append(arena, .{ .text = text, .style = style });
}
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Search prompt at the bottom
if (is_searching) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const query = app.account_search_buf[0..app.account_search_len];
const match_count = app.account_search_matches.items.len;
const prompt = if (query.len > 0)
try std.fmt.allocPrint(arena, " /{s} ({d} match{s})", .{
query,
match_count,
if (match_count != 1) @as([]const u8, "es") else "",
})
else
try std.fmt.allocPrint(arena, " /", .{});
try lines.append(arena, .{ .text = prompt, .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " /: search j/k: navigate Enter: select Esc: cancel", .style = th.mutedStyle() });
}
// Scroll so cursor is visible
const cursor_line = app.account_picker_cursor + account_picker_header_lines;
const effective_cursor = if (is_searching)
(if (search_cursor_idx) |sci| sci + 1 else 0)
else
app.account_picker_cursor;
const cursor_line = effective_cursor + account_picker_header_lines;
var start: usize = 0;
if (cursor_line >= height) {
start = cursor_line - height + 2; // keep one line of padding below
start = cursor_line - height + 2;
}
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);