ai: options, cds, cash and bug fixes

This commit is contained in:
Emil Lerch 2026-02-26 08:52:35 -08:00
parent 529143cf49
commit bec1dcff2c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 597 additions and 35 deletions

45
src/cache/store.zig vendored
View file

@ -818,6 +818,16 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
try writer.writeAll("#!srfv1\n"); try writer.writeAll("#!srfv1\n");
for (lots) |lot| { for (lots) |lot| {
var od_buf: [10]u8 = undefined; var od_buf: [10]u8 = undefined;
// Write security_type if not stock (stock is the default)
if (lot.lot_type != .stock) {
const type_str: []const u8 = switch (lot.lot_type) {
.option => "option",
.cd => "cd",
.cash => "cash",
.stock => unreachable,
};
try writer.print("security_type::{s},", .{type_str});
}
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,
}); });
@ -834,6 +844,13 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
if (lot.account) |a| { if (lot.account) |a| {
try writer.print(",account::{s}", .{a}); try writer.print(",account::{s}", .{a});
} }
if (lot.maturity_date) |md| {
var md_buf: [10]u8 = undefined;
try writer.print(",maturity_date::{s}", .{md.format(&md_buf)});
}
if (lot.rate) |r| {
try writer.print(",rate:num:{d}", .{r});
}
try writer.writeAll("\n"); try writer.writeAll("\n");
} }
@ -842,6 +859,7 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
/// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio. /// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio.
pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio { pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio {
const LotType = @import("../models/portfolio.zig").LotType;
var lots: std.ArrayList(Lot) = .empty; var lots: std.ArrayList(Lot) = .empty;
errdefer { errdefer {
for (lots.items) |lot| { for (lots.items) |lot| {
@ -866,6 +884,7 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
var sym_raw: ?[]const u8 = null; var sym_raw: ?[]const u8 = null;
var note_raw: ?[]const u8 = null; var note_raw: ?[]const u8 = null;
var account_raw: ?[]const u8 = null; var account_raw: ?[]const u8 = null;
var sec_type_raw: ?[]const u8 = null;
for (record.fields) |field| { for (record.fields) |field| {
if (std.mem.eql(u8, field.key, "symbol")) { if (std.mem.eql(u8, field.key, "symbol")) {
@ -890,10 +909,34 @@ pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Por
if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null }; if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null };
} else if (std.mem.eql(u8, field.key, "account")) { } else if (std.mem.eql(u8, field.key, "account")) {
if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null }; if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null };
} else if (std.mem.eql(u8, field.key, "security_type")) {
if (field.value) |v| sec_type_raw = switch (v) { .string => |s| s, else => null };
} else if (std.mem.eql(u8, field.key, "maturity_date")) {
if (field.value) |v| {
const str = switch (v) { .string => |s| s, else => continue };
lot.maturity_date = Date.parse(str) catch null;
}
} else if (std.mem.eql(u8, field.key, "rate")) {
if (field.value) |v| {
const r = Store.numVal(v);
if (r > 0) lot.rate = r;
}
} }
} }
if (sym_raw) |s| { // Determine lot type
if (sec_type_raw) |st| {
lot.lot_type = LotType.fromString(st);
}
// Cash lots don't require a symbol -- generate a placeholder
if (lot.lot_type == .cash) {
if (sym_raw == null) {
lot.symbol = try allocator.dupe(u8, "CASH");
} else {
lot.symbol = try allocator.dupe(u8, sym_raw.?);
}
} else if (sym_raw) |s| {
lot.symbol = try allocator.dupe(u8, s); lot.symbol = try allocator.dupe(u8, s);
} else continue; } else continue;

View file

@ -991,17 +991,19 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
return; return;
} }
// Get positions // Get stock/ETF positions (excludes options, CDs, cash)
const positions = try portfolio.positions(allocator); const positions = try portfolio.positions(allocator);
defer allocator.free(positions); defer allocator.free(positions);
// Get unique symbols and fetch current prices // Get unique stock/ETF symbols and fetch current prices
const syms = try portfolio.symbols(allocator); const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms); defer allocator.free(syms);
var prices = std.StringHashMap(f64).init(allocator); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
var fail_count: usize = 0;
if (config.twelvedata_key) |td_key| { if (config.twelvedata_key) |td_key| {
var td = zfin.TwelveData.init(allocator, td_key); var td = zfin.TwelveData.init(allocator, td_key);
defer td.deinit(); defer td.deinit();
@ -1018,13 +1020,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
defer q.deinit(); defer q.deinit();
const price = q.close(); const price = q.close();
if (price > 0) try prices.put(sym, price); if (price > 0) try prices.put(sym, price);
} else |_| {} } else |_| {
} else |_| {} fail_count += 1;
}
} else |_| {
fail_count += 1;
}
} }
} else { } else {
try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
} }
if (fail_count > 0) {
var warn_msg_buf: [128]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_msg_buf, "Warning: {d} securities failed to load prices\n", .{fail_count}) catch "Warning: some securities failed\n";
try stderr_print(warn_msg);
}
// Compute summary // Compute summary
var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch { var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch {
try stderr_print("Error computing portfolio summary.\n"); try stderr_print("Error computing portfolio summary.\n");
@ -1032,6 +1044,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
}; };
defer summary.deinit(allocator); defer summary.deinit(allocator);
// Include non-stock assets in the grand total
const cash_total = portfolio.totalCash();
const cd_total = portfolio.totalCdFaceValue();
const opt_total = portfolio.totalOptionCost();
const non_stock = cash_total + cd_total + 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;
}
// Reweight allocations against grand total
if (summary.total_value > 0) {
for (summary.allocations) |*a| {
a.weight = a.market_value / summary.total_value;
}
}
var buf: [32768]u8 = undefined; var buf: [32768]u8 = undefined;
var writer = std.fs.File.stdout().writer(&buf); var writer = std.fs.File.stdout().writer(&buf);
const out = &writer.interface; const out = &writer.interface;
@ -1059,10 +1088,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
try out.print("\n", .{}); try out.print("\n", .{});
} }
// Lot counts // Lot counts (stocks/ETFs only)
var open_lots: u32 = 0; var open_lots: u32 = 0;
var closed_lots: u32 = 0; var closed_lots: u32 = 0;
for (portfolio.lots) |lot| { for (portfolio.lots) |lot| {
if (lot.lot_type != .stock) continue;
if (lot.isOpen()) open_lots += 1 else closed_lots += 1; if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
} }
try setFg(out, color, CLR_MUTED); try setFg(out, color, CLR_MUTED);
@ -1082,11 +1112,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
// Position rows with lot detail // Position rows with lot detail
for (summary.allocations) |a| { for (summary.allocations) |a| {
// Count lots for this symbol // Count stock lots for this symbol
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty; var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
defer lots_for_sym.deinit(allocator); defer lots_for_sym.deinit(allocator);
for (portfolio.lots) |lot| { for (portfolio.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, a.symbol)) { if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
try lots_for_sym.append(allocator, lot); try lots_for_sym.append(allocator, lot);
} }
} }
@ -1202,6 +1232,144 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
try reset(out, color); try reset(out, color);
} }
// Options section
if (portfolio.hasType(.option)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Options\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:<30} {s:>6} {s:>12} {s:>14} {s}\n", .{
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
});
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14} {s:->10}\n", .{
"", "", "", "", "",
});
try reset(out, color);
var opt_total_cost: f64 = 0;
for (portfolio.lots) |lot| {
if (lot.lot_type != .option) continue;
const qty = lot.shares;
const cost_per = lot.open_price;
const total_cost_opt = @abs(qty) * cost_per;
opt_total_cost += total_cost_opt;
var cost_per_buf: [24]u8 = undefined;
var total_cost_buf: [24]u8 = undefined;
const acct: []const u8 = lot.account orelse "";
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
lot.symbol,
qty,
fmt.fmtMoney2(&cost_per_buf, cost_per),
fmt.fmtMoney(&total_cost_buf, total_cost_opt),
acct,
});
}
// Options total
try setFg(out, color, CLR_MUTED);
try out.print(" {s:->30} {s:->6} {s:->12} {s:->14}\n", .{ "", "", "", "" });
try reset(out, color);
var opt_total_buf: [24]u8 = undefined;
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
"", "", "TOTAL", fmt.fmtMoney(&opt_total_buf, opt_total_cost),
});
}
// CDs section
if (portfolio.hasType(.cd)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Certificates of Deposit\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
"CUSIP", "Face Value", "Rate", "Maturity", "Description",
});
try out.print(" {s:->12} {s:->14} {s:->7} {s:->10} {s:->30}\n", .{
"", "", "", "", "",
});
try reset(out, color);
// Collect and sort CDs by maturity date (earliest first)
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(allocator);
for (portfolio.lots) |lot| {
if (lot.lot_type == .cd) {
try cd_lots.append(allocator, lot);
}
}
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
_ = ctx;
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
return ad < bd;
}
}.f);
var cd_section_total: f64 = 0;
for (cd_lots.items) |lot| {
cd_section_total += lot.shares;
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
lot.symbol,
fmt.fmtMoney(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
});
}
// CD total
try setFg(out, color, CLR_MUTED);
try out.print(" {s:->12} {s:->14}\n", .{ "", "" });
try reset(out, color);
var cd_total_buf: [24]u8 = undefined;
try out.print(" {s:>12} {s:>14}\n", .{
"TOTAL", fmt.fmtMoney(&cd_total_buf, cd_section_total),
});
}
// Cash section
if (portfolio.hasType(.cash)) {
try out.print("\n", .{});
try setBold(out, color);
try out.print(" Cash\n", .{});
try reset(out, color);
try setFg(out, color, CLR_MUTED);
try out.print(" {s:<20} {s:>14}\n", .{ "Account", "Balance" });
try out.print(" {s:->20} {s:->14}\n", .{ "", "" });
try reset(out, color);
for (portfolio.lots) |lot| {
if (lot.lot_type != .cash) continue;
var cash_buf: [24]u8 = undefined;
const acct2: []const u8 = lot.account orelse "Unknown";
try out.print(" {s:<20} {s:>14}\n", .{
acct2,
fmt.fmtMoney(&cash_buf, lot.shares),
});
}
// Cash total
try setFg(out, color, CLR_MUTED);
try out.print(" {s:->20} {s:->14}\n", .{ "", "" });
try reset(out, color);
var cash_total_buf: [24]u8 = undefined;
try setBold(out, color);
try out.print(" {s:>20} {s:>14}\n", .{
"TOTAL", fmt.fmtMoney(&cash_total_buf, portfolio.totalCash()),
});
try reset(out, color);
}
// Watchlist // Watchlist
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;

