ai: handle drip investments
This commit is contained in:
parent
bec1dcff2c
commit
7d72fe734c
5 changed files with 468 additions and 76 deletions
17
src/cache/store.zig
vendored
17
src/cache/store.zig
vendored
|
|
@ -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 => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
220
src/cli/main.zig
220
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;
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
|
|
|
|||
286
src/tui/main.zig
286
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue