zfin/src/models/portfolio.zig

372 lines
12 KiB
Zig

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
watch, // watchlist item (no position, just track price)
pub fn label(self: LotType) []const u8 {
return switch (self) {
.stock => "Stock",
.option => "Option",
.cd => "CD",
.cash => "Cash",
.watch => "Watch",
};
}
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;
if (std.mem.eql(u8, s, "watch")) return .watch;
return .stock;
}
};
/// A single lot in a portfolio -- one purchase/sale event.
/// Open lots have no close_date/close_price.
/// Closed lots have both.
pub const Lot = struct {
symbol: []const u8,
shares: f64,
open_date: Date,
open_price: f64,
close_date: ?Date = null,
close_price: ?f64 = null,
/// Optional note/tag for the lot
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,
/// Whether this lot is from dividend reinvestment (DRIP).
/// DRIP lots are summarized as ST/LT groups instead of shown individually.
drip: bool = false,
/// Ticker alias for price fetching (e.g. CUSIP symbol with ticker::VTTHX).
/// When set, this ticker is used for API calls instead of the symbol field.
ticker: ?[]const u8 = null,
/// Manual price override (e.g. for mutual funds not covered by data providers).
/// Used as fallback when API price fetch fails.
price: ?f64 = null,
/// Date of the manual price (for display/staleness tracking).
price_date: ?Date = null,
/// The symbol to use for price fetching (ticker if set, else symbol).
pub fn priceSymbol(self: Lot) []const u8 {
return self.ticker orelse self.symbol;
}
pub fn isOpen(self: Lot) bool {
return self.close_date == null;
}
pub fn costBasis(self: Lot) f64 {
return self.shares * self.open_price;
}
pub fn marketValue(self: Lot, current_price: f64) f64 {
return self.shares * current_price;
}
pub fn realizedPnl(self: Lot) ?f64 {
const cp = self.close_price orelse return null;
return self.shares * (cp - self.open_price);
}
pub fn unrealizedPnl(self: Lot, current_price: f64) f64 {
return self.shares * (current_price - self.open_price);
}
pub fn returnPct(self: Lot, current_price: f64) f64 {
if (self.open_price == 0) return 0;
const price = if (self.close_price) |cp| cp else current_price;
return (price / self.open_price) - 1.0;
}
};
/// Aggregated position for a single symbol across multiple lots.
pub const Position = struct {
symbol: []const u8,
/// Total open shares
shares: f64,
/// Weighted average cost basis per share (open lots only)
avg_cost: f64,
/// Total cost basis of open lots
total_cost: f64,
/// Number of open lots
open_lots: u32,
/// Number of closed lots
closed_lots: u32,
/// Total realized P&L from closed lots
realized_pnl: f64,
};
/// A portfolio is a collection of lots.
pub const Portfolio = struct {
lots: []Lot,
allocator: std.mem.Allocator,
pub fn deinit(self: *Portfolio) void {
for (self.lots) |lot| {
self.allocator.free(lot.symbol);
if (lot.note) |n| self.allocator.free(n);
if (lot.account) |a| self.allocator.free(a);
if (lot.ticker) |t| self.allocator.free(t);
}
self.allocator.free(self.lots);
}
/// 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();
for (self.lots) |lot| {
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 unique symbols for stock/ETF lots only (skips options, CDs, cash).
/// Returns the price symbol (ticker alias if set, otherwise raw symbol).
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.priceSymbol(), {});
}
}
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;
errdefer result.deinit(allocator);
for (self.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, symbol)) {
try result.append(allocator, lot);
}
}
return result.toOwnedSlice(allocator);
}
/// 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).
/// Keys by priceSymbol() so CUSIP lots with ticker aliases aggregate under the ticker.
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 sym = lot.priceSymbol();
const entry = try map.getOrPut(sym);
if (!entry.found_existing) {
entry.value_ptr.* = .{
.symbol = sym,
.shares = 0,
.avg_cost = 0,
.total_cost = 0,
.open_lots = 0,
.closed_lots = 0,
.realized_pnl = 0,
};
}
if (lot.isOpen()) {
entry.value_ptr.shares += lot.shares;
entry.value_ptr.total_cost += lot.costBasis();
entry.value_ptr.open_lots += 1;
} else {
entry.value_ptr.closed_lots += 1;
entry.value_ptr.realized_pnl += lot.realizedPnl() orelse 0;
}
}
// Compute avg_cost
var iter = map.valueIterator();
while (iter.next()) |pos| {
if (pos.shares > 0) {
pos.avg_cost = pos.total_cost / pos.shares;
}
}
var result = std.ArrayList(Position).empty;
errdefer result.deinit(allocator);
var viter = map.valueIterator();
while (viter.next()) |pos| {
try result.append(allocator, pos.*);
}
return result.toOwnedSlice(allocator);
}
/// 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() and lot.lot_type == .stock) total += lot.costBasis();
}
return total;
}
/// 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;
}
/// 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" {
const lot = Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 150.0,
};
try std.testing.expect(lot.isOpen());
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0), 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedPnl(200.0), 0.01);
try std.testing.expect(lot.realizedPnl() == null);
}
test "closed lot" {
const lot = Lot{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 15),
.open_price = 150.0,
.close_date = Date.fromYmd(2024, 6, 15),
.close_price = 200.0,
};
try std.testing.expect(!lot.isOpen());
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedPnl().?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
}
test "portfolio positions" {
const allocator = std.testing.allocator;
var lots = [_]Lot{
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 3, 1), .open_price = 160.0 },
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 220.0 },
.{ .symbol = "AAPL", .shares = 3, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 130.0, .close_date = Date.fromYmd(2024, 2, 1), .close_price = 155.0 },
};
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
// Don't call deinit since these are stack-allocated test strings
const pos = try portfolio.positions(allocator);
defer allocator.free(pos);
try std.testing.expectEqual(@as(usize, 2), pos.len);
// Find AAPL position
var aapl: ?Position = null;
for (pos) |p| {
if (std.mem.eql(u8, p.symbol, "AAPL")) aapl = p;
}
try std.testing.expect(aapl != null);
try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01);
try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots);
try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots);
try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_pnl, 0.01); // 3 * (155-130)
}