Compare commits

..

No commits in common. "5ee2151a4737dffc420616d028cd10b2c36f1600" and "3ae53f15b000aa48eb7aa3336b235706857d53b4" have entirely different histories.

10 changed files with 120 additions and 2067 deletions

24
TODO.md
View file

@ -52,30 +52,6 @@ previously selected one (like `cd -` in bash or `Ctrl+^` in vim). Store
key swaps current and last. Works on any tab — particularly useful for key swaps current and last. Works on any tab — particularly useful for
eyeball-comparing performance/risk data between two symbols. 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 ## Market-aware cache TTL for daily candles
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful Daily candle TTL is currently 23h45m, but candle data only becomes meaningful

View file

@ -110,16 +110,3 @@ symbol::ORC42,asset_class::Bonds,geo::US,pct:num:8.33
symbol::VFORX,geo::US,asset_class::US Large Cap symbol::VFORX,geo::US,asset_class::US Large Cap
symbol::VTTHX,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,8 +38,6 @@ pub const TaxType = enum {
pub const AccountTaxEntry = struct { pub const AccountTaxEntry = struct {
account: []const u8, account: []const u8,
tax_type: TaxType, tax_type: TaxType,
institution: ?[]const u8 = null,
account_number: ?[]const u8 = null,
}; };
/// Parsed account metadata. /// Parsed account metadata.
@ -50,8 +48,6 @@ pub const AccountMap = struct {
pub fn deinit(self: *AccountMap) void { pub fn deinit(self: *AccountMap) void {
for (self.entries) |e| { for (self.entries) |e| {
self.allocator.free(e.account); 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); self.allocator.free(self.entries);
} }
@ -65,42 +61,15 @@ pub const AccountMap = struct {
} }
return "Unknown"; 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. /// Parse an accounts.srf file into an AccountMap.
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>] /// Each record has: account::<NAME>,tax_type::<TYPE>
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap { pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
var entries = std.ArrayList(AccountTaxEntry).empty; var entries = std.ArrayList(AccountTaxEntry).empty;
errdefer { errdefer {
for (entries.items) |e| { for (entries.items) |e| {
allocator.free(e.account); allocator.free(e.account);
if (e.institution) |s| allocator.free(s);
if (e.account_number) |s| allocator.free(s);
} }
entries.deinit(allocator); entries.deinit(allocator);
} }
@ -114,8 +83,6 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
try entries.append(allocator, .{ try entries.append(allocator, .{
.account = try allocator.dupe(u8, entry.account), .account = try allocator.dupe(u8, entry.account),
.tax_type = entry.tax_type, .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); defer pf_data.deinit(allocator);
// Load classification metadata // Load classification metadata
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0; const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return; const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
defer allocator.free(meta_path); defer allocator.free(meta_path);

File diff suppressed because it is too large Load diff

View file

@ -20,7 +20,6 @@ const usage =
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf) \\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit) \\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI \\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ \\
@ -43,12 +42,6 @@ const usage =
\\ -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)
\\ \\
\\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: \\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.
@ -115,7 +108,6 @@ pub fn main() !u8 {
if (args.len >= 3 and if (args.len >= 3 and
!std.mem.eql(u8, command, "cache") and !std.mem.eql(u8, command, "cache") and
!std.mem.eql(u8, command, "enrich") 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, "analysis") and
!std.mem.eql(u8, command, "portfolio")) !std.mem.eql(u8, command, "portfolio"))
{ {
@ -222,8 +214,6 @@ pub fn main() !u8 {
return 1; return 1;
} }
try commands.enrich.run(allocator, &svc, args[2], out); 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")) { } else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf) // File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf"; var analysis_file: []const u8 = "portfolio.srf";
@ -258,7 +248,6 @@ const commands = struct {
const lookup = @import("commands/lookup.zig"); const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig"); const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig"); const analysis = @import("commands/analysis.zig");
const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig"); const enrich = @import("commands/enrich.zig");
}; };

View file

@ -126,9 +126,6 @@ pub const Lot = struct {
/// Aggregated position for a single symbol across multiple lots. /// Aggregated position for a single symbol across multiple lots.
pub const Position = struct { pub const Position = struct {
symbol: []const u8, 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 /// Total open shares
shares: f64, shares: f64,
/// Weighted average cost basis per share (open lots only) /// Weighted average cost basis per share (open lots only)
@ -255,7 +252,6 @@ pub const Portfolio = struct {
if (!entry.found_existing) { if (!entry.found_existing) {
entry.value_ptr.* = .{ entry.value_ptr.* = .{
.symbol = sym, .symbol = sym,
.lot_symbol = lot.symbol,
.shares = 0, .shares = 0,
.avg_cost = 0, .avg_cost = 0,
.total_cost = 0, .total_cost = 0,
@ -306,78 +302,6 @@ pub const Portfolio = struct {
return result.toOwnedSlice(allocator); 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. /// Total cost basis of all open stock lots.
pub fn totalCostBasis(self: Portfolio) f64 { pub fn totalCostBasis(self: Portfolio) f64 {
var total: f64 = 0; var total: f64 = 0;
@ -640,34 +564,3 @@ 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,7 +95,6 @@ pub const InputMode = enum {
symbol_input, symbol_input,
help, help,
account_picker, account_picker,
account_search,
}; };
pub const StyledLine = struct { pub const StyledLine = struct {
@ -310,15 +309,8 @@ pub const App = struct {
// Account filter state // Account filter state
account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts) account_filter: ?[]const u8 = null, // active account filter (owned copy; null = all accounts)
filtered_positions: ?[]zfin.Position = null, // positions for filtered account (from positionsForAccount) account_list: std.ArrayList([]const u8) = .empty, // distinct accounts from portfolio lots (borrowed from portfolio)
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_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 navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view options_cursor: usize = 0, // selected row in flattened options view
@ -395,9 +387,6 @@ pub const App = struct {
if (self.mode == .account_picker) { if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key); return self.handleAccountPickerKey(ctx, key);
} }
if (self.mode == .account_search) {
return self.handleAccountSearchKey(ctx, key);
}
if (self.mode == .help) { if (self.mode == .help) {
self.mode = .normal; self.mode = .normal;
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
@ -630,44 +619,19 @@ pub const App = struct {
fn handleAccountPickerKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { 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" const total_items = self.account_list.items.len + 1; // +1 for "All accounts"
if (key.codepoint == vaxis.Key.escape or key.codepoint == 'q') { // Cancel: return to normal mode without changing the filter
if (key.codepoint == vaxis.Key.escape) {
self.mode = .normal; self.mode = .normal;
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
// Confirm: apply the selected account filter
if (key.codepoint == vaxis.Key.enter) { if (key.codepoint == vaxis.Key.enter) {
self.applyAccountPickerSelection(); self.applyAccountPickerSelection();
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
// '/' enters search mode // Use the keymap for navigation actions
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; const action = self.keymap.matchAction(key) orelse return;
switch (action) { switch (action) {
.select_next => { .select_next => {
@ -693,111 +657,6 @@ 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. /// Apply the current account picker selection and return to normal mode.
fn applyAccountPickerSelection(self: *App) void { fn applyAccountPickerSelection(self: *App) void {
if (self.account_picker_cursor == 0) { if (self.account_picker_cursor == 0) {
@ -823,31 +682,12 @@ 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. /// Set or clear the account filter. Owns the string via allocator.
pub fn setAccountFilter(self: *App, name: ?[]const u8) void { 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.account_filter) |old| self.allocator.free(old);
if (self.filtered_positions) |fp| self.allocator.free(fp);
self.filtered_positions = null;
if (name) |n| { if (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null; 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 { } else {
self.account_filter = null; self.account_filter = null;
} }
@ -1506,11 +1346,7 @@ pub const App = struct {
self.portfolio_rows.deinit(self.allocator); self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator);
self.account_list.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.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.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator); if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit(); if (self.classification_map) |*cm| cm.deinit();
@ -1612,7 +1448,7 @@ pub const App = struct {
if (self.mode == .help) { if (self.mode == .help) {
try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena)); try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena));
} else if (self.mode == .account_picker or self.mode == .account_search) { } else if (self.mode == .account_picker) {
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height); try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
} else { } else {
switch (self.active_tab) { 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 // Look for metadata.srf next to the portfolio file
if (app.portfolio_path) |ppath| { if (app.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf" // Derive metadata path: same directory as portfolio, named "metadata.srf"
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer app.allocator.free(meta_path); defer app.allocator.free(meta_path);
@ -40,7 +40,23 @@ pub fn loadData(app: *App) void {
} }
// Load account tax type metadata file (optional) // Load account tax type metadata file (optional)
app.ensureAccountMap(); 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
}
}
}
loadDataFinish(app, pf, summary); loadDataFinish(app, pf, summary);
} }

View file

@ -199,7 +199,6 @@ pub fn loadPortfolioData(app: *App) void {
sortPortfolioAllocations(app); sortPortfolioAllocations(app);
buildAccountList(app); buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app); rebuildPortfolioRows(app);
const summary = pf_data.summary; const summary = pf_data.summary;
@ -289,14 +288,10 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Count lots for this symbol (filtered by account when filter is active) // Count lots for this symbol (filtered by account when filter is active)
var lcount: usize = 0; var lcount: usize = 0;
if (findFilteredPosition(app, a.symbol)) |pos| {
lcount = pos.open_lots + pos.closed_lots;
} else if (app.account_filter == null) {
if (app.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
lcount += 1; if (matchesAccountFilter(app, lot.account)) lcount += 1;
}
} }
} }
} }
@ -548,73 +543,33 @@ pub fn rebuildPortfolioRows(app: *App) void {
} }
} }
/// Build the ordered list of distinct account names from portfolio lots. /// Build the sorted list of distinct account names from portfolio lots.
/// Order: accounts.srf file order first, then any remaining accounts alphabetically. /// Called after portfolio data is loaded or reloaded.
/// Also assigns shortcut keys and loads account numbers from accounts.srf.
pub fn buildAccountList(app: *App) void { pub fn buildAccountList(app: *App) void {
app.account_list.clearRetainingCapacity(); app.account_list.clearRetainingCapacity();
app.account_numbers.clearRetainingCapacity();
app.account_shortcut_keys.clearRetainingCapacity();
const pf = app.portfolio orelse return; const pf = app.portfolio orelse return;
// Collect distinct account names from portfolio lots // Use a set to deduplicate
var seen = std.StringHashMap(void).init(app.allocator); var seen = std.StringHashMap(void).init(app.allocator);
defer seen.deinit(); defer seen.deinit();
var lot_accounts = std.ArrayList([]const u8).empty;
defer lot_accounts.deinit(app.allocator);
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.account) |acct| { if (lot.account) |acct| {
if (acct.len > 0 and !seen.contains(acct)) { if (acct.len > 0 and !seen.contains(acct)) {
seen.put(acct, {}) catch continue; seen.put(acct, {}) catch continue;
lot_accounts.append(app.allocator, acct) catch continue; app.account_list.append(app.allocator, acct) catch continue;
} }
} }
} }
app.ensureAccountMap(); // Sort alphabetically
std.mem.sort([]const u8, app.account_list.items, {}, struct {
// 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 { fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b); return std.mem.lessThan(u8, a, b);
} }
}.lessThan); }.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 the current filter no longer exists in the new list, clear it
if (app.account_filter) |af| { if (app.account_filter) |af| {
var found = false; var found = false;
@ -628,41 +583,6 @@ 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. /// Check if a lot matches the active account filter.
/// Returns true if no filter is active or the lot's account matches. /// Returns true if no filter is active or the lot's account matches.
fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool { fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
@ -672,11 +592,25 @@ fn matchesAccountFilter(app: *const App, account: ?[]const u8) bool {
} }
/// Check if an allocation matches the active account filter. /// Check if an allocation matches the active account filter.
/// When filtered, checks against pre-computed filtered_positions. /// 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.
fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool { fn allocationMatchesFilter(app: *const App, a: zfin.valuation.Allocation) bool {
if (app.account_filter == null) return true; const filter = app.account_filter orelse return true;
if (app.filtered_positions == null) return false; // Simple case: allocation has a single account
return findFilteredPosition(app, a.symbol) != null; 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;
} }
/// Account-filtered view of an allocation. When a position spans multiple accounts, /// Account-filtered view of an allocation. When a position spans multiple accounts,
@ -690,24 +624,41 @@ const FilteredAlloc = struct {
/// Compute account-filtered values for an allocation. /// Compute account-filtered values for an allocation.
/// For single-account positions (or no filter), returns the allocation's own values. /// For single-account positions (or no filter), returns the allocation's own values.
/// For filtered views, uses pre-computed filtered_positions. /// For "Multiple"-account positions with a filter, sums only the matching lots.
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc { fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
if (app.account_filter == null) return .{ const filter = app.account_filter orelse return .{
.shares = a.shares, .shares = a.shares,
.cost_basis = a.cost_basis, .cost_basis = a.cost_basis,
.market_value = a.market_value, .market_value = a.market_value,
.unrealized_gain_loss = a.unrealized_gain_loss, .unrealized_gain_loss = a.unrealized_gain_loss,
}; };
if (findFilteredPosition(app, a.symbol)) |pos| { if (!std.mem.eql(u8, a.account, "Multiple")) return .{
const mv = pos.shares * a.current_price * pos.price_ratio; .shares = a.shares,
return .{ .cost_basis = a.cost_basis,
.shares = pos.shares, .market_value = a.market_value,
.cost_basis = pos.total_cost, .unrealized_gain_loss = a.unrealized_gain_loss,
.market_value = mv,
.unrealized_gain_loss = mv - pos.total_cost,
}; };
// 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;
} }
return .{ .shares = 0, .cost_basis = 0, .market_value = 0, .unrealized_gain_loss = 0 }; }
const mv = shares * a.current_price * a.price_ratio;
return .{
.shares = shares,
.cost_basis = cost,
.market_value = mv,
.unrealized_gain_loss = mv - cost,
};
} }
/// Totals for the filtered account view (stocks + cash + CDs + options). /// Totals for the filtered account view (stocks + cash + CDs + options).
@ -945,7 +896,6 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
if (app.portfolio) |pf| { if (app.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) { if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) {
const ds = lot.open_date.format(&pos_date_buf); const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date); const indicator = fmt.capitalGainsIndicator(lot.open_date);
date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
@ -954,11 +904,30 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
} }
} }
} }
}
} else if (app.account_filter) |af| {
acct_col = af;
} else { } else {
acct_col = a.account; // 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 (!mixed) {
acct_col = common_acct orelse "";
} else {
acct_col = "Multiple";
}
}
} }
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0) const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
@ -1285,7 +1254,6 @@ pub fn reloadPortfolioFile(app: *App) void {
sortPortfolioAllocations(app); sortPortfolioAllocations(app);
buildAccountList(app); buildAccountList(app);
recomputeFilteredPositions(app);
rebuildPortfolioRows(app); rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory // Invalidate analysis data -- it holds pointers into old portfolio memory
@ -1320,92 +1288,28 @@ pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell,
const th = app.theme; const th = app.theme;
var lines: std.ArrayList(tui.StyledLine) = .empty; 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 = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() }); try lines.append(arena, .{ .text = " Filter by Account", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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) // Item 0 = "All accounts" (clears filter)
const total_items = app.account_list.items.len + 1; const total_items = app.account_list.items.len + 1;
for (0..total_items) |i| { for (0..total_items) |i| {
const is_selected = if (is_searching) const is_selected = i == app.account_picker_cursor;
(if (search_cursor_idx) |sci| i == sci + 1 else false)
else
i == app.account_picker_cursor;
const marker: []const u8 = if (is_selected) " > " else " "; const marker: []const u8 = if (is_selected) " > " else " ";
const label: []const u8 = if (i == 0) "All accounts" else app.account_list.items[i - 1];
if (i == 0) { const text = try std.fmt.allocPrint(arena, "{s}{s}", .{ marker, label });
const text = try std.fmt.allocPrint(arena, "{s}A: All accounts", .{marker});
const style = if (is_selected) th.selectStyle() else th.contentStyle(); 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 = text, .style = style });
} }
}
// Search prompt at the bottom
if (is_searching) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); 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 // Scroll so cursor is visible
const effective_cursor = if (is_searching) const cursor_line = app.account_picker_cursor + account_picker_header_lines;
(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; var start: usize = 0;
if (cursor_line >= height) { if (cursor_line >= height) {
start = cursor_line - height + 2; start = cursor_line - height + 2; // keep one line of padding below
} }
start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0); start = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);