ai: options, cds, cash and bug fixes
This commit is contained in:
parent
529143cf49
commit
bec1dcff2c
5 changed files with 597 additions and 35 deletions
45
src/cache/store.zig
vendored
45
src/cache/store.zig
vendored
|
|
@ -818,6 +818,16 @@ pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]co
|
|||
try writer.writeAll("#!srfv1\n");
|
||||
for (lots) |lot| {
|
||||
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}", .{
|
||||
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| {
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio {
|
||||
const LotType = @import("../models/portfolio.zig").LotType;
|
||||
var lots: std.ArrayList(Lot) = .empty;
|
||||
errdefer {
|
||||
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 note_raw: ?[]const u8 = null;
|
||||
var account_raw: ?[]const u8 = null;
|
||||
var sec_type_raw: ?[]const u8 = null;
|
||||
|
||||
for (record.fields) |field| {
|
||||
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 };
|
||||
} else if (std.mem.eql(u8, field.key, "account")) {
|
||||
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);
|
||||
} else continue;
|
||||
|
||||
|
|
|
|||
184
src/cli/main.zig
184
src/cli/main.zig
|
|
@ -991,17 +991,19 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
|||
return;
|
||||
}
|
||||
|
||||
// Get positions
|
||||
// Get stock/ETF positions (excludes options, CDs, cash)
|
||||
const positions = try portfolio.positions(allocator);
|
||||
defer allocator.free(positions);
|
||||
|
||||
// Get unique symbols and fetch current prices
|
||||
const syms = try portfolio.symbols(allocator);
|
||||
// Get unique stock/ETF symbols and fetch current prices
|
||||
const syms = try portfolio.stockSymbols(allocator);
|
||||
defer allocator.free(syms);
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
var fail_count: usize = 0;
|
||||
|
||||
if (config.twelvedata_key) |td_key| {
|
||||
var td = zfin.TwelveData.init(allocator, td_key);
|
||||
defer td.deinit();
|
||||
|
|
@ -1018,13 +1020,23 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
|||
defer q.deinit();
|
||||
const price = q.close();
|
||||
if (price > 0) try prices.put(sym, price);
|
||||
} else |_| {}
|
||||
} else |_| {}
|
||||
} else |_| {
|
||||
fail_count += 1;
|
||||
}
|
||||
} else |_| {
|
||||
fail_count += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch {
|
||||
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);
|
||||
|
||||
// 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 writer = std.fs.File.stdout().writer(&buf);
|
||||
const out = &writer.interface;
|
||||
|
|
@ -1059,10 +1088,11 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
|||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
// Lot counts
|
||||
// Lot counts (stocks/ETFs only)
|
||||
var open_lots: u32 = 0;
|
||||
var closed_lots: u32 = 0;
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.lot_type != .stock) continue;
|
||||
if (lot.isOpen()) open_lots += 1 else closed_lots += 1;
|
||||
}
|
||||
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
|
||||
for (summary.allocations) |a| {
|
||||
// Count lots for this symbol
|
||||
// Count stock lots for this symbol
|
||||
var lots_for_sym: std.ArrayList(zfin.Lot) = .empty;
|
||||
defer lots_for_sym.deinit(allocator);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1202,6 +1232,144 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []
|
|||
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
|
||||
if (watchlist_path) |wl_path| {
|
||||
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,30 @@
|
|||
const std = @import("std");
|
||||
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.
|
||||
/// Open lots have no close_date/close_price.
|
||||
/// Closed lots have both.
|
||||
|
|
@ -15,6 +39,12 @@ pub const Lot = struct {
|
|||
note: ?[]const u8 = null,
|
||||
/// Optional account identifier (e.g. "Roth IRA", "Brokerage")
|
||||
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 {
|
||||
return self.close_date == null;
|
||||
|
|
@ -75,7 +105,7 @@ pub const Portfolio = struct {
|
|||
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 {
|
||||
var seen = std.StringHashMap(void).init(allocator);
|
||||
defer seen.deinit();
|
||||
|
|
@ -94,6 +124,27 @@ pub const Portfolio = struct {
|
|||
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.
|
||||
pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot {
|
||||
var result = std.ArrayList(Lot).empty;
|
||||
|
|
@ -107,12 +158,26 @@ pub const Portfolio = struct {
|
|||
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 {
|
||||
var map = std.StringHashMap(Position).init(allocator);
|
||||
defer map.deinit();
|
||||
|
||||
for (self.lots) |lot| {
|
||||
if (lot.lot_type != .stock) continue;
|
||||
const entry = try map.getOrPut(lot.symbol);
|
||||
if (!entry.found_existing) {
|
||||
entry.value_ptr.* = .{
|
||||
|
|
@ -153,23 +218,63 @@ pub const Portfolio = struct {
|
|||
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 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.isOpen()) total += lot.costBasis();
|
||||
if (lot.isOpen() and lot.lot_type == .stock) total += lot.costBasis();
|
||||
}
|
||||
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 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.lot_type == .stock) {
|
||||
if (lot.realizedPnl()) |pnl| total += pnl;
|
||||
}
|
||||
}
|
||||
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" {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub const SectorWeight = @import("models/etf_profile.zig").SectorWeight;
|
|||
pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo;
|
||||
pub const SecurityType = @import("models/ticker_info.zig").SecurityType;
|
||||
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 Portfolio = @import("models/portfolio.zig").Portfolio;
|
||||
pub const Quote = @import("models/quote.zig").Quote;
|
||||
|
|
|
|||
275
src/tui/main.zig
275
src/tui/main.zig
|
|
@ -55,7 +55,7 @@ const InputMode = enum {
|
|||
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 {
|
||||
kind: Kind,
|
||||
symbol: []const u8,
|
||||
|
|
@ -65,7 +65,7 @@ const PortfolioRow = struct {
|
|||
/// Number of lots for this symbol (set on position rows)
|
||||
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
|
||||
|
|
@ -123,8 +123,11 @@ const App = struct {
|
|||
// Portfolio navigation
|
||||
cursor: usize = 0, // selected row in portfolio view
|
||||
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_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_cursor: usize = 0, // selected row in flattened options view
|
||||
|
|
@ -262,7 +265,9 @@ const App = struct {
|
|||
if (self.active_tab == .portfolio and mouse.row > 0) {
|
||||
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) {
|
||||
const row_idx = content_row - self.portfolio_header_lines;
|
||||
const line_idx = content_row - self.portfolio_header_lines;
|
||||
if (line_idx < self.portfolio_line_count and line_idx < self.portfolio_line_to_row.len) {
|
||||
const row_idx = self.portfolio_line_to_row[line_idx];
|
||||
if (row_idx < self.portfolio_rows.items.len) {
|
||||
self.cursor = row_idx;
|
||||
self.toggleExpand();
|
||||
|
|
@ -270,6 +275,7 @@ const App = struct {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Options tab: single-click to select and expand/collapse
|
||||
if (self.active_tab == .options and mouse.row > 0) {
|
||||
const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset;
|
||||
|
|
@ -541,7 +547,11 @@ const App = struct {
|
|||
self.rebuildPortfolioRows();
|
||||
}
|
||||
},
|
||||
.lot => {},
|
||||
.lot, .option_row, .cd_row, .cash_row, .section_header => {},
|
||||
.cash_total => {
|
||||
self.cash_expanded = !self.cash_expanded;
|
||||
self.rebuildPortfolioRows();
|
||||
},
|
||||
.watchlist => {
|
||||
self.setActiveSymbol(row.symbol);
|
||||
self.active_tab = .quote;
|
||||
|
|
@ -815,17 +825,22 @@ const App = struct {
|
|||
var prices = std.StringHashMap(f64).init(self.allocator);
|
||||
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");
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(syms);
|
||||
|
||||
var latest_date: ?zfin.Date = null;
|
||||
var fail_count: usize = 0;
|
||||
for (syms) |sym| {
|
||||
// Try cache first; if miss, fetch (handles new securities / stale cache)
|
||||
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;
|
||||
};
|
||||
if (candles_slice) |cs| {
|
||||
|
|
@ -850,6 +865,27 @@ const App = struct {
|
|||
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.rebuildPortfolioRows();
|
||||
|
||||
|
|
@ -857,8 +893,15 @@ const App = struct {
|
|||
self.setActiveSymbol(summary.allocations[0].symbol);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
self.portfolio_rows.clearRetainingCapacity();
|
||||
|
|
@ -869,7 +912,7 @@ const App = struct {
|
|||
var lcount: usize = 0;
|
||||
if (self.portfolio) |pf| {
|
||||
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;
|
||||
defer matching.deinit(self.allocator);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -924,6 +967,80 @@ const App = struct {
|
|||
}) 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 {
|
||||
|
|
@ -1349,9 +1466,11 @@ const App = struct {
|
|||
|
||||
// Track header line count for mouse click mapping (after all header lines)
|
||||
self.portfolio_header_lines = lines.items.len;
|
||||
self.portfolio_line_count = 0;
|
||||
|
||||
// Data rows
|
||||
for (self.portfolio_rows.items, 0..) |row, ri| {
|
||||
const lines_before = lines.items.len;
|
||||
const is_cursor = ri == self.cursor;
|
||||
const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol);
|
||||
switch (row.kind) {
|
||||
|
|
@ -1381,21 +1500,48 @@ const App = struct {
|
|||
|
||||
// Date + ST/LT: show for single-lot, blank for multi-lot
|
||||
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| {
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
break :blk "";
|
||||
} else "";
|
||||
|
||||
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}", .{
|
||||
arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col,
|
||||
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, acct_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();
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue