ai: handle drip investments

This commit is contained in:
Emil Lerch 2026-02-26 10:06:20 -08:00
parent bec1dcff2c
commit 7d72fe734c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 468 additions and 76 deletions

17
src/cache/store.zig vendored
View file

@ -824,10 +824,16 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
.option => "option", .option => "option",
.cd => "cd", .cd => "cd",
.cash => "cash", .cash => "cash",
.watch => "watch",
.stock => unreachable, .stock => unreachable,
}; };
try writer.print("security_type::{s},", .{type_str}); 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}", .{ 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, 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| { if (lot.rate) |r| {
try writer.print(",rate:num:{d}", .{r}); try writer.print(",rate:num:{d}", .{r});
} }
if (lot.drip) {
try writer.writeAll(",drip::true");
}
try writer.writeAll("\n"); try writer.writeAll("\n");
} }
@ -921,6 +930,14 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
const r = Store.numVal(v); const r = Store.numVal(v);
if (r > 0) lot.rate = r; 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 => {},
}
}
} }
} }

View file

@ -1166,36 +1166,86 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
// Lot detail rows (always expanded for CLI) // Lot detail rows (always expanded for CLI)
if (is_multi) { if (is_multi) {
// Check if any lots are DRIP
var has_drip = false;
for (lots_for_sym.items) |lot| { for (lots_for_sym.items) |lot| {
var lot_price_buf: [24]u8 = undefined; if (lot.drip) { has_drip = true; break; }
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 "";
// Compute lot gain/loss if (!has_drip) {
const use_price = lot.close_price orelse a.current_price; // No DRIP: show all individually
const gl = lot.shares * (use_price - lot.open_price); for (lots_for_sym.items) |lot| {
var lot_gl_buf: [24]u8 = undefined; try printCliLotRow(out, color, lot, a.current_price);
const lot_gl_abs = if (gl >= 0) gl else -gl; }
const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs); } else {
const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; // Show non-DRIP lots individually
for (lots_for_sym.items) |lot| {
if (!lot.drip) {
try printCliLotRow(out, color, lot, a.current_price);
}
}
// 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 setFg(out, color, CLR_MUTED);
try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "", 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); try reset(out, color);
try setGainLoss(out, color, gl); }
try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money }); if (lt_lots > 0) {
try reset(out, color); 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 setFg(out, color, CLR_MUTED);
try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); 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); try reset(out, color);
} }
} }
} }
}
// Totals line // Totals line
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
@ -1370,18 +1420,58 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
try reset(out, color); try reset(out, color);
} }
// Watchlist // 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();
// Mark portfolio position symbols as seen
for (summary.allocations) |a| {
try watch_seen.put(a.symbol, {});
}
// 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| { if (watchlist_path) |wl_path| {
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
if (wl_data) |wd| { if (wl_data) |wd| {
defer allocator.free(wd); defer allocator.free(wd);
var store = zfin.cache.Store.init(allocator, config.cache_dir);
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Watchlist:\n", .{});
try reset(out, color);
var wl_lines = std.mem.splitScalar(u8, wd, '\n'); var wl_lines = std.mem.splitScalar(u8, wd, '\n');
while (wl_lines.next()) |line| { while (wl_lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
@ -1391,20 +1481,10 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
if (sym.len > 0 and sym.len <= 10) { if (sym.len > 0 and sym.len <= 10) {
// Get price from cache if available if (watch_seen.contains(sym)) continue;
var price_str: [16]u8 = undefined; try watch_seen.put(sym, {});
var ps: []const u8 = "--"; try renderWatch(out, color, &store, allocator, sym, &any_watch);
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 |_| {}
}
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(); 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 { fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void {
if (std.mem.eql(u8, subcommand, "stats")) { if (std.mem.eql(u8, subcommand, "stats")) {
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;

View file

@ -7,6 +7,7 @@ pub const LotType = enum {
option, // option contracts option, // option contracts
cd, // certificates of deposit cd, // certificates of deposit
cash, // cash/money market cash, // cash/money market
watch, // watchlist item (no position, just track price)
pub fn label(self: LotType) []const u8 { pub fn label(self: LotType) []const u8 {
return switch (self) { return switch (self) {
@ -14,6 +15,7 @@ pub const LotType = enum {
.option => "Option", .option => "Option",
.cd => "CD", .cd => "CD",
.cash => "Cash", .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, "option")) return .option;
if (std.mem.eql(u8, s, "cd")) return .cd; if (std.mem.eql(u8, s, "cd")) return .cd;
if (std.mem.eql(u8, s, "cash")) return .cash; if (std.mem.eql(u8, s, "cash")) return .cash;
if (std.mem.eql(u8, s, "watch")) return .watch;
return .stock; return .stock;
} }
}; };
@ -45,6 +48,9 @@ pub const Lot = struct {
maturity_date: ?Date = null, maturity_date: ?Date = null,
/// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%) /// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%)
rate: ?f64 = null, 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 { pub fn isOpen(self: Lot) bool {
return self.close_date == null; return self.close_date == null;
@ -275,6 +281,19 @@ pub const Portfolio = struct {
} }
return false; 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" { test "lot basics" {

View file

@ -25,6 +25,7 @@ pub const Action = enum {
symbol_input, symbol_input,
help, help,
edit, edit,
reload_portfolio,
collapse_all_calls, collapse_all_calls,
collapse_all_puts, collapse_all_puts,
options_filter_1, options_filter_1,
@ -102,6 +103,7 @@ const default_bindings = [_]Binding{
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, .{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
.{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .help, .key = .{ .codepoint = '?' } },
.{ .action = .edit, .key = .{ .codepoint = 'e' } }, .{ .action = .edit, .key = .{ .codepoint = 'e' } },
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
.{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },
.{ .action = .options_filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } },

View file

@ -64,8 +64,15 @@ const PortfolioRow = struct {
lot: ?zfin.Lot = null, lot: ?zfin.Lot = null,
/// Number of lots for this symbol (set on position rows) /// Number of lots for this symbol (set on position rows)
lot_count: usize = 0, 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 /// Styled line for rendering
@ -481,6 +488,10 @@ const App = struct {
return ctx.consumeAndRedraw(); return ctx.consumeAndRedraw();
} }
}, },
.reload_portfolio => {
self.reloadPortfolioFile();
return ctx.consumeAndRedraw();
},
.collapse_all_calls => { .collapse_all_calls => {
if (self.active_tab == .options) { if (self.active_tab == .options) {
self.toggleAllCallsPuts(true); self.toggleAllCallsPuts(true);
@ -547,7 +558,7 @@ const App = struct {
self.rebuildPortfolioRows(); self.rebuildPortfolioRows();
} }
}, },
.lot, .option_row, .cd_row, .cash_row, .section_header => {}, .lot, .option_row, .cd_row, .cash_row, .section_header, .drip_summary => {},
.cash_total => { .cash_total => {
self.cash_expanded = !self.cash_expanded; self.cash_expanded = !self.cash_expanded;
self.rebuildPortfolioRows(); self.rebuildPortfolioRows();
@ -807,12 +818,21 @@ const App = struct {
self.freePortfolioSummary(); self.freePortfolioSummary();
// Fetch data for watchlist symbols so they have prices to display // 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| { if (self.watchlist) |wl| {
for (wl) |sym| { for (wl) |sym| {
const result = self.svc.getCandles(sym) catch continue; const result = self.svc.getCandles(sym) catch continue;
self.allocator.free(result.data); 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; const pf = self.portfolio orelse return;
@ -935,6 +955,15 @@ const App = struct {
} }
} }
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| {
if (lot.drip) { has_drip = true; break; }
}
if (!has_drip) {
// No DRIP lots: show all individually
for (matching.items) |lot| { for (matching.items) |lot| {
self.portfolio_rows.append(self.allocator, .{ self.portfolio_rows.append(self.allocator, .{
.kind = .lot, .kind = .lot,
@ -943,24 +972,112 @@ const App = struct {
.lot = lot, .lot = lot,
}) catch continue; }) 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| { if (self.watchlist) |wl| {
for (wl) |sym| { for (wl) |sym| {
if (self.portfolio_summary) |s| { if (watch_seen.contains(sym)) continue;
var found = false; watch_seen.put(sym, {}) catch {};
for (s.allocations) |a| {
if (std.mem.eql(u8, a.symbol, sym)) {
found = true;
break;
}
}
if (found) continue;
}
self.portfolio_rows.append(self.allocator, .{ self.portfolio_rows.append(self.allocator, .{
.kind = .watchlist, .kind = .watchlist,
.symbol = sym, .symbol = sym,
@ -1249,6 +1366,117 @@ const App = struct {
self.portfolio_rows.clearRetainingCapacity(); 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 // Drawing
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface { 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 }); 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 // Map all styled lines produced by this row back to the row index
const lines_after = lines.items.len; const lines_after = lines.items.len;