diff --git a/src/cache/store.zig b/src/cache/store.zig index 9f614d7..e6f24b8 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -824,10 +824,16 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co .option => "option", .cd => "cd", .cash => "cash", + .watch => "watch", .stock => unreachable, }; try writer.print("security_type::{s},", .{type_str}); } + // Watch lots only need a symbol + if (lot.lot_type == .watch) { + try writer.print("symbol::{s}\n", .{lot.symbol}); + continue; + } try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{ lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price, }); @@ -851,6 +857,9 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co if (lot.rate) |r| { try writer.print(",rate:num:{d}", .{r}); } + if (lot.drip) { + try writer.writeAll(",drip::true"); + } try writer.writeAll("\n"); } @@ -921,6 +930,14 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por const r = Store.numVal(v); if (r > 0) lot.rate = r; } + } else if (std.mem.eql(u8, field.key, "drip")) { + if (field.value) |v| { + switch (v) { + .string => |s| lot.drip = std.mem.eql(u8, s, "true") or std.mem.eql(u8, s, "1"), + .number => |n| lot.drip = n > 0, + else => {}, + } + } } } diff --git a/src/cli/main.zig b/src/cli/main.zig index e46e2b4..82a7756 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1166,33 +1166,83 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] // Lot detail rows (always expanded for CLI) if (is_multi) { + // Check if any lots are DRIP + var has_drip = false; for (lots_for_sym.items) |lot| { - var lot_price_buf: [24]u8 = undefined; - var lot_date_buf: [10]u8 = undefined; - const date_str = lot.open_date.format(&lot_date_buf); - const indicator = fmt.capitalGainsIndicator(lot.open_date); - const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; - const acct_col: []const u8 = lot.account orelse ""; + if (lot.drip) { has_drip = true; break; } + } - // Compute lot gain/loss - const use_price = lot.close_price orelse a.current_price; - const gl = lot.shares * (use_price - lot.open_price); - var lot_gl_buf: [24]u8 = undefined; - const lot_gl_abs = if (gl >= 0) gl else -gl; - const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs); - const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; + if (!has_drip) { + // No DRIP: show all individually + for (lots_for_sym.items) |lot| { + try printCliLotRow(out, color, lot, a.current_price); + } + } else { + // Show non-DRIP lots individually + for (lots_for_sym.items) |lot| { + if (!lot.drip) { + try printCliLotRow(out, color, lot, a.current_price); + } + } - try setFg(out, color, CLR_MUTED); - try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ - status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "", - }); - try reset(out, color); - try setGainLoss(out, color, gl); - try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money }); - try reset(out, color); - try setFg(out, color, CLR_MUTED); - try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); - try reset(out, color); + // Summarize DRIP lots as ST/LT + var st_lots: usize = 0; + var st_shares: f64 = 0; + var st_cost: f64 = 0; + var st_first: ?zfin.Date = null; + var st_last: ?zfin.Date = null; + var lt_lots: usize = 0; + var lt_shares: f64 = 0; + var lt_cost: f64 = 0; + var lt_first: ?zfin.Date = null; + var lt_last: ?zfin.Date = null; + + for (lots_for_sym.items) |lot| { + if (!lot.drip) continue; + const is_lt = std.mem.eql(u8, fmt.capitalGainsIndicator(lot.open_date), "LT"); + if (is_lt) { + lt_lots += 1; + lt_shares += lot.shares; + lt_cost += lot.costBasis(); + if (lt_first == null or lot.open_date.days < lt_first.?.days) lt_first = lot.open_date; + if (lt_last == null or lot.open_date.days > lt_last.?.days) lt_last = lot.open_date; + } else { + st_lots += 1; + st_shares += lot.shares; + st_cost += lot.costBasis(); + if (st_first == null or lot.open_date.days < st_first.?.days) st_first = lot.open_date; + if (st_last == null or lot.open_date.days > st_last.?.days) st_last = lot.open_date; + } + } + + if (st_lots > 0) { + var avg_buf: [24]u8 = undefined; + var d1_buf: [10]u8 = undefined; + var d2_buf: [10]u8 = undefined; + try setFg(out, color, CLR_MUTED); + try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{ + st_lots, + st_shares, + fmt.fmtMoney2(&avg_buf, if (st_shares > 0) st_cost / st_shares else 0), + if (st_first) |d| d.format(&d1_buf)[0..7] else "?", + if (st_last) |d| d.format(&d2_buf)[0..7] else "?", + }); + try reset(out, color); + } + if (lt_lots > 0) { + var avg_buf2: [24]u8 = undefined; + var d1_buf2: [10]u8 = undefined; + var d2_buf2: [10]u8 = undefined; + try setFg(out, color, CLR_MUTED); + try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{ + lt_lots, + lt_shares, + fmt.fmtMoney2(&avg_buf2, if (lt_shares > 0) lt_cost / lt_shares else 0), + if (lt_first) |d| d.format(&d1_buf2)[0..7] else "?", + if (lt_last) |d| d.format(&d2_buf2)[0..7] else "?", + }); + try reset(out, color); + } } } } @@ -1370,41 +1420,71 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] try reset(out, color); } - // Watchlist - if (watchlist_path) |wl_path| { - const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; - if (wl_data) |wd| { - defer allocator.free(wd); - var store = zfin.cache.Store.init(allocator, config.cache_dir); + // Watchlist (from watch lots in portfolio + separate watchlist file) + { + var store = zfin.cache.Store.init(allocator, config.cache_dir); + var any_watch = false; + var watch_seen = std.StringHashMap(void).init(allocator); + defer watch_seen.deinit(); - try out.print("\n", .{}); - try setBold(out, color); - try out.print(" Watchlist:\n", .{}); - try reset(out, color); + // Mark portfolio position symbols as seen + for (summary.allocations) |a| { + try watch_seen.put(a.symbol, {}); + } - var wl_lines = std.mem.splitScalar(u8, wd, '\n'); - while (wl_lines.next()) |line| { - const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); - if (trimmed.len == 0 or trimmed[0] == '#') continue; - if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { - const rest = trimmed[idx + "symbol::".len ..]; - const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; - const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); - if (sym.len > 0 and sym.len <= 10) { - // Get price from cache if available - var price_str: [16]u8 = undefined; - var ps: []const u8 = "--"; - const cached = store.readRaw(sym, .candles_daily) catch null; - if (cached) |cdata| { - defer allocator.free(cdata); - if (zfin.cache.Store.deserializeCandles(allocator, cdata)) |candles| { - defer allocator.free(candles); - if (candles.len > 0) { - ps = fmt.fmtMoney2(&price_str, candles[candles.len - 1].close); - } - } else |_| {} + // Helper to render a watch symbol + const renderWatch = struct { + fn f(o: anytype, c: bool, s: *zfin.cache.Store, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void { + if (!any.*) { + try o.print("\n", .{}); + try setBold(o, c); + try o.print(" Watchlist:\n", .{}); + try reset(o, c); + any.* = true; + } + var price_str2: [16]u8 = undefined; + var ps2: []const u8 = "--"; + const cached2 = s.readRaw(sym, .candles_daily) catch null; + if (cached2) |cdata2| { + defer a2.free(cdata2); + if (zfin.cache.Store.deserializeCandles(a2, cdata2)) |candles2| { + defer a2.free(candles2); + if (candles2.len > 0) { + ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close); + } + } else |_| {} + } + try o.print(" {s:<6} {s:>10}\n", .{ sym, ps2 }); + } + }.f; + + // Watch lots from portfolio + for (portfolio.lots) |lot| { + if (lot.lot_type == .watch) { + if (watch_seen.contains(lot.symbol)) continue; + try watch_seen.put(lot.symbol, {}); + try renderWatch(out, color, &store, allocator, lot.symbol, &any_watch); + } + } + + // Separate watchlist file (backward compat) + if (watchlist_path) |wl_path| { + const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; + if (wl_data) |wd| { + defer allocator.free(wd); + var wl_lines = std.mem.splitScalar(u8, wd, '\n'); + while (wl_lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { + const rest = trimmed[idx + "symbol::".len ..]; + const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; + const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); + if (sym.len > 0 and sym.len <= 10) { + if (watch_seen.contains(sym)) continue; + try watch_seen.put(sym, {}); + try renderWatch(out, color, &store, allocator, sym, &any_watch); } - try out.print(" {s:<6} {s:>10}\n", .{ sym, ps }); } } } @@ -1461,6 +1541,34 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] try out.flush(); } +fn printCliLotRow(out: anytype, color: bool, lot: zfin.Lot, current_price: f64) !void { + var lot_price_buf: [24]u8 = undefined; + var lot_date_buf: [10]u8 = undefined; + const date_str = lot.open_date.format(&lot_date_buf); + const indicator = fmt.capitalGainsIndicator(lot.open_date); + const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; + const acct_col: []const u8 = lot.account orelse ""; + + const use_price = lot.close_price orelse current_price; + const gl = lot.shares * (use_price - lot.open_price); + var lot_gl_buf: [24]u8 = undefined; + const lot_gl_abs = if (gl >= 0) gl else -gl; + const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs); + const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; + + try setFg(out, color, CLR_MUTED); + try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ + status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "", + }); + try reset(out, color); + try setGainLoss(out, color, gl); + try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money }); + try reset(out, color); + try setFg(out, color, CLR_MUTED); + try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); + try reset(out, color); +} + fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void { if (std.mem.eql(u8, subcommand, "stats")) { var buf: [4096]u8 = undefined; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 8ac1e24..f20be1b 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -7,6 +7,7 @@ pub const LotType = enum { option, // option contracts cd, // certificates of deposit cash, // cash/money market + watch, // watchlist item (no position, just track price) pub fn label(self: LotType) []const u8 { return switch (self) { @@ -14,6 +15,7 @@ pub const LotType = enum { .option => "Option", .cd => "CD", .cash => "Cash", + .watch => "Watch", }; } @@ -21,6 +23,7 @@ pub const LotType = enum { if (std.mem.eql(u8, s, "option")) return .option; if (std.mem.eql(u8, s, "cd")) return .cd; if (std.mem.eql(u8, s, "cash")) return .cash; + if (std.mem.eql(u8, s, "watch")) return .watch; return .stock; } }; @@ -45,6 +48,9 @@ pub const Lot = struct { maturity_date: ?Date = null, /// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%) rate: ?f64 = null, + /// Whether this lot is from dividend reinvestment (DRIP). + /// DRIP lots are summarized as ST/LT groups instead of shown individually. + drip: bool = false, pub fn isOpen(self: Lot) bool { return self.close_date == null; @@ -275,6 +281,19 @@ pub const Portfolio = struct { } return false; } + + /// Get watchlist symbols (from watch lots in the portfolio). + pub fn watchSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { + var result = std.ArrayList([]const u8).empty; + errdefer result.deinit(allocator); + + for (self.lots) |lot| { + if (lot.lot_type == .watch) { + try result.append(allocator, lot.symbol); + } + } + return result.toOwnedSlice(allocator); + } }; test "lot basics" { diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 45ad087..2ebe085 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -25,6 +25,7 @@ pub const Action = enum { symbol_input, help, edit, + reload_portfolio, collapse_all_calls, collapse_all_puts, options_filter_1, @@ -102,6 +103,7 @@ const default_bindings = [_]Binding{ .{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, .{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .edit, .key = .{ .codepoint = 'e' } }, + .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } }, .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, .{ .action = .options_filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } }, diff --git a/src/tui/main.zig b/src/tui/main.zig index 1dcfd50..ba03ac4 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -64,8 +64,15 @@ const PortfolioRow = struct { lot: ?zfin.Lot = null, /// Number of lots for this symbol (set on position rows) lot_count: usize = 0, + /// DRIP summary data (for drip_summary rows) + drip_is_lt: bool = false, // true = LT summary, false = ST summary + drip_lot_count: usize = 0, + drip_shares: f64 = 0, + drip_avg_cost: f64 = 0, + drip_date_first: ?zfin.Date = null, + drip_date_last: ?zfin.Date = null, - const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total }; + const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total, drip_summary }; }; /// Styled line for rendering @@ -481,6 +488,10 @@ const App = struct { return ctx.consumeAndRedraw(); } }, + .reload_portfolio => { + self.reloadPortfolioFile(); + return ctx.consumeAndRedraw(); + }, .collapse_all_calls => { if (self.active_tab == .options) { self.toggleAllCallsPuts(true); @@ -547,7 +558,7 @@ const App = struct { self.rebuildPortfolioRows(); } }, - .lot, .option_row, .cd_row, .cash_row, .section_header => {}, + .lot, .option_row, .cd_row, .cash_row, .section_header, .drip_summary => {}, .cash_total => { self.cash_expanded = !self.cash_expanded; self.rebuildPortfolioRows(); @@ -807,12 +818,21 @@ const App = struct { self.freePortfolioSummary(); // Fetch data for watchlist symbols so they have prices to display + // (from both the separate watchlist file and watch lots in the portfolio) if (self.watchlist) |wl| { for (wl) |sym| { const result = self.svc.getCandles(sym) catch continue; self.allocator.free(result.data); } } + if (self.portfolio) |pf| { + for (pf.lots) |lot| { + if (lot.lot_type == .watch) { + const result = self.svc.getCandles(lot.symbol) catch continue; + self.allocator.free(result.data); + } + } + } const pf = self.portfolio orelse return; @@ -934,33 +954,130 @@ const App = struct { matching.append(self.allocator, lot) catch continue; } } - std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn); + std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn); + + // Check if any lots are DRIP + var has_drip = false; for (matching.items) |lot| { - self.portfolio_rows.append(self.allocator, .{ - .kind = .lot, - .symbol = lot.symbol, - .pos_idx = i, - .lot = lot, - }) catch continue; + if (lot.drip) { has_drip = true; break; } + } + + if (!has_drip) { + // No DRIP lots: show all individually + for (matching.items) |lot| { + self.portfolio_rows.append(self.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) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .lot, + .symbol = lot.symbol, + .pos_idx = i, + .lot = lot, + }) catch continue; + } + } + + // Build ST and LT DRIP summaries + var st_lots: usize = 0; + var st_shares: f64 = 0; + var st_cost: f64 = 0; + var st_first: ?zfin.Date = null; + var st_last: ?zfin.Date = null; + var lt_lots: usize = 0; + var lt_shares: f64 = 0; + var lt_cost: f64 = 0; + var lt_first: ?zfin.Date = null; + var lt_last: ?zfin.Date = null; + + for (matching.items) |lot| { + if (!lot.drip) continue; + const is_lt = std.mem.eql(u8, fmt.capitalGainsIndicator(lot.open_date), "LT"); + if (is_lt) { + lt_lots += 1; + lt_shares += lot.shares; + lt_cost += lot.costBasis(); + if (lt_first == null or lot.open_date.days < lt_first.?.days) lt_first = lot.open_date; + if (lt_last == null or lot.open_date.days > lt_last.?.days) lt_last = lot.open_date; + } else { + st_lots += 1; + st_shares += lot.shares; + st_cost += lot.costBasis(); + if (st_first == null or lot.open_date.days < st_first.?.days) st_first = lot.open_date; + if (st_last == null or lot.open_date.days > st_last.?.days) st_last = lot.open_date; + } + } + + if (st_lots > 0) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .drip_summary, + .symbol = a.symbol, + .pos_idx = i, + .drip_is_lt = false, + .drip_lot_count = st_lots, + .drip_shares = st_shares, + .drip_avg_cost = if (st_shares > 0) st_cost / st_shares else 0, + .drip_date_first = st_first, + .drip_date_last = st_last, + }) catch {}; + } + if (lt_lots > 0) { + self.portfolio_rows.append(self.allocator, .{ + .kind = .drip_summary, + .symbol = a.symbol, + .pos_idx = i, + .drip_is_lt = true, + .drip_lot_count = lt_lots, + .drip_shares = lt_shares, + .drip_avg_cost = if (lt_shares > 0) lt_cost / lt_shares else 0, + .drip_date_first = lt_first, + .drip_date_last = lt_last, + }) catch {}; + } } } } } } - // Add watchlist items (integrated, dimmed) + // Add watchlist items from both the separate watchlist file and + // watch lots embedded in the portfolio. Skip symbols already in allocations. + var watch_seen = std.StringHashMap(void).init(self.allocator); + defer watch_seen.deinit(); + + // Mark all portfolio position symbols as seen + if (self.portfolio_summary) |s| { + for (s.allocations) |a| { + watch_seen.put(a.symbol, {}) catch {}; + } + } + + // Watch lots from portfolio file + if (self.portfolio) |pf| { + for (pf.lots) |lot| { + if (lot.lot_type == .watch) { + if (watch_seen.contains(lot.symbol)) continue; + watch_seen.put(lot.symbol, {}) catch {}; + self.portfolio_rows.append(self.allocator, .{ + .kind = .watchlist, + .symbol = lot.symbol, + }) catch continue; + } + } + } + + // Separate watchlist file (backward compat) if (self.watchlist) |wl| { for (wl) |sym| { - if (self.portfolio_summary) |s| { - var found = false; - for (s.allocations) |a| { - if (std.mem.eql(u8, a.symbol, sym)) { - found = true; - break; - } - } - if (found) continue; - } + if (watch_seen.contains(sym)) continue; + watch_seen.put(sym, {}) catch {}; self.portfolio_rows.append(self.allocator, .{ .kind = .watchlist, .symbol = sym, @@ -1249,6 +1366,117 @@ const App = struct { self.portfolio_rows.clearRetainingCapacity(); } + /// Reload portfolio file from disk without re-fetching prices. + /// Uses cached candle data to recompute summary. + fn reloadPortfolioFile(self: *App) void { + // Re-read the portfolio file + if (self.portfolio) |*pf| pf.deinit(); + self.portfolio = null; + if (self.portfolio_path) |path| { + const file_data = std.fs.cwd().readFileAlloc(self.allocator, path, 10 * 1024 * 1024) catch { + self.setStatus("Error reading portfolio file"); + return; + }; + defer self.allocator.free(file_data); + if (zfin.cache.deserializePortfolio(self.allocator, file_data)) |pf| { + self.portfolio = pf; + } else |_| { + self.setStatus("Error parsing portfolio file"); + return; + } + } else { + self.setStatus("No portfolio file to reload"); + return; + } + + // Reload watchlist file too (if separate) + freeWatchlist(self.allocator, self.watchlist); + self.watchlist = null; + if (self.watchlist_path) |path| { + self.watchlist = loadWatchlist(self.allocator, path); + } + + // Recompute summary using cached prices (no network) + self.freePortfolioSummary(); + self.expanded = [_]bool{false} ** 64; + self.cash_expanded = false; + self.cursor = 0; + self.scroll_offset = 0; + self.portfolio_rows.clearRetainingCapacity(); + + const pf = self.portfolio orelse return; + const positions = pf.positions(self.allocator) catch { + self.setStatus("Error computing positions"); + return; + }; + defer self.allocator.free(positions); + + var prices = std.StringHashMap(f64).init(self.allocator); + defer prices.deinit(); + + const syms = pf.stockSymbols(self.allocator) catch { + self.setStatus("Error getting symbols"); + return; + }; + defer self.allocator.free(syms); + + var latest_date: ?zfin.Date = null; + var missing: usize = 0; + for (syms) |sym| { + // Cache only — no network + const candles_slice = self.svc.getCachedCandles(sym); + if (candles_slice) |cs| { + defer self.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; + } + } + self.candle_last_date = latest_date; + + var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices) catch { + self.setStatus("Error computing portfolio summary"); + return; + }; + + if (summary.allocations.len == 0) { + summary.deinit(self.allocator); + self.setStatus("No cached prices available"); + return; + } + + // Include non-stock assets + const cash_total = pf.totalCash(); + const cd_total_val = pf.totalCdFaceValue(); + const opt_total = pf.totalOptionCost(); + const non_stock = cash_total + cd_total_val + opt_total; + summary.total_value += non_stock; + summary.total_cost += non_stock; + if (summary.total_cost > 0) { + summary.unrealized_return = summary.unrealized_pnl / summary.total_cost; + } + if (summary.total_value > 0) { + for (summary.allocations) |*a| { + a.weight = a.market_value / summary.total_value; + } + } + + self.portfolio_summary = summary; + self.rebuildPortfolioRows(); + + 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)"; + self.setStatus(warn_msg); + } else { + self.setStatus("Portfolio reloaded from disk"); + } + } + // ── Drawing ────────────────────────────────────────────────── fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface { @@ -1708,6 +1936,24 @@ const App = struct { try lines.append(arena, .{ .text = text, .style = row_style5 }); } }, + .drip_summary => { + const label_str: []const u8 = if (row.drip_is_lt) "LT" else "ST"; + var drip_avg_buf: [24]u8 = undefined; + var drip_d1_buf: [10]u8 = undefined; + var drip_d2_buf: [10]u8 = undefined; + const drip_d1: []const u8 = if (row.drip_date_first) |d| d.format(&drip_d1_buf)[0..7] else "?"; + const drip_d2: []const u8 = if (row.drip_date_last) |d| d.format(&drip_d2_buf)[0..7] else "?"; + const text = try std.fmt.allocPrint(arena, " {s}: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})", .{ + label_str, + row.drip_lot_count, + row.drip_shares, + fmt.fmtMoney2(&drip_avg_buf, row.drip_avg_cost), + drip_d1, + drip_d2, + }); + 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;