View file

@ -1,6 +1,30 @@
const std = @import("std"); const std = @import("std");
const Date = @import("date.zig").Date; const Date = @import("date.zig").Date;
/// Type of holding in a portfolio lot.
pub const LotType = enum {
stock, // stocks and ETFs (default)
option, // option contracts
cd, // certificates of deposit
cash, // cash/money market
pub fn label(self: LotType) []const u8 {
return switch (self) {
.stock => "Stock",
.option => "Option",
.cd => "CD",
.cash => "Cash",
};
}
pub fn fromString(s: []const u8) LotType {
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;
return .stock;
}
};
/// A single lot in a portfolio -- one purchase/sale event. /// A single lot in a portfolio -- one purchase/sale event.
/// Open lots have no close_date/close_price. /// Open lots have no close_date/close_price.
/// Closed lots have both. /// Closed lots have both.
@ -15,6 +39,12 @@ pub const Lot = struct {
note: ?[]const u8 = null, note: ?[]const u8 = null,
/// Optional account identifier (e.g. "Roth IRA", "Brokerage") /// Optional account identifier (e.g. "Roth IRA", "Brokerage")
account: ?[]const u8 = null, account: ?[]const u8 = null,
/// Type of holding (stock, option, cd, cash)
lot_type: LotType = .stock,
/// Maturity date (for CDs)
maturity_date: ?Date = null,
/// Interest rate (for CDs, as percentage e.g. 3.8 = 3.8%)
rate: ?f64 = null,
pub fn isOpen(self: Lot) bool { pub fn isOpen(self: Lot) bool {
return self.close_date == null; return self.close_date == null;
@ -75,7 +105,7 @@ pub const Portfolio = struct {
self.allocator.free(self.lots); self.allocator.free(self.lots);
} }
/// Get all unique symbols in the portfolio. /// Get all unique symbols in the portfolio (all types).
pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator); var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit(); defer seen.deinit();
@ -94,6 +124,27 @@ pub const Portfolio = struct {
return result.toOwnedSlice(allocator); return result.toOwnedSlice(allocator);
} }
/// Get unique symbols for stock/ETF lots only (skips options, CDs, cash).
pub fn stockSymbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 {
var seen = std.StringHashMap(void).init(allocator);
defer seen.deinit();
for (self.lots) |lot| {
if (lot.lot_type == .stock) {
try seen.put(lot.symbol, {});
}
}
var result = std.ArrayList([]const u8).empty;
errdefer result.deinit(allocator);
var iter = seen.keyIterator();
while (iter.next()) |key| {
try result.append(allocator, key.*);
}
return result.toOwnedSlice(allocator);
}
/// Get all lots for a given symbol. /// Get all lots for a given symbol.
pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot { pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot {
var result = std.ArrayList(Lot).empty; var result = std.ArrayList(Lot).empty;
@ -107,12 +158,26 @@ pub const Portfolio = struct {
return result.toOwnedSlice(allocator); return result.toOwnedSlice(allocator);
} }
/// Aggregate lots into positions by symbol. /// Get all lots of a given security type (allocated copy).
pub fn lotsOfTypeAlloc(self: Portfolio, allocator: std.mem.Allocator, sec_type: LotType) ![]Lot {
var result = std.ArrayList(Lot).empty;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (lot.lot_type == sec_type) {
try result.append(allocator, lot);
}
}
return result.toOwnedSlice(allocator);
}
/// Aggregate stock/ETF lots into positions by symbol (skips options, CDs, cash).
pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position { pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position {
var map = std.StringHashMap(Position).init(allocator); var map = std.StringHashMap(Position).init(allocator);
defer map.deinit(); defer map.deinit();
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.lot_type != .stock) continue;
const entry = try map.getOrPut(lot.symbol); const entry = try map.getOrPut(lot.symbol);
if (!entry.found_existing) { if (!entry.found_existing) {
entry.value_ptr.* = .{ entry.value_ptr.* = .{
@ -153,23 +218,63 @@ pub const Portfolio = struct {
return result.toOwnedSlice(allocator); return result.toOwnedSlice(allocator);
} }
/// Total cost basis of all open lots. /// Total cost basis of all open stock lots.
pub fn totalCostBasis(self: Portfolio) f64 { pub fn totalCostBasis(self: Portfolio) f64 {
var total: f64 = 0; var total: f64 = 0;
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.isOpen()) total += lot.costBasis(); if (lot.isOpen() and lot.lot_type == .stock) total += lot.costBasis();
} }
return total; return total;
} }
/// Total realized P&L from all closed lots. /// Total realized P&L from all closed stock lots.
pub fn totalRealizedPnl(self: Portfolio) f64 { pub fn totalRealizedPnl(self: Portfolio) f64 {
var total: f64 = 0; var total: f64 = 0;
for (self.lots) |lot| { for (self.lots) |lot| {
if (lot.realizedPnl()) |pnl| total += pnl; if (lot.lot_type == .stock) {
if (lot.realizedPnl()) |pnl| total += pnl;
}
} }
return total; return total;
} }
/// Total cash across all accounts.
pub fn totalCash(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .cash) total += lot.shares;
}
return total;
}
/// Total CD face value across all accounts.
pub fn totalCdFaceValue(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .cd) total += lot.shares;
}
return total;
}
/// Total option cost basis (absolute value of shares * open_price).
pub fn totalOptionCost(self: Portfolio) f64 {
var total: f64 = 0;
for (self.lots) |lot| {
if (lot.lot_type == .option) {
// shares can be negative (short), open_price is per-contract cost
total += @abs(lot.shares) * lot.open_price;
}
}
return total;
}
/// Check if portfolio has any lots of a given type.
pub fn hasType(self: Portfolio, sec_type: LotType) bool {
for (self.lots) |lot| {
if (lot.lot_type == sec_type) return true;
}
return false;
}
}; };
test "lot basics" { test "lot basics" {

View file

@ -21,6 +21,7 @@ pub const SectorWeight = @import("models/etf_profile.zig").SectorWeight;
pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo; pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo;
pub const SecurityType = @import("models/ticker_info.zig").SecurityType; pub const SecurityType = @import("models/ticker_info.zig").SecurityType;
pub const Lot = @import("models/portfolio.zig").Lot; pub const Lot = @import("models/portfolio.zig").Lot;
pub const LotType = @import("models/portfolio.zig").LotType;
pub const Position = @import("models/portfolio.zig").Position; pub const Position = @import("models/portfolio.zig").Position;
pub const Portfolio = @import("models/portfolio.zig").Portfolio; pub const Portfolio = @import("models/portfolio.zig").Portfolio;
pub const Quote = @import("models/quote.zig").Quote; pub const Quote = @import("models/quote.zig").Quote;

View file

@ -55,7 +55,7 @@ const InputMode = enum {
help, help,
}; };
/// A row in the portfolio view -- either a position header or an individual lot. /// A row in the portfolio view -- position header, lot detail, or special sections.
const PortfolioRow = struct { const PortfolioRow = struct {
kind: Kind, kind: Kind,
symbol: []const u8, symbol: []const u8,
@ -65,7 +65,7 @@ const PortfolioRow = struct {
/// 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,
const Kind = enum { position, lot, watchlist }; const Kind = enum { position, lot, watchlist, section_header, option_row, cd_row, cash_row, cash_total };
}; };
/// Styled line for rendering /// Styled line for rendering
@ -123,8 +123,11 @@ const App = struct {
// Portfolio navigation // Portfolio navigation
cursor: usize = 0, // selected row in portfolio view cursor: usize = 0, // selected row in portfolio view
expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded
cash_expanded: bool = false, // whether cash section is expanded to show per-account
portfolio_rows: std.ArrayList(PortfolioRow) = .empty, portfolio_rows: std.ArrayList(PortfolioRow) = .empty,
portfolio_header_lines: usize = 0, // number of styled lines before data rows portfolio_header_lines: usize = 0, // number of styled lines before data rows
portfolio_line_to_row: [256]usize = [_]usize{0} ** 256, // maps styled line index -> portfolio_rows index
portfolio_line_count: usize = 0, // total styled lines in portfolio view
// Options navigation (inline expand/collapse like portfolio) // Options navigation (inline expand/collapse like portfolio)
options_cursor: usize = 0, // selected row in flattened options view options_cursor: usize = 0, // selected row in flattened options view
@ -262,11 +265,14 @@ const App = struct {
if (self.active_tab == .portfolio and mouse.row > 0) { if (self.active_tab == .portfolio and mouse.row > 0) {
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) { if (content_row >= self.portfolio_header_lines and self.portfolio_rows.items.len > 0) {
const row_idx = content_row - self.portfolio_header_lines; const line_idx = content_row - self.portfolio_header_lines;
if (row_idx < self.portfolio_rows.items.len) { if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
self.cursor = row_idx; const row_idx = self.portfolio_line_to_row[line_idx];
self.toggleExpand(); if (row_idx < self.portfolio_rows.items.len) {
return ctx.consumeAndRedraw(); self.cursor = row_idx;
self.toggleExpand();
return ctx.consumeAndRedraw();
}
} }
} }
} }
@ -541,7 +547,11 @@ const App = struct {
self.rebuildPortfolioRows(); self.rebuildPortfolioRows();
} }
}, },
.lot => {}, .lot, .option_row, .cd_row, .cash_row, .section_header => {},
.cash_total => {
self.cash_expanded = !self.cash_expanded;
self.rebuildPortfolioRows();
},
.watchlist => { .watchlist => {
self.setActiveSymbol(row.symbol); self.setActiveSymbol(row.symbol);
self.active_tab = .quote; self.active_tab = .quote;
@ -815,17 +825,22 @@ const App = struct {
var prices = std.StringHashMap(f64).init(self.allocator); var prices = std.StringHashMap(f64).init(self.allocator);
defer prices.deinit(); defer prices.deinit();
const syms = pf.symbols(self.allocator) catch { // Only fetch prices for stock/ETF symbols (skip options, CDs, cash)
const syms = pf.stockSymbols(self.allocator) catch {
self.setStatus("Error getting symbols"); self.setStatus("Error getting symbols");
return; return;
}; };
defer self.allocator.free(syms); defer self.allocator.free(syms);
var latest_date: ?zfin.Date = null; var latest_date: ?zfin.Date = null;
var fail_count: usize = 0;
for (syms) |sym| { for (syms) |sym| {
// Try cache first; if miss, fetch (handles new securities / stale cache) // Try cache first; if miss, fetch (handles new securities / stale cache)
const candles_slice = self.svc.getCachedCandles(sym) orelse blk: { const candles_slice = self.svc.getCachedCandles(sym) orelse blk: {
const result = self.svc.getCandles(sym) catch break :blk null; const result = self.svc.getCandles(sym) catch {
fail_count += 1;
break :blk null;
};
break :blk result.data; break :blk result.data;
}; };
if (candles_slice) |cs| { if (candles_slice) |cs| {
@ -850,6 +865,27 @@ const App = struct {
return; return;
} }
// Include non-stock assets in the grand total
// Cash and CDs add equally to value and cost (no gain/loss),
// options add at cost basis (no live pricing).
// This keeps unrealized_pnl correct (only stocks contribute market gains)
// but dilutes the return% against the full portfolio cost base.
const cash_total = pf.totalCash();
const cd_total = pf.totalCdFaceValue();
const opt_total = pf.totalOptionCost();
const non_stock = cash_total + cd_total + 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;
}
// Reweight allocations against grand total
if (summary.total_value > 0) {
for (summary.allocations) |*a| {
a.weight = a.market_value / summary.total_value;
}
}
self.portfolio_summary = summary; self.portfolio_summary = summary;
self.rebuildPortfolioRows(); self.rebuildPortfolioRows();
@ -857,7 +893,14 @@ const App = struct {
self.setActiveSymbol(summary.allocations[0].symbol); self.setActiveSymbol(summary.allocations[0].symbol);
} }
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); // Show warning if any securities failed to load
if (fail_count > 0) {
var warn_buf: [128]u8 = undefined;
const warn_msg = std.fmt.bufPrint(&warn_buf, "Warning: {d} securities failed to load prices", .{fail_count}) catch "Warning: some securities failed";
self.setStatus(warn_msg);
} else {
self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help");
}
} }
fn rebuildPortfolioRows(self: *App) void { fn rebuildPortfolioRows(self: *App) void {
@ -869,7 +912,7 @@ const App = struct {
var lcount: usize = 0; var lcount: usize = 0;
if (self.portfolio) |pf| { if (self.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1; if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1;
} }
} }
@ -887,7 +930,7 @@ const App = struct {
var matching: std.ArrayList(zfin.Lot) = .empty; var matching: std.ArrayList(zfin.Lot) = .empty;
defer matching.deinit(self.allocator); defer matching.deinit(self.allocator);
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, a.symbol)) { if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
matching.append(self.allocator, lot) catch continue; matching.append(self.allocator, lot) catch continue;
} }
} }
@ -924,6 +967,80 @@ const App = struct {
}) catch continue; }) catch continue;
} }
} }
// Options section
if (self.portfolio) |pf| {
if (pf.hasType(.option)) {
self.portfolio_rows.append(self.allocator, .{
.kind = .section_header,
.symbol = "Options",
}) catch {};
for (pf.lots) |lot| {
if (lot.lot_type == .option) {
self.portfolio_rows.append(self.allocator, .{
.kind = .option_row,
.symbol = lot.symbol,
.lot = lot,
}) catch continue;
}
}
}
// CDs section (sorted by maturity date, earliest first)
if (pf.hasType(.cd)) {
self.portfolio_rows.append(self.allocator, .{
.kind = .section_header,
.symbol = "Certificates of Deposit",
}) catch {};
var cd_lots: std.ArrayList(zfin.Lot) = .empty;
defer cd_lots.deinit(self.allocator);
for (pf.lots) |lot| {
if (lot.lot_type == .cd) {
cd_lots.append(self.allocator, lot) catch continue;
}
}
std.mem.sort(zfin.Lot, cd_lots.items, {}, struct {
fn f(ctx: void, a: zfin.Lot, b: zfin.Lot) bool {
_ = ctx;
const ad = if (a.maturity_date) |d| d.days else std.math.maxInt(i32);
const bd = if (b.maturity_date) |d| d.days else std.math.maxInt(i32);
return ad < bd;
}
}.f);
for (cd_lots.items) |lot| {
self.portfolio_rows.append(self.allocator, .{
.kind = .cd_row,
.symbol = lot.symbol,
.lot = lot,
}) catch continue;
}
}
// Cash section (single total row, expandable to show per-account)
if (pf.hasType(.cash)) {
self.portfolio_rows.append(self.allocator, .{
.kind = .section_header,
.symbol = "Cash",
}) catch {};
// Total cash row
self.portfolio_rows.append(self.allocator, .{
.kind = .cash_total,
.symbol = "CASH",
}) catch {};
// Per-account cash rows (expanded when cash_total is toggled)
if (self.cash_expanded) {
for (pf.lots) |lot| {
if (lot.lot_type == .cash) {
self.portfolio_rows.append(self.allocator, .{
.kind = .cash_row,
.symbol = lot.account orelse "Unknown",
.lot = lot,
}) catch continue;
}
}
}
}
}
} }
fn loadPerfData(self: *App) void { fn loadPerfData(self: *App) void {
@ -1349,9 +1466,11 @@ const App = struct {
// Track header line count for mouse click mapping (after all header lines) // Track header line count for mouse click mapping (after all header lines)
self.portfolio_header_lines = lines.items.len; self.portfolio_header_lines = lines.items.len;
self.portfolio_line_count = 0;
// Data rows // Data rows
for (self.portfolio_rows.items, 0..) |row, ri| { for (self.portfolio_rows.items, 0..) |row, ri| {
const lines_before = lines.items.len;
const is_cursor = ri == self.cursor; const is_cursor = ri == self.cursor;
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol); const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol);
switch (row.kind) { switch (row.kind) {
@ -1381,21 +1500,48 @@ const App = struct {
// Date + ST/LT: show for single-lot, blank for multi-lot // Date + ST/LT: show for single-lot, blank for multi-lot
var pos_date_buf: [10]u8 = undefined; var pos_date_buf: [10]u8 = undefined;
const date_col: []const u8 = if (!is_multi) blk: { var date_col: []const u8 = "";
var acct_col: []const u8 = "";
if (!is_multi) {
if (self.portfolio) |pf| { if (self.portfolio) |pf| {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, a.symbol)) { if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
const ds = lot.open_date.format(&pos_date_buf); const ds = lot.open_date.format(&pos_date_buf);
const indicator = fmt.capitalGainsIndicator(lot.open_date); const indicator = fmt.capitalGainsIndicator(lot.open_date);
break :blk std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; date_col = std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds;
acct_col = lot.account orelse "";
break;
} }
} }
} }
break :blk ""; } else {
} else ""; // Multi-lot: show account if all lots share the same one
if (self.portfolio) |pf| {
var common_acct: ?[]const u8 = null;
var mixed = false;
for (pf.lots) |lot| {
if (lot.lot_type == .stock and std.mem.eql(u8, lot.symbol, a.symbol)) {
if (common_acct) |ca| {
const la = lot.account orelse "";
if (!std.mem.eql(u8, ca, la)) {
mixed = true;
break;
}
} else {
common_acct = lot.account orelse "";
}
}
}
if (!mixed) {
acct_col = common_acct orelse "";
} else {
acct_col = "Multiple";
}
}
}
const text = try std.fmt.allocPrint(arena, "{s}{s}{s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13}", .{ const text = try std.fmt.allocPrint(arena, "{s}{s}{s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13} {s}", .{
arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, acct_col,
}); });
// base: neutral text for main cols, green/red only for gain/loss col // base: neutral text for main cols, green/red only for gain/loss col
@ -1472,7 +1618,106 @@ const App = struct {
const row_style = if (is_cursor) th.selectStyle() else th.contentStyle(); const row_style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style }); try lines.append(arena, .{ .text = text, .style = row_style });
}, },
.section_header => {
// Blank line before section header
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const hdr_text = try std.fmt.allocPrint(arena, " {s}", .{row.symbol});
const hdr_style = if (is_cursor) th.selectStyle() else th.headerStyle();
try lines.append(arena, .{ .text = hdr_text, .style = hdr_style });
// Add column headers for each section type
if (std.mem.eql(u8, row.symbol, "Options")) {
const col_hdr = try std.fmt.allocPrint(arena, " {s:<30} {s:>6} {s:>12} {s:>14} {s}", .{
"Contract", "Qty", "Cost/Ctrct", "Total Cost", "Account",
});
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
} else if (std.mem.eql(u8, row.symbol, "Certificates of Deposit")) {
const col_hdr = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
"CUSIP", "Face Value", "Rate", "Maturity", "Description", "Account",
});
try lines.append(arena, .{ .text = col_hdr, .style = th.mutedStyle() });
}
},
.option_row => {
if (row.lot) |lot| {
// Options: symbol (description), qty (contracts), cost/contract, cost basis, account
const qty = lot.shares; // negative = short
const cost_per = lot.open_price; // per-contract cost
const total_cost = @abs(qty) * cost_per;
var cost_buf3: [24]u8 = undefined;
var total_buf: [24]u8 = undefined;
const acct_col2: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
lot.symbol,
qty,
fmt.fmtMoney2(&cost_buf3, cost_per),
fmt.fmtMoney(&total_buf, total_cost),
acct_col2,
});
const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style2 });
}
},
.cd_row => {
if (row.lot) |lot| {
// CDs: symbol (CUSIP), face value, rate%, maturity date, note, account
var face_buf: [24]u8 = undefined;
var mat_buf: [10]u8 = undefined;
const mat_str: []const u8 = if (lot.maturity_date) |md| md.format(&mat_buf) else "--";
var rate_str_buf: [10]u8 = undefined;
const rate_str: []const u8 = if (lot.rate) |r|
std.fmt.bufPrint(&rate_str_buf, "{d:.2}%", .{r}) catch "--"
else
"--";
const note_str: []const u8 = lot.note orelse "";
// Truncate note to 40 chars for display
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
const acct_col3: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
lot.symbol,
fmt.fmtMoney(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
acct_col3,
});
const row_style3 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style3 });
}
},
.cash_total => {
if (self.portfolio) |pf| {
const total_cash = pf.totalCash();
var cash_buf: [24]u8 = undefined;
const arrow3: []const u8 = if (self.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
arrow3,
fmt.fmtMoney(&cash_buf, total_cash),
});
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style4 });
}
},
.cash_row => {
if (row.lot) |lot| {
var cash_amt_buf: [24]u8 = undefined;
const text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{
row.symbol, // account name
fmt.fmtMoney(&cash_amt_buf, lot.shares),
});
const row_style5 = if (is_cursor) th.selectStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = row_style5 });
}
},
} }
// Map all styled lines produced by this row back to the row index
const lines_after = lines.items.len;
for (lines_before..lines_after) |li| {
const map_idx = li - self.portfolio_header_lines;
if (map_idx < self.portfolio_line_to_row.len) {
self.portfolio_line_to_row[map_idx] = ri;
}
}
self.portfolio_line_count = lines_after - self.portfolio_header_lines;
} }
// Render // Render