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",
|
.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 => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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)
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try setFg(out, color, CLR_MUTED);
|
// Summarize DRIP lots as ST/LT
|
||||||
try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
|
var st_lots: usize = 0;
|
||||||
status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "",
|
var st_shares: f64 = 0;
|
||||||
});
|
var st_cost: f64 = 0;
|
||||||
try reset(out, color);
|
var st_first: ?zfin.Date = null;
|
||||||
try setGainLoss(out, color, gl);
|
var st_last: ?zfin.Date = null;
|
||||||
try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money });
|
var lt_lots: usize = 0;
|
||||||
try reset(out, color);
|
var lt_shares: f64 = 0;
|
||||||
try setFg(out, color, CLR_MUTED);
|
var lt_cost: f64 = 0;
|
||||||
try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col });
|
var lt_first: ?zfin.Date = null;
|
||||||
try reset(out, color);
|
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);
|
try reset(out, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watchlist
|
// Watchlist (from watch lots in portfolio + separate watchlist file)
|
||||||
if (watchlist_path) |wl_path| {
|
{
|
||||||
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
|
var store = zfin.cache.Store.init(allocator, config.cache_dir);
|
||||||
if (wl_data) |wd| {
|
var any_watch = false;
|
||||||
defer allocator.free(wd);
|
var watch_seen = std.StringHashMap(void).init(allocator);
|
||||||
var store = zfin.cache.Store.init(allocator, config.cache_dir);
|
defer watch_seen.deinit();
|
||||||
|
|
||||||
try out.print("\n", .{});
|
// Mark portfolio position symbols as seen
|
||||||
try setBold(out, color);
|
for (summary.allocations) |a| {
|
||||||
try out.print(" Watchlist:\n", .{});
|
try watch_seen.put(a.symbol, {});
|
||||||
try reset(out, color);
|
}
|
||||||
|
|
||||||
var wl_lines = std.mem.splitScalar(u8, wd, '\n');
|
// Helper to render a watch symbol
|
||||||
while (wl_lines.next()) |line| {
|
const renderWatch = struct {
|
||||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
fn f(o: anytype, c: bool, s: *zfin.cache.Store, a2: std.mem.Allocator, sym: []const u8, any: *bool) !void {
|
||||||
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
if (!any.*) {
|
||||||
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
|
try o.print("\n", .{});
|
||||||
const rest = trimmed[idx + "symbol::".len ..];
|
try setBold(o, c);
|
||||||
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
|
try o.print(" Watchlist:\n", .{});
|
||||||
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
|
try reset(o, c);
|
||||||
if (sym.len > 0 and sym.len <= 10) {
|
any.* = true;
|
||||||
// Get price from cache if available
|
}
|
||||||
var price_str: [16]u8 = undefined;
|
var price_str2: [16]u8 = undefined;
|
||||||
var ps: []const u8 = "--";
|
var ps2: []const u8 = "--";
|
||||||
const cached = store.readRaw(sym, .candles_daily) catch null;
|
const cached2 = s.readRaw(sym, .candles_daily) catch null;
|
||||||
if (cached) |cdata| {
|
if (cached2) |cdata2| {
|
||||||
defer allocator.free(cdata);
|
defer a2.free(cdata2);
|
||||||
if (zfin.cache.Store.deserializeCandles(allocator, cdata)) |candles| {
|
if (zfin.cache.Store.deserializeCandles(a2, cdata2)) |candles2| {
|
||||||
defer allocator.free(candles);
|
defer a2.free(candles2);
|
||||||
if (candles.len > 0) {
|
if (candles2.len > 0) {
|
||||||
ps = fmt.fmtMoney2(&price_str, candles[candles.len - 1].close);
|
ps2 = fmt.fmtMoney2(&price_str2, candles2[candles2.len - 1].close);
|
||||||
}
|
}
|
||||||
} else |_| {}
|
} 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();
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
||||||
|
|
|
||||||
|
|
@ -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 } } },
|
||||||
|
|
|
||||||
286
src/tui/main.zig
286
src/tui/main.zig
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -934,33 +954,130 @@ const App = struct {
|
||||||
matching.append(self.allocator, lot) catch continue;
|
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| {
|
for (matching.items) |lot| {
|
||||||
self.portfolio_rows.append(self.allocator, .{
|
if (lot.drip) { has_drip = true; break; }
|
||||||
.kind = .lot,
|
}
|
||||||
.symbol = lot.symbol,
|
|
||||||
.pos_idx = i,
|
if (!has_drip) {
|
||||||
.lot = lot,
|
// No DRIP lots: show all individually
|
||||||
}) catch continue;
|
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| {
|
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue