allow for filtering by account (substantial change to calculations - seems ok)

This commit is contained in:
Emil Lerch 2026-03-31 11:03:37 -07:00
parent 2ac4156bc1
commit 31dd551efe
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 645 additions and 162 deletions

View file

@ -93,6 +93,7 @@ pub const InputMode = enum {
normal,
symbol_input,
help,
account_picker,
};
pub const StyledLine = struct {
@ -296,6 +297,11 @@ pub const App = struct {
watchlist_prices: ?std.StringHashMap(f64) = null, // cached watchlist prices (no disk I/O during render)
prefetched_prices: ?std.StringHashMap(f64) = null, // prices loaded before TUI starts (with stderr progress)
// 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)
account_picker_cursor: usize = 0, // cursor position in picker (0 = "All accounts")
// Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view
options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded
@ -368,6 +374,9 @@ pub const App = struct {
if (self.mode == .symbol_input) {
return self.handleInputKey(ctx, key);
}
if (self.mode == .account_picker) {
return self.handleAccountPickerKey(ctx, key);
}
if (self.mode == .help) {
self.mode = .normal;
return ctx.consumeAndRedraw();
@ -385,6 +394,42 @@ pub const App = struct {
}
fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void {
// Account picker mouse handling
if (self.mode == .account_picker) {
const total_items = self.account_list.items.len + 1;
switch (mouse.button) {
.wheel_up => {
if (self.shouldDebounceWheel()) return;
if (self.account_picker_cursor > 0)
self.account_picker_cursor -= 1;
return ctx.consumeAndRedraw();
},
.wheel_down => {
if (self.shouldDebounceWheel()) return;
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
self.account_picker_cursor += 1;
return ctx.consumeAndRedraw();
},
.left => {
if (mouse.type != .press) return;
// Map click row to picker item index.
// mouse.row maps directly to content line index
// (same convention as portfolio click handling).
const content_row = @as(usize, @intCast(mouse.row));
if (content_row >= portfolio_tab.account_picker_header_lines) {
const item_idx = content_row - portfolio_tab.account_picker_header_lines;
if (item_idx < total_items) {
self.account_picker_cursor = item_idx;
self.applyAccountPickerSelection();
return ctx.consumeAndRedraw();
}
}
},
else => {},
}
return;
}
switch (mouse.button) {
.wheel_up => {
self.moveBy(-3);
@ -560,9 +605,95 @@ pub const App = struct {
}
}
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: no special behavior needed (options is now inline)
/// Handles keypresses in account_picker mode.
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) {
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
const action = self.keymap.matchAction(key) orelse return;
switch (action) {
.select_next => {
if (total_items > 0 and self.account_picker_cursor < total_items - 1)
self.account_picker_cursor += 1;
return ctx.consumeAndRedraw();
},
.select_prev => {
if (self.account_picker_cursor > 0)
self.account_picker_cursor -= 1;
return ctx.consumeAndRedraw();
},
.scroll_top => {
self.account_picker_cursor = 0;
return ctx.consumeAndRedraw();
},
.scroll_bottom => {
if (total_items > 0)
self.account_picker_cursor = total_items - 1;
return ctx.consumeAndRedraw();
},
else => {},
}
}
/// Apply the current account picker selection and return to normal mode.
fn applyAccountPickerSelection(self: *App) void {
if (self.account_picker_cursor == 0) {
// "All accounts" clear filter
self.setAccountFilter(null);
} else {
const idx = self.account_picker_cursor - 1;
if (idx < self.account_list.items.len) {
self.setAccountFilter(self.account_list.items[idx]);
}
}
self.mode = .normal;
self.cursor = 0;
self.scroll_offset = 0;
portfolio_tab.rebuildPortfolioRows(self);
if (self.account_filter) |af| {
var tmp_buf: [256]u8 = undefined;
const msg = std.fmt.bufPrint(&tmp_buf, "Filtered: {s}", .{af}) catch "Filtered";
self.setStatus(msg);
} else {
self.setStatus("Filter cleared: showing all accounts");
}
}
/// 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 (name) |n| {
self.account_filter = self.allocator.dupe(u8, n) catch null;
} else {
self.account_filter = null;
}
}
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
// Escape: clear account filter on portfolio tab, no-op otherwise
if (key.codepoint == vaxis.Key.escape) {
if (self.active_tab == .portfolio and self.account_filter != null) {
self.setAccountFilter(null);
self.cursor = 0;
self.scroll_offset = 0;
portfolio_tab.rebuildPortfolioRows(self);
self.setStatus("Filter cleared: showing all accounts");
return ctx.consumeAndRedraw();
}
return;
}
@ -754,8 +885,32 @@ pub const App = struct {
return ctx.consumeAndRedraw();
}
},
.account_filter => {
if (self.active_tab == .portfolio and self.portfolio != null) {
self.mode = .account_picker;
// Position cursor on the currently-active filter (or 0 for "All")
self.account_picker_cursor = 0;
if (self.account_filter) |af| {
for (self.account_list.items, 0..) |acct, ai| {
if (std.mem.eql(u8, acct, af)) {
self.account_picker_cursor = ai + 1; // +1 because 0 = "All accounts"
break;
}
}
}
return ctx.consumeAndRedraw();
}
},
}
}
/// Returns true if this wheel event should be suppressed (too close to the last one).
fn shouldDebounceWheel(self: *App) bool {
const now = std.time.nanoTimestamp();
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return true;
self.last_wheel_ns = now;
return false;
}
/// Move cursor/scroll. Positive = down, negative = up.
/// For portfolio and options tabs, moves the row cursor by 1 with
@ -763,9 +918,7 @@ pub const App = struct {
/// For other tabs, adjusts scroll_offset by |n|.
fn moveBy(self: *App, n: isize) void {
if (self.active_tab == .portfolio or self.active_tab == .options) {
const now = std.time.nanoTimestamp();
if (now - self.last_wheel_ns < 1 * std.time.ns_per_ms) return;
self.last_wheel_ns = now;
if (self.shouldDebounceWheel()) return;
if (self.active_tab == .portfolio) {
stepCursor(&self.cursor, self.portfolio_rows.items.len, n);
self.ensureCursorVisible();
@ -1174,6 +1327,8 @@ pub const App = struct {
self.freePortfolioSummary();
self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator);
self.account_list.deinit(self.allocator);
if (self.account_filter) |af| self.allocator.free(af);
if (self.watchlist_prices) |*wp| wp.deinit();
if (self.analysis_result) |*ar| ar.deinit(self.allocator);
if (self.classification_map) |*cm| cm.deinit();
@ -1275,6 +1430,8 @@ 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) {
try portfolio_tab.drawAccountPicker(self, ctx.arena, buf, width, height);
} else {
switch (self.active_tab) {
.portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height),
@ -1360,14 +1517,31 @@ pub const App = struct {
buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style };
}
}
} else if (self.mode == .account_picker) {
const prompt_style = t.inputStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style });
const hint = " j/k=navigate Enter=select Esc=cancel Click=select ";
for (0..@min(hint.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = prompt_style };
}
} else {
const status_style = t.statusStyle();
@memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style });
// Show account filter indicator when active, appended to status message
if (self.account_filter != null and self.active_tab == .portfolio) {
const af = self.account_filter.?;
const msg = self.getStatus();
const filter_text = std.fmt.allocPrint(ctx.arena, "{s} [Account: {s}]", .{ msg, af }) catch msg;
for (0..@min(filter_text.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(filter_text[i]) }, .style = status_style };
}
} else {
const msg = self.getStatus();
for (0..@min(msg.len, width)) |i| {
buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style };
}
}
}
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
}
@ -1435,7 +1609,7 @@ pub const App = struct {
"Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", "Filter +/- 7 NTM",
"Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe", "Chart: prev timeframe",
"Sort: next column", "Sort: prev column", "Sort: reverse order",
"Sort: next column", "Sort: prev column", "Sort: reverse order", "Account filter (portfolio)",
};
for (actions, 0..) |action, ai| {

View file

@ -42,6 +42,7 @@ pub const Action = enum {
sort_col_next,
sort_col_prev,
sort_reverse,
account_filter,
};
pub const KeyCombo = struct {
@ -124,6 +125,7 @@ const default_bindings = [_]Binding{
.{ .action = .sort_col_next, .key = .{ .codepoint = '>' } },
.{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } },
.{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } },
.{ .action = .account_filter, .key = .{ .codepoint = 'a' } },
};
pub fn defaults() KeyMap {

View file

@ -187,6 +187,7 @@ pub fn loadPortfolioData(app: *App) void {
}
sortPortfolioAllocations(app);
buildAccountList(app);
rebuildPortfolioRows(app);
const summary = pf_data.summary;
@ -270,11 +271,16 @@ pub fn rebuildPortfolioRows(app: *App) void {
if (app.portfolio_summary) |s| {
for (s.allocations, 0..) |a, i| {
// Count lots for this symbol
// Skip allocations that don't match account filter
if (!allocationMatchesFilter(app, a)) continue;
// 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)) lcount += 1;
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account)) lcount += 1;
}
}
}
@ -293,6 +299,7 @@ pub fn rebuildPortfolioRows(app: *App) void {
defer matching.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .stock and std.mem.eql(u8, lot.priceSymbol(), a.symbol)) {
if (matchesAccountFilter(app, lot.account))
matching.append(app.allocator, lot) catch continue;
}
}
@ -367,6 +374,8 @@ pub fn rebuildPortfolioRows(app: *App) void {
// Add watchlist items from both the separate watchlist file and
// watch lots embedded in the portfolio. Skip symbols already in allocations.
// Hide watchlist entirely when account filter is active (watchlist items don't belong to accounts).
if (app.account_filter == null) {
var watch_seen = std.StringHashMap(void).init(app.allocator);
defer watch_seen.deinit();
@ -402,16 +411,29 @@ pub fn rebuildPortfolioRows(app: *App) void {
}) catch continue;
}
}
}
// Options section
// Options section (filtered by account when filter is active)
if (app.portfolio) |pf| {
if (pf.hasType(.option)) {
var has_matching = false;
if (app.account_filter != null) {
for (pf.lots) |lot| {
if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) {
has_matching = true;
break;
}
}
} else {
has_matching = true;
}
if (has_matching) {
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Options",
}) catch {};
for (pf.lots) |lot| {
if (lot.security_type == .option) {
if (lot.security_type == .option and matchesAccountFilter(app, lot.account)) {
app.portfolio_rows.append(app.allocator, .{
.kind = .option_row,
.symbol = lot.symbol,
@ -420,20 +442,22 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
}
}
// CDs section (sorted by maturity date, earliest first)
// CDs section (sorted by maturity date, earliest first; filtered by account)
if (pf.hasType(.cd)) {
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cd and matchesAccountFilter(app, lot.account)) {
cd_lots.append(app.allocator, lot) catch continue;
}
}
if (cd_lots.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Certificates of Deposit",
}) catch {};
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cd) {
cd_lots.append(app.allocator, lot) catch continue;
}
}
std.mem.sort(zfin.Lot, cd_lots.items, {}, fmt.lotMaturitySortFn);
for (cd_lots.items) |lot| {
app.portfolio_rows.append(app.allocator, .{
@ -443,19 +467,42 @@ pub fn rebuildPortfolioRows(app: *App) void {
}) catch continue;
}
}
}
// Cash section (single total row, expandable to show per-account)
// Cash section (filtered by account when filter is active)
if (pf.hasType(.cash)) {
// When filtered, only show cash lots matching the account
if (app.account_filter != null) {
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
defer cash_lots.deinit(app.allocator);
for (pf.lots) |lot| {
if (lot.security_type == .cash and matchesAccountFilter(app, lot.account)) {
cash_lots.append(app.allocator, lot) catch continue;
}
}
if (cash_lots.items.len > 0) {
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
for (cash_lots.items) |lot| {
app.portfolio_rows.append(app.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
.lot = lot,
}) catch continue;
}
}
} else {
// Unfiltered: show total + expandable per-account rows
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
// Total cash row
app.portfolio_rows.append(app.allocator, .{
.kind = .cash_total,
.symbol = "CASH",
}) catch {};
// Per-account cash rows (expanded when cash_total is toggled)
if (app.cash_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .cash) {
@ -468,19 +515,19 @@ pub fn rebuildPortfolioRows(app: *App) void {
}
}
}
}
// Illiquid assets section (similar to cash: total row, expandable)
// Illiquid assets section (hidden when account filter is active)
if (app.account_filter == null) {
if (pf.hasType(.illiquid)) {
app.portfolio_rows.append(app.allocator, .{
.kind = .section_header,
.symbol = "Illiquid Assets",
}) catch {};
// Total illiquid row
app.portfolio_rows.append(app.allocator, .{
.kind = .illiquid_total,
.symbol = "ILLIQUID",
}) catch {};
// Per-asset rows (expanded when illiquid_total is toggled)
if (app.illiquid_expanded) {
for (pf.lots) |lot| {
if (lot.security_type == .illiquid) {
@ -494,6 +541,170 @@ 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.
pub fn buildAccountList(app: *App) void {
app.account_list.clearRetainingCapacity();
const pf = app.portfolio orelse return;
// Use a set to deduplicate
var seen = std.StringHashMap(void).init(app.allocator);
defer seen.deinit();
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;
}
}
}
// Sort alphabetically
std.mem.sort([]const u8, app.account_list.items, {}, struct {
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lessThan);
// If the current filter no longer exists in the new list, clear it
if (app.account_filter) |af| {
var found = false;
for (app.account_list.items) |acct| {
if (std.mem.eql(u8, acct, af)) {
found = true;
break;
}
}
if (!found) app.setAccountFilter(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 {
const filter = app.account_filter orelse return true;
const acct = account orelse return false;
return std.mem.eql(u8, acct, filter);
}
/// 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.
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;
}
/// Account-filtered view of an allocation. When a position spans multiple accounts,
/// this holds the values for only the lots matching the active account filter.
const FilteredAlloc = struct {
shares: f64,
cost_basis: f64,
market_value: f64,
unrealized_gain_loss: f64,
};
/// 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.
fn filteredAllocValues(app: *const App, a: zfin.valuation.Allocation) FilteredAlloc {
const filter = app.account_filter orelse 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;
}
}
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).
const FilteredTotals = struct {
value: f64,
cost: f64,
};
/// Compute total value and cost across all asset types for the active account filter.
/// Returns {0, 0} if no filter is active.
fn computeFilteredTotals(app: *const App) FilteredTotals {
if (app.account_filter == null) return .{ .value = 0, .cost = 0 };
var value: f64 = 0;
var cost: f64 = 0;
if (app.portfolio_summary) |s| {
for (s.allocations) |a| {
if (allocationMatchesFilter(app, a)) {
const fa = filteredAllocValues(app, a);
value += fa.market_value;
cost += fa.cost_basis;
}
}
}
if (app.portfolio) |pf| {
for (pf.lots) |lot| {
if (!matchesAccountFilter(app, lot.account)) continue;
switch (lot.security_type) {
.cash => {
value += lot.shares;
cost += lot.shares;
},
.cd => {
value += lot.shares;
cost += lot.shares;
},
.option => {
const opt_cost = @abs(lot.shares) * lot.open_price;
value += opt_cost;
cost += opt_cost;
},
else => {},
}
}
}
return .{ .value = value, .cost = cost };
}
// Rendering
@ -510,6 +721,39 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.portfolio_summary) |s| {
if (app.account_filter) |af| {
// Filtered mode: compute account-specific totals
const ft = computeFilteredTotals(app);
const filtered_value = ft.value;
const filtered_cost = ft.cost;
const filtered_gl = filtered_value - filtered_cost;
const filtered_return = if (filtered_cost > 0) (filtered_gl / filtered_cost) else @as(f64, 0);
// Account name line
const acct_text = try std.fmt.allocPrint(arena, " Account: {s}", .{af});
try lines.append(arena, .{ .text = acct_text, .style = th.headerStyle() });
var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined;
const val_str = fmt.fmtMoneyAbs(&val_buf, filtered_value);
const cost_str = fmt.fmtMoneyAbs(&cost_buf, filtered_cost);
const gl_abs = if (filtered_gl >= 0) filtered_gl else -filtered_gl;
const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs);
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{
val_str, cost_str, if (filtered_gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, filtered_return * 100.0,
});
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
if (app.candle_last_date) |d| {
var asof_buf: [10]u8 = undefined;
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
// No historical snapshots or net worth when filtered
} else {
// Unfiltered mode: use portfolio_summary totals directly
var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined;
@ -549,7 +793,6 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
// Historical portfolio value snapshots
if (app.historical_snapshots) |snapshots| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Build a single-line summary: " Historical: 1M: +3.2% 3M: +8.1% 1Y: +22.4% 3Y: +45.1% 5Y: -- 10Y: --"
var hist_parts: [6][]const u8 = undefined;
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
const snap = snapshots[pi];
@ -562,6 +805,7 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
});
try lines.append(arena, .{ .text = hist_text, .style = th.mutedStyle() });
}
}
} else if (app.portfolio != null) {
try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf <SYMBOL>' for each holding.", .style = th.mutedStyle() });
} else {
@ -602,6 +846,12 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
app.portfolio_header_lines = lines.items.len;
app.portfolio_line_count = 0;
// Compute filtered total value for account-relative weight calculation
const filtered_total_for_weight: f64 = if (app.account_filter != null)
computeFilteredTotals(app).value
else
0;
// Data rows
for (app.portfolio_rows.items, 0..) |row, ri| {
const lines_before = lines.items.len;
@ -612,23 +862,30 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
if (app.portfolio_summary) |s| {
if (row.pos_idx < s.allocations.len) {
const a = s.allocations[row.pos_idx];
// Use account-filtered values for multi-account positions
const fa = filteredAllocValues(app, a);
const display_shares = fa.shares;
const display_avg_cost = if (fa.shares > 0) fa.cost_basis / fa.shares else a.avg_cost;
const display_mv = fa.market_value;
const display_gl = fa.unrealized_gain_loss;
const is_multi = row.lot_count > 1;
const is_expanded = is_multi and row.pos_idx < app.expanded.len and app.expanded[row.pos_idx];
const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> ";
const star: []const u8 = if (is_active_sym) "* " else " ";
const pnl_pct = if (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0);
const pnl_pct = if (fa.cost_basis > 0) (display_gl / fa.cost_basis) * 100.0 else @as(f64, 0);
var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_abs = if (display_gl >= 0) display_gl else -display_gl;
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
var pnl_buf: [20]u8 = undefined;
const pnl_str = if (a.unrealized_gain_loss >= 0)
const pnl_str = if (display_gl >= 0)
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
else
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
var mv_buf: [24]u8 = undefined;
const mv_str = fmt.fmtMoneyAbs(&mv_buf, a.market_value);
const mv_str = fmt.fmtMoneyAbs(&mv_buf, display_mv);
var cost_buf2: [24]u8 = undefined;
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost);
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, display_avg_cost);
var price_buf2: [24]u8 = undefined;
const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price);
@ -674,8 +931,13 @@ pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width
}
}
const display_weight = if (app.account_filter != null and filtered_total_for_weight > 0)
(display_mv / filtered_total_for_weight)
else
a.weight;
const text = try std.fmt.allocPrint(arena, "{s}{s}" ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
arrow, star, a.display_symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
arrow, star, a.display_symbol, display_shares, cost_str, price_str, mv_str, pnl_str, display_weight * 100.0, date_col, acct_col,
});
// base: neutral text for main cols, green/red only for gain/loss col
@ -925,6 +1187,11 @@ fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wid
/// Reload portfolio file from disk without re-fetching prices.
/// Uses cached candle data to recompute summary.
pub fn reloadPortfolioFile(app: *App) void {
// Save the account filter name before freeing the old portfolio.
// account_filter is an owned copy so it survives the portfolio free,
// but account_list entries borrow from the portfolio and will dangle.
app.account_list.clearRetainingCapacity();
// Re-read the portfolio file
if (app.portfolio) |*pf| pf.deinit();
app.portfolio = null;
@ -1019,6 +1286,7 @@ pub fn reloadPortfolioFile(app: *App) void {
}
sortPortfolioAllocations(app);
buildAccountList(app);
rebuildPortfolioRows(app);
// Invalidate analysis data -- it holds pointers into old portfolio memory
@ -1041,3 +1309,42 @@ pub fn reloadPortfolioFile(app: *App) void {
app.setStatus("Portfolio reloaded from disk");
}
}
// Account picker
/// Number of header lines in the account picker before the list items start.
/// Used for mouse click hit-testing.
pub const account_picker_header_lines: usize = 3;
/// Draw the account picker overlay (replaces portfolio content).
pub fn drawAccountPicker(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
const th = app.theme;
var lines: std.ArrayList(tui.StyledLine) = .empty;
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() });
// 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 });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Scroll so cursor is visible
const cursor_line = app.account_picker_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 = @min(start, if (lines.items.len > 0) lines.items.len - 1 else 0);
try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
}