1317 lines
60 KiB
Zig
1317 lines
60 KiB
Zig
const std = @import("std");
|
|
const vaxis = @import("vaxis");
|
|
const zfin = @import("../root.zig");
|
|
const fmt = @import("../format.zig");
|
|
const views = @import("../views/portfolio_sections.zig");
|
|
const cli = @import("../commands/common.zig");
|
|
const theme_mod = @import("theme.zig");
|
|
const tui = @import("../tui.zig");
|
|
|
|
const App = tui.App;
|
|
const StyledLine = tui.StyledLine;
|
|
const PortfolioRow = tui.PortfolioRow;
|
|
const PortfolioSortField = tui.PortfolioSortField;
|
|
const colLabel = tui.colLabel;
|
|
const glyph = tui.glyph;
|
|
|
|
// Portfolio column layout (display columns).
|
|
// Each column width includes its trailing separator space.
|
|
// prefix(4) + sym(sw+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) + gl(14+1) + weight(8) + date(13+1) + account
|
|
const prefix_cols: usize = 4;
|
|
const sw: usize = fmt.sym_col_width;
|
|
|
|
/// Cumulative column end positions for click-to-sort hit testing.
|
|
pub const col_end_symbol: usize = prefix_cols + sw + 1;
|
|
pub const col_end_shares: usize = col_end_symbol + 9;
|
|
pub const col_end_avg_cost: usize = col_end_shares + 11;
|
|
pub const col_end_price: usize = col_end_avg_cost + 11;
|
|
pub const col_end_market_value: usize = col_end_price + 17;
|
|
pub const col_end_gain_loss: usize = col_end_market_value + 15;
|
|
pub const col_end_weight: usize = col_end_gain_loss + 9;
|
|
pub const col_end_date: usize = col_end_weight + 14;
|
|
|
|
// Gain/loss column start position (used for alt-style coloring)
|
|
const gl_col_start: usize = col_end_market_value;
|
|
|
|
/// Map a semantic StyleIntent to a platform-specific vaxis style.
|
|
fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style {
|
|
return switch (intent) {
|
|
.normal => th.contentStyle(),
|
|
.muted => th.mutedStyle(),
|
|
.positive => th.positiveStyle(),
|
|
.negative => th.negativeStyle(),
|
|
};
|
|
}
|
|
|
|
// ── Data loading ──────────────────────────────────────────────
|
|
|
|
/// Load portfolio data: prices, summary, candle map, and historical snapshots.
|
|
///
|
|
/// Call paths:
|
|
/// 1. First tab visit: loadTabData() → here (guarded by portfolio_loaded flag)
|
|
/// 2. Manual refresh (r/F5): refreshCurrentTab() clears portfolio_loaded → loadTabData() → here
|
|
/// 3. Disk reload (R): reloadPortfolioFile() — separate function, cache-only, no network
|
|
///
|
|
/// On first call, uses prefetched_prices (populated before TUI started).
|
|
/// On refresh, fetches live via svc.loadPrices. Tab switching skips this
|
|
/// entirely because the portfolio_loaded guard in loadTabData() short-circuits.
|
|
pub fn loadPortfolioData(app: *App) void {
|
|
app.portfolio_loaded = true;
|
|
app.freePortfolioSummary();
|
|
|
|
const pf = app.portfolio orelse return;
|
|
|
|
const positions = pf.positions(app.allocator) catch {
|
|
app.setStatus("Error computing positions");
|
|
return;
|
|
};
|
|
defer app.allocator.free(positions);
|
|
|
|
var prices = std.StringHashMap(f64).init(app.allocator);
|
|
defer prices.deinit();
|
|
|
|
// Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
|
|
const syms = pf.stockSymbols(app.allocator) catch {
|
|
app.setStatus("Error getting symbols");
|
|
return;
|
|
};
|
|
defer app.allocator.free(syms);
|
|
|
|
var latest_date: ?zfin.Date = null;
|
|
var fail_count: usize = 0;
|
|
var fetch_count: usize = 0;
|
|
var stale_count: usize = 0;
|
|
var failed_syms: [8][]const u8 = undefined;
|
|
|
|
if (app.prefetched_prices) |*pp| {
|
|
// Use pre-fetched prices from before TUI started (first load only)
|
|
// Move stock prices into the working map
|
|
for (syms) |sym| {
|
|
if (pp.get(sym)) |price| {
|
|
prices.put(sym, price) catch {};
|
|
}
|
|
}
|
|
|
|
// Extract watchlist prices
|
|
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
|
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
|
|
}
|
|
var wp = &(app.watchlist_prices.?);
|
|
var pp_iter = pp.iterator();
|
|
while (pp_iter.next()) |entry| {
|
|
if (!prices.contains(entry.key_ptr.*)) {
|
|
wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
|
}
|
|
}
|
|
|
|
pp.deinit();
|
|
app.prefetched_prices = null;
|
|
} else {
|
|
// Live fetch (refresh path) — fetch watchlist first, then stock prices
|
|
if (app.watchlist_prices) |*wp| wp.clearRetainingCapacity() else {
|
|
app.watchlist_prices = std.StringHashMap(f64).init(app.allocator);
|
|
}
|
|
var wp = &(app.watchlist_prices.?);
|
|
if (app.watchlist) |wl| {
|
|
for (wl) |sym| {
|
|
const result = app.svc.getCandles(sym) catch continue;
|
|
defer app.allocator.free(result.data);
|
|
if (result.data.len > 0) {
|
|
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
|
}
|
|
}
|
|
}
|
|
for (pf.lots) |lot| {
|
|
if (lot.security_type == .watch) {
|
|
const sym = lot.priceSymbol();
|
|
const result = app.svc.getCandles(sym) catch continue;
|
|
defer app.allocator.free(result.data);
|
|
if (result.data.len > 0) {
|
|
wp.put(sym, result.data[result.data.len - 1].close) catch {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch stock prices with TUI status-bar progress
|
|
const TuiProgress = struct {
|
|
app: *App,
|
|
failed: *[8][]const u8,
|
|
fail_n: usize = 0,
|
|
|
|
fn onProgress(ctx: *anyopaque, _: usize, _: usize, symbol: []const u8, status: zfin.DataService.SymbolStatus) void {
|
|
const s: *@This() = @ptrCast(@alignCast(ctx));
|
|
switch (status) {
|
|
.fetching => {
|
|
var buf: [64]u8 = undefined;
|
|
const msg = std.fmt.bufPrint(&buf, "Loading {s}...", .{symbol}) catch "Loading...";
|
|
s.app.setStatus(msg);
|
|
},
|
|
.failed, .failed_used_stale => {
|
|
if (s.fail_n < s.failed.len) {
|
|
s.failed[s.fail_n] = symbol;
|
|
s.fail_n += 1;
|
|
}
|
|
},
|
|
else => {},
|
|
}
|
|
}
|
|
|
|
fn callback(s: *@This()) zfin.DataService.ProgressCallback {
|
|
return .{
|
|
.context = @ptrCast(s),
|
|
.on_progress = onProgress,
|
|
};
|
|
}
|
|
};
|
|
var tui_progress = TuiProgress{ .app = app, .failed = &failed_syms };
|
|
const load_result = app.svc.loadPrices(syms, &prices, false, tui_progress.callback());
|
|
latest_date = load_result.latest_date;
|
|
fail_count = load_result.fail_count;
|
|
fetch_count = load_result.fetched_count;
|
|
stale_count = load_result.stale_count;
|
|
}
|
|
app.candle_last_date = latest_date;
|
|
|
|
// Build portfolio summary, candle map, and historical snapshots
|
|
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc) catch |err| switch (err) {
|
|
error.NoAllocations => {
|
|
app.setStatus("No cached prices. Run: zfin perf <SYMBOL> first");
|
|
return;
|
|
},
|
|
error.SummaryFailed => {
|
|
app.setStatus("Error computing portfolio summary");
|
|
return;
|
|
},
|
|
else => {
|
|
app.setStatus("Error building portfolio data");
|
|
return;
|
|
},
|
|
};
|
|
// Transfer ownership: summary stored on App, candle_map freed after snapshots extracted
|
|
app.portfolio_summary = pf_data.summary;
|
|
app.historical_snapshots = pf_data.snapshots;
|
|
{
|
|
// Free candle_map values and map (snapshots are value types, already copied)
|
|
var it = pf_data.candle_map.valueIterator();
|
|
while (it.next()) |v| app.allocator.free(v.*);
|
|
pf_data.candle_map.deinit();
|
|
}
|
|
|
|
sortPortfolioAllocations(app);
|
|
buildAccountList(app);
|
|
rebuildPortfolioRows(app);
|
|
|
|
const summary = pf_data.summary;
|
|
if (app.symbol.len == 0 and summary.allocations.len > 0) {
|
|
app.setActiveSymbol(summary.allocations[0].symbol);
|
|
}
|
|
|
|
// Show warning if any securities failed to load
|
|
if (fail_count > 0) {
|
|
var warn_buf: [256]u8 = undefined;
|
|
if (fail_count <= 3) {
|
|
// Show actual symbol names for easier debugging
|
|
var sym_buf: [128]u8 = undefined;
|
|
var sym_len: usize = 0;
|
|
const show = @min(fail_count, failed_syms.len);
|
|
for (0..show) |fi| {
|
|
if (sym_len > 0) {
|
|
if (sym_len + 2 < sym_buf.len) {
|
|
sym_buf[sym_len] = ',';
|
|
sym_buf[sym_len + 1] = ' ';
|
|
sym_len += 2;
|
|
}
|
|
}
|
|
const s = failed_syms[fi];
|
|
const copy_len = @min(s.len, sym_buf.len - sym_len);
|
|
@memcpy(sym_buf[sym_len..][0..copy_len], s[0..copy_len]);
|
|
sym_len += copy_len;
|
|
}
|
|
if (stale_count > 0) {
|
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to refresh: {s} (using stale cache)", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
|
|
app.setStatus(warn_msg);
|
|
} else {
|
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "Failed to load: {s}", .{sym_buf[0..sym_len]}) catch "Warning: some securities failed";
|
|
app.setStatus(warn_msg);
|
|
}
|
|
} else {
|
|
if (stale_count > 0 and stale_count == fail_count) {
|
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "{d} symbols failed to refresh (using stale cache) | r/F5 to retry", .{fail_count}) catch "Warning: some securities used stale cache";
|
|
app.setStatus(warn_msg);
|
|
} else {
|
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
|
|
app.setStatus(warn_msg);
|
|
}
|
|
}
|
|
} else if (fetch_count > 0) {
|
|
var info_buf: [128]u8 = undefined;
|
|
const info_msg = std.fmt.bufPrint(&info_buf, "Loaded {d} symbols ({d} fetched) | r/F5 to refresh", .{ syms.len, fetch_count }) catch "Loaded | r/F5 to refresh";
|
|
app.setStatus(info_msg);
|
|
} else {
|
|
app.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
|
|
}
|
|
}
|
|
|
|
pub fn sortPortfolioAllocations(app: *App) void {
|
|
if (app.portfolio_summary) |s| {
|
|
const SortCtx = struct {
|
|
field: PortfolioSortField,
|
|
dir: tui.SortDirection,
|
|
|
|
fn lessThan(ctx: @This(), a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
|
|
const lhs = if (ctx.dir == .asc) a else b;
|
|
const rhs = if (ctx.dir == .asc) b else a;
|
|
return switch (ctx.field) {
|
|
.symbol => std.mem.lessThan(u8, lhs.display_symbol, rhs.display_symbol),
|
|
.shares => lhs.shares < rhs.shares,
|
|
.avg_cost => lhs.avg_cost < rhs.avg_cost,
|
|
.price => lhs.current_price < rhs.current_price,
|
|
.market_value => lhs.market_value < rhs.market_value,
|
|
.gain_loss => lhs.unrealized_gain_loss < rhs.unrealized_gain_loss,
|
|
.weight => lhs.weight < rhs.weight,
|
|
.account => std.mem.lessThan(u8, lhs.account, rhs.account),
|
|
};
|
|
}
|
|
};
|
|
std.mem.sort(zfin.valuation.Allocation, s.allocations, SortCtx{ .field = app.portfolio_sort_field, .dir = app.portfolio_sort_dir }, SortCtx.lessThan);
|
|
}
|
|
}
|
|
|
|
pub fn rebuildPortfolioRows(app: *App) void {
|
|
app.portfolio_rows.clearRetainingCapacity();
|
|
app.freePreparedSections();
|
|
|
|
if (app.portfolio_summary) |s| {
|
|
for (s.allocations, 0..) |a, i| {
|
|
// 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)) {
|
|
if (matchesAccountFilter(app, lot.account)) lcount += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .position,
|
|
.symbol = a.symbol,
|
|
.pos_idx = i,
|
|
.lot_count = lcount,
|
|
}) catch continue;
|
|
|
|
// Only expand if multi-lot
|
|
if (lcount > 1 and i < app.expanded.len and app.expanded[i]) {
|
|
if (app.portfolio) |pf| {
|
|
// Collect matching lots, sort: open first (date desc), then closed (date desc)
|
|
var matching: std.ArrayList(zfin.Lot) = .empty;
|
|
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;
|
|
}
|
|
}
|
|
std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn);
|
|
|
|
// Check if any lots are DRIP
|
|
var has_drip = false;
|
|
for (matching.items) |lot| {
|
|
if (lot.drip) {
|
|
has_drip = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!has_drip) {
|
|
// No DRIP lots: show all individually
|
|
for (matching.items) |lot| {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .lot,
|
|
.symbol = lot.symbol,
|
|
.pos_idx = i,
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
} else {
|
|
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
|
|
for (matching.items) |lot| {
|
|
if (!lot.drip) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .lot,
|
|
.symbol = lot.symbol,
|
|
.pos_idx = i,
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
|
|
// Build ST and LT DRIP summaries
|
|
const drip = fmt.aggregateDripLots(matching.items);
|
|
|
|
if (!drip.st.isEmpty()) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .drip_summary,
|
|
.symbol = a.symbol,
|
|
.pos_idx = i,
|
|
.drip_is_lt = false,
|
|
.drip_lot_count = drip.st.lot_count,
|
|
.drip_shares = drip.st.shares,
|
|
.drip_avg_cost = drip.st.avgCost(),
|
|
.drip_date_first = drip.st.first_date,
|
|
.drip_date_last = drip.st.last_date,
|
|
}) catch {};
|
|
}
|
|
if (!drip.lt.isEmpty()) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .drip_summary,
|
|
.symbol = a.symbol,
|
|
.pos_idx = i,
|
|
.drip_is_lt = true,
|
|
.drip_lot_count = drip.lt.lot_count,
|
|
.drip_shares = drip.lt.shares,
|
|
.drip_avg_cost = drip.lt.avgCost(),
|
|
.drip_date_first = drip.lt.first_date,
|
|
.drip_date_last = drip.lt.last_date,
|
|
}) catch {};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Mark all portfolio position symbols as seen
|
|
if (app.portfolio_summary) |s| {
|
|
for (s.allocations) |a| {
|
|
watch_seen.put(a.symbol, {}) catch {};
|
|
}
|
|
}
|
|
|
|
// Watch lots from portfolio file
|
|
if (app.portfolio) |pf| {
|
|
for (pf.lots) |lot| {
|
|
if (lot.security_type == .watch) {
|
|
if (watch_seen.contains(lot.priceSymbol())) continue;
|
|
watch_seen.put(lot.priceSymbol(), {}) catch {};
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .watchlist,
|
|
.symbol = lot.symbol,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Separate watchlist file (backward compat)
|
|
if (app.watchlist) |wl| {
|
|
for (wl) |sym| {
|
|
if (watch_seen.contains(sym)) continue;
|
|
watch_seen.put(sym, {}) catch {};
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .watchlist,
|
|
.symbol = sym,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Options section (sorted by expiration date, then symbol; filtered by account)
|
|
if (app.portfolio) |pf| {
|
|
app.prepared_options = views.Options.init(app.allocator, pf.lots, app.account_filter) catch null;
|
|
if (app.prepared_options) |opts| {
|
|
if (opts.items.len > 0) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .section_header,
|
|
.symbol = "Options",
|
|
}) catch {};
|
|
for (opts.items) |po| {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .option_row,
|
|
.symbol = po.lot.symbol,
|
|
.lot = po.lot,
|
|
.prepared_text = po.columns[0].text,
|
|
.row_style = po.row_style,
|
|
.premium_style = po.premium_style,
|
|
.premium_col_start = po.premium_col_start,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// CDs section (sorted by maturity date, earliest first; filtered by account)
|
|
app.prepared_cds = views.CDs.init(app.allocator, pf.lots, app.account_filter) catch null;
|
|
if (app.prepared_cds) |cds| {
|
|
if (cds.items.len > 0) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .section_header,
|
|
.symbol = "Certificates of Deposit",
|
|
}) catch {};
|
|
for (cds.items) |pc| {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .cd_row,
|
|
.symbol = pc.lot.symbol,
|
|
.lot = pc.lot,
|
|
.prepared_text = pc.text,
|
|
.row_style = pc.row_style,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {};
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .cash_total,
|
|
.symbol = "CASH",
|
|
}) catch {};
|
|
if (app.cash_expanded) {
|
|
for (pf.lots) |lot| {
|
|
if (lot.security_type == .cash) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .cash_row,
|
|
.symbol = lot.account orelse "Unknown",
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {};
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .illiquid_total,
|
|
.symbol = "ILLIQUID",
|
|
}) catch {};
|
|
if (app.illiquid_expanded) {
|
|
for (pf.lots) |lot| {
|
|
if (lot.security_type == .illiquid) {
|
|
app.portfolio_rows.append(app.allocator, .{
|
|
.kind = .illiquid_row,
|
|
.symbol = lot.symbol,
|
|
.lot = lot,
|
|
}) catch continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 ─────────────────────────────────────────────────
|
|
|
|
pub fn drawContent(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const th = app.theme;
|
|
|
|
if (app.portfolio == null and app.watchlist == null) {
|
|
try drawWelcomeScreen(app, arena, buf, width, height);
|
|
return;
|
|
}
|
|
|
|
var lines: std.ArrayList(StyledLine) = .empty;
|
|
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;
|
|
const val_str = fmt.fmtMoneyAbs(&val_buf, s.total_value);
|
|
const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost);
|
|
const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
|
|
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 (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0,
|
|
});
|
|
const summary_style = if (s.unrealized_gain_loss >= 0) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
|
|
|
// "as of" date indicator
|
|
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() });
|
|
}
|
|
|
|
// Net Worth line (only if portfolio has illiquid assets)
|
|
if (app.portfolio) |pf| {
|
|
if (pf.hasType(.illiquid)) {
|
|
const illiquid_total = pf.totalIlliquid();
|
|
const net_worth = s.total_value + illiquid_total;
|
|
var nw_buf: [24]u8 = undefined;
|
|
var il_buf: [24]u8 = undefined;
|
|
const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {s} (Liquid: {s} Illiquid: {s})", .{
|
|
fmt.fmtMoneyAbs(&nw_buf, net_worth),
|
|
val_str,
|
|
fmt.fmtMoneyAbs(&il_buf, illiquid_total),
|
|
});
|
|
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
|
|
}
|
|
}
|
|
|
|
// Historical portfolio value snapshots
|
|
if (app.historical_snapshots) |snapshots| {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
var hist_parts: [6][]const u8 = undefined;
|
|
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
|
|
const snap = snapshots[pi];
|
|
var hbuf: [16]u8 = undefined;
|
|
const change_str = fmt.fmtHistoricalChange(&hbuf, snap.position_count, snap.changePct());
|
|
hist_parts[pi] = try std.fmt.allocPrint(arena, "{s}: {s}", .{ period.label(), change_str });
|
|
}
|
|
const hist_text = try std.fmt.allocPrint(arena, " Historical: {s} {s} {s} {s} {s} {s}", .{
|
|
hist_parts[0], hist_parts[1], hist_parts[2], hist_parts[3], hist_parts[4], hist_parts[5],
|
|
});
|
|
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 {
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
}
|
|
|
|
// Empty line before header
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
|
|
// Column header (4-char prefix to match arrow(2)+star(2) in data rows)
|
|
// Active sort column gets a sort indicator within the column width
|
|
const sf = app.portfolio_sort_field;
|
|
const si = app.portfolio_sort_dir.indicator();
|
|
// Build column labels with indicator embedded in padding
|
|
// Left-aligned cols: "Name▲ " Right-aligned cols: " ▼Price"
|
|
var sym_hdr_buf: [16]u8 = undefined;
|
|
var shr_hdr_buf: [16]u8 = undefined;
|
|
var avg_hdr_buf: [16]u8 = undefined;
|
|
var prc_hdr_buf: [16]u8 = undefined;
|
|
var mv_hdr_buf: [24]u8 = undefined;
|
|
var gl_hdr_buf: [24]u8 = undefined;
|
|
var wt_hdr_buf: [16]u8 = undefined;
|
|
const sym_hdr = colLabel(&sym_hdr_buf, "Symbol", fmt.sym_col_width, true, if (sf == .symbol) si else null);
|
|
const shr_hdr = colLabel(&shr_hdr_buf, "Shares", 8, false, if (sf == .shares) si else null);
|
|
const avg_hdr = colLabel(&avg_hdr_buf, "Avg Cost", 10, false, if (sf == .avg_cost) si else null);
|
|
const prc_hdr = colLabel(&prc_hdr_buf, "Price", 10, false, if (sf == .price) si else null);
|
|
const mv_hdr = colLabel(&mv_hdr_buf, "Market Value", 16, false, if (sf == .market_value) si else null);
|
|
const gl_hdr = colLabel(&gl_hdr_buf, "Gain/Loss", 14, false, if (sf == .gain_loss) si else null);
|
|
const wt_hdr = colLabel(&wt_hdr_buf, "Weight", 8, false, if (sf == .weight) si else null);
|
|
const acct_ind: []const u8 = if (sf == .account) si else "";
|
|
|
|
const hdr = try std.fmt.allocPrint(arena, " {s} {s} {s} {s} {s} {s} {s} {s:>13} {s}{s}", .{
|
|
sym_hdr, shr_hdr, avg_hdr, prc_hdr, mv_hdr, gl_hdr, wt_hdr, "Date", acct_ind, "Account",
|
|
});
|
|
try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() });
|
|
|
|
// Track header line count for mouse click mapping (after all header lines)
|
|
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;
|
|
const is_cursor = ri == app.cursor;
|
|
const is_active_sym = std.mem.eql(u8, row.symbol, app.symbol);
|
|
switch (row.kind) {
|
|
.position => {
|
|
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 (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 (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 (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, display_mv);
|
|
var cost_buf2: [24]u8 = undefined;
|
|
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);
|
|
|
|
// Date + ST/LT: show for single-lot, blank for multi-lot
|
|
var pos_date_buf: [10]u8 = undefined;
|
|
var date_col: []const u8 = "";
|
|
var acct_col: []const u8 = "";
|
|
if (!is_multi) {
|
|
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 (!mixed) {
|
|
acct_col = common_acct orelse "";
|
|
} else {
|
|
acct_col = "Multiple";
|
|
}
|
|
}
|
|
}
|
|
|
|
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, 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
|
|
// Manual-price positions use warning color to indicate stale/estimated price
|
|
const base_style = if (is_cursor) th.selectStyle() else if (a.is_manual_price) th.warningStyle() else th.contentStyle();
|
|
const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle();
|
|
|
|
try lines.append(arena, .{
|
|
.text = text,
|
|
.style = base_style,
|
|
.alt_style = gl_style,
|
|
.alt_start = gl_col_start,
|
|
.alt_end = gl_col_start + 14,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
.lot => {
|
|
if (row.lot) |lot| {
|
|
var date_buf: [10]u8 = undefined;
|
|
const date_str = lot.open_date.format(&date_buf);
|
|
|
|
// Compute lot gain/loss and market value if we have a price
|
|
var lot_gl_str: []const u8 = "";
|
|
var lot_mv_str: []const u8 = "";
|
|
var lot_positive = true;
|
|
if (app.portfolio_summary) |s| {
|
|
if (row.pos_idx < s.allocations.len) {
|
|
const price = s.allocations[row.pos_idx].current_price;
|
|
const use_price = lot.close_price orelse price;
|
|
const gl = lot.shares * (use_price - lot.open_price);
|
|
lot_positive = gl >= 0;
|
|
var lot_gl_money_buf: [24]u8 = undefined;
|
|
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
|
|
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{
|
|
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money,
|
|
});
|
|
var lot_mv_buf: [24]u8 = undefined;
|
|
lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)});
|
|
}
|
|
}
|
|
|
|
var price_str2: [24]u8 = undefined;
|
|
const lot_price_str = fmt.fmtMoneyAbs(&price_str2, lot.open_price);
|
|
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
|
|
const indicator = fmt.capitalGainsIndicator(lot.open_date);
|
|
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
|
|
const acct_col: []const u8 = lot.account orelse "";
|
|
const text = try std.fmt.allocPrint(arena, " " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{
|
|
status_str, lot.shares, lot_price_str, "", lot_mv_str, lot_gl_str, "", lot_date_col, acct_col,
|
|
});
|
|
const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
|
const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle();
|
|
try lines.append(arena, .{
|
|
.text = text,
|
|
.style = base_style,
|
|
.alt_style = gl_col_style,
|
|
.alt_start = gl_col_start,
|
|
.alt_end = gl_col_start + 14,
|
|
});
|
|
}
|
|
},
|
|
.watchlist => {
|
|
var price_str3: [16]u8 = undefined;
|
|
const ps: []const u8 = if (app.watchlist_prices) |wp|
|
|
(if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--")
|
|
else
|
|
"--";
|
|
const star2: []const u8 = if (is_active_sym) "* " else " ";
|
|
const text = try std.fmt.allocPrint(arena, " {s}" ++ fmt.sym_col_spec ++ " {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{
|
|
star2, row.symbol, "--", "--", ps, "--", "--", "watch", "",
|
|
});
|
|
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style });
|
|
},
|
|
.section_header => {
|
|
// Blank line before section header
|
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
|
const hdr_text = try std.fmt.allocPrint(arena, " {s}", .{row.symbol});
|
|
const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
|
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
|
|
// Add column headers for each section type
|
|
if (std.mem.eql(u8, row.symbol, "Options")) {
|
|
const col_hdr = try std.fmt.allocPrint(arena, views.OptionsLayout.header, views.OptionsLayout.header_labels);
|
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
|
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
|
|
const col_hdr = try std.fmt.allocPrint(arena, views.CDsLayout.header, views.CDsLayout.header_labels);
|
|
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
|
|
}
|
|
},
|
|
.option_row => {
|
|
if (row.prepared_text) |text| {
|
|
const row_style2 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
|
|
const prem_style = if (is_cursor) th.selectStyle() else mapIntent(th, row.premium_style);
|
|
try lines.append(arena, .{
|
|
.text = text,
|
|
.style = row_style2,
|
|
.alt_style = prem_style,
|
|
.alt_start = row.premium_col_start,
|
|
.alt_end = row.premium_col_start + 14,
|
|
});
|
|
}
|
|
},
|
|
.cd_row => {
|
|
if (row.prepared_text) |text| {
|
|
const row_style3 = if (is_cursor) th.selectStyle() else mapIntent(th, row.row_style);
|
|
try lines.append(arena, .{ .text = text, .style = row_style3 });
|
|
}
|
|
},
|
|
.cash_total => {
|
|
if (app.portfolio) |pf| {
|
|
const total_cash = pf.totalCash();
|
|
var cash_buf: [24]u8 = undefined;
|
|
const arrow3: []const u8 = if (app.cash_expanded) "v " else "> ";
|
|
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
|
|
arrow3,
|
|
fmt.fmtMoneyAbs(&cash_buf, total_cash),
|
|
});
|
|
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style4 });
|
|
}
|
|
},
|
|
.cash_row => {
|
|
if (row.lot) |lot| {
|
|
var cash_row_buf: [160]u8 = undefined;
|
|
const row_text = fmt.fmtCashRow(&cash_row_buf, row.symbol, lot.shares, lot.note);
|
|
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
|
|
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style5 });
|
|
}
|
|
},
|
|
.illiquid_total => {
|
|
if (app.portfolio) |pf| {
|
|
const total_illiquid = pf.totalIlliquid();
|
|
var illiquid_buf: [24]u8 = undefined;
|
|
const arrow4: []const u8 = if (app.illiquid_expanded) "v " else "> ";
|
|
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{
|
|
arrow4,
|
|
fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid),
|
|
});
|
|
const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style6 });
|
|
}
|
|
},
|
|
.illiquid_row => {
|
|
if (row.lot) |lot| {
|
|
var illiquid_row_buf: [160]u8 = undefined;
|
|
const row_text = fmt.fmtIlliquidRow(&illiquid_row_buf, row.symbol, lot.shares, lot.note);
|
|
const text = try std.fmt.allocPrint(arena, " {s}", .{row_text});
|
|
const row_style7 = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
|
try lines.append(arena, .{ .text = text, .style = row_style7 });
|
|
}
|
|
},
|
|
.drip_summary => {
|
|
var drip_buf: [128]u8 = undefined;
|
|
const drip_text = fmt.fmtDripSummary(&drip_buf, if (row.drip_is_lt) "LT" else "ST", .{
|
|
.lot_count = row.drip_lot_count,
|
|
.shares = row.drip_shares,
|
|
.cost = row.drip_shares * row.drip_avg_cost,
|
|
.first_date = row.drip_date_first,
|
|
.last_date = row.drip_date_last,
|
|
});
|
|
const text = try std.fmt.allocPrint(arena, " {s}", .{drip_text});
|
|
const drip_style = if (is_cursor) th.selectStyle() else th.mutedStyle();
|
|
try lines.append(arena, .{ .text = text, .style = drip_style });
|
|
},
|
|
}
|
|
// Map all styled lines produced by this row back to the row index
|
|
const lines_after = lines.items.len;
|
|
for (lines_before..lines_after) |li| {
|
|
const map_idx = li - app.portfolio_header_lines;
|
|
if (map_idx < app.portfolio_line_to_row.len) {
|
|
app.portfolio_line_to_row[map_idx] = ri;
|
|
}
|
|
}
|
|
app.portfolio_line_count = lines_after - app.portfolio_header_lines;
|
|
}
|
|
|
|
// Render
|
|
const start = @min(app.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0);
|
|
try app.drawStyledContent(arena, buf, width, height, lines.items[start..]);
|
|
}
|
|
|
|
fn drawWelcomeScreen(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
|
const th = app.theme;
|
|
const welcome_lines = [_]StyledLine{
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " zfin", .style = th.headerStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " No portfolio loaded.", .style = th.mutedStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Getting started:", .style = th.contentStyle() },
|
|
.{ .text = " / Enter a stock symbol (e.g. AAPL, VTI)", .style = th.contentStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Portfolio mode:", .style = th.contentStyle() },
|
|
.{ .text = " zfin -p portfolio.srf Load a portfolio file", .style = th.mutedStyle() },
|
|
.{ .text = " portfolio.srf Auto-loaded from cwd if present", .style = th.mutedStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Navigation:", .style = th.contentStyle() },
|
|
.{ .text = " h / l Previous / next tab", .style = th.mutedStyle() },
|
|
.{ .text = " j / k Select next / prev item", .style = th.mutedStyle() },
|
|
.{ .text = " Enter Expand position lots", .style = th.mutedStyle() },
|
|
.{ .text = " s Select symbol for other tabs", .style = th.mutedStyle() },
|
|
.{ .text = " 1-5 Jump to tab", .style = th.mutedStyle() },
|
|
.{ .text = " ? Full help", .style = th.mutedStyle() },
|
|
.{ .text = " q Quit", .style = th.mutedStyle() },
|
|
.{ .text = "", .style = th.contentStyle() },
|
|
.{ .text = " Sample portfolio.srf:", .style = th.contentStyle() },
|
|
.{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.mutedStyle() },
|
|
.{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.mutedStyle() },
|
|
};
|
|
try app.drawStyledContent(arena, buf, width, height, &welcome_lines);
|
|
}
|
|
|
|
/// 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;
|
|
if (app.portfolio_path) |path| {
|
|
const file_data = std.fs.cwd().readFileAlloc(app.allocator, path, 10 * 1024 * 1024) catch {
|
|
app.setStatus("Error reading portfolio file");
|
|
return;
|
|
};
|
|
defer app.allocator.free(file_data);
|
|
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
|
|
app.portfolio = pf;
|
|
} else |_| {
|
|
app.setStatus("Error parsing portfolio file");
|
|
return;
|
|
}
|
|
} else {
|
|
app.setStatus("No portfolio file to reload");
|
|
return;
|
|
}
|
|
|
|
// Reload watchlist file too (if separate)
|
|
tui.freeWatchlist(app.allocator, app.watchlist);
|
|
app.watchlist = null;
|
|
if (app.watchlist_path) |path| {
|
|
app.watchlist = tui.loadWatchlist(app.allocator, path);
|
|
}
|
|
|
|
// Recompute summary using cached prices (no network)
|
|
app.freePortfolioSummary();
|
|
app.expanded = [_]bool{false} ** 64;
|
|
app.cash_expanded = false;
|
|
app.illiquid_expanded = false;
|
|
app.cursor = 0;
|
|
app.scroll_offset = 0;
|
|
app.portfolio_rows.clearRetainingCapacity();
|
|
|
|
const pf = app.portfolio orelse return;
|
|
const positions = pf.positions(app.allocator) catch {
|
|
app.setStatus("Error computing positions");
|
|
return;
|
|
};
|
|
defer app.allocator.free(positions);
|
|
|
|
var prices = std.StringHashMap(f64).init(app.allocator);
|
|
defer prices.deinit();
|
|
|
|
const syms = pf.stockSymbols(app.allocator) catch {
|
|
app.setStatus("Error getting symbols");
|
|
return;
|
|
};
|
|
defer app.allocator.free(syms);
|
|
|
|
var latest_date: ?zfin.Date = null;
|
|
var missing: usize = 0;
|
|
for (syms) |sym| {
|
|
// Cache only — no network
|
|
const candles_slice = app.svc.getCachedCandles(sym);
|
|
if (candles_slice) |cs| {
|
|
defer app.allocator.free(cs);
|
|
if (cs.len > 0) {
|
|
prices.put(sym, cs[cs.len - 1].close) catch {};
|
|
const d = cs[cs.len - 1].date;
|
|
if (latest_date == null or d.days > latest_date.?.days) latest_date = d;
|
|
}
|
|
} else {
|
|
missing += 1;
|
|
}
|
|
}
|
|
app.candle_last_date = latest_date;
|
|
|
|
// Build portfolio summary, candle map, and historical snapshots from cache
|
|
var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc) catch |err| switch (err) {
|
|
error.NoAllocations => {
|
|
app.setStatus("No cached prices available");
|
|
return;
|
|
},
|
|
error.SummaryFailed => {
|
|
app.setStatus("Error computing portfolio summary");
|
|
return;
|
|
},
|
|
else => {
|
|
app.setStatus("Error building portfolio data");
|
|
return;
|
|
},
|
|
};
|
|
app.portfolio_summary = pf_data.summary;
|
|
app.historical_snapshots = pf_data.snapshots;
|
|
{
|
|
var it = pf_data.candle_map.valueIterator();
|
|
while (it.next()) |v| app.allocator.free(v.*);
|
|
pf_data.candle_map.deinit();
|
|
}
|
|
|
|
sortPortfolioAllocations(app);
|
|
buildAccountList(app);
|
|
rebuildPortfolioRows(app);
|
|
|
|
// Invalidate analysis data -- it holds pointers into old portfolio memory
|
|
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
|
app.analysis_result = null;
|
|
app.analysis_loaded = false;
|
|
app.analysis_disabled = false; // Portfolio loaded; analysis is now possible
|
|
|
|
// If currently on the analysis tab, eagerly recompute so the user
|
|
// doesn't see an error message before switching away and back.
|
|
if (app.active_tab == .analysis) {
|
|
app.loadAnalysisData();
|
|
}
|
|
|
|
if (missing > 0) {
|
|
var warn_buf: [128]u8 = undefined;
|
|
const warn_msg = std.fmt.bufPrint(&warn_buf, "Reloaded. {d} symbols missing cached prices", .{missing}) catch "Reloaded (some prices missing)";
|
|
app.setStatus(warn_msg);
|
|
} else {
|
|
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..]);
|
|
}
|