827 lines
34 KiB
Zig
827 lines
34 KiB
Zig
const std = @import("std");
|
|
const Date = @import("date.zig").Date;
|
|
const Candle = @import("candle.zig").Candle;
|
|
|
|
/// Synthesize a stable-NAV (= $1) candle for a given date. Used when
|
|
/// historical price data for a money-market fund doesn't reach back as
|
|
/// far as the period under analysis — the close is known to be $1 by
|
|
/// construction, so we can extrapolate backward without inventing data.
|
|
pub fn stableNavCandle(date: Date) Candle {
|
|
return .{ .date = date, .open = 1, .high = 1, .low = 1, .close = 1, .adj_close = 1, .volume = 0 };
|
|
}
|
|
|
|
/// 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
|
|
illiquid, // illiquid assets (real estate, vehicles, etc.)
|
|
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",
|
|
.illiquid => "Illiquid",
|
|
.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, "illiquid")) return .illiquid;
|
|
if (std.mem.eql(u8, s, "watch")) return .watch;
|
|
return .stock;
|
|
}
|
|
};
|
|
|
|
/// Call or put option type.
|
|
pub const OptionType = enum {
|
|
call,
|
|
put,
|
|
|
|
pub fn fromString(s: []const u8) OptionType {
|
|
if (std.mem.eql(u8, s, "put")) return .put;
|
|
return .call;
|
|
}
|
|
};
|
|
|
|
/// 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)
|
|
security_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,
|
|
/// Price ratio for institutional share classes. When set, the fetched price
|
|
/// (from the `ticker` symbol) is multiplied by this ratio to get the actual
|
|
/// institutional NAV. E.g. if VTTHX (investor) is $27.78 and the institutional
|
|
/// class trades at $144.04, price_ratio = 144.04 / 27.78 ≈ 5.185.
|
|
price_ratio: f64 = 1.0,
|
|
/// Underlying stock symbol for option lots (e.g. "AMZN").
|
|
underlying: ?[]const u8 = null,
|
|
/// Strike price for option lots.
|
|
strike: ?f64 = null,
|
|
/// Contract multiplier (shares per contract). Default 100 for standard US equity options.
|
|
multiplier: f64 = 100.0,
|
|
/// Call or put (for option lots).
|
|
option_type: OptionType = .call,
|
|
|
|
/// 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 {
|
|
if (self.close_date != null) return false;
|
|
if (self.maturity_date) |mat| {
|
|
const today = Date.fromEpoch(std.time.timestamp());
|
|
if (!today.lessThan(mat)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// Realized gain/loss for a closed lot: shares * (close_price - open_price).
|
|
/// Returns null if the lot is still open.
|
|
pub fn realizedGainLoss(self: Lot) ?f64 {
|
|
const cp = self.close_price orelse return null;
|
|
return self.shares * (cp - self.open_price);
|
|
}
|
|
|
|
/// Unrealized gain/loss for an open lot at the given market price.
|
|
pub fn unrealizedGainLoss(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,
|
|
/// Original lot symbol before ticker aliasing (e.g. CUSIP "02315N600").
|
|
/// Same as `symbol` when no ticker alias is set.
|
|
lot_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_gain_loss: f64,
|
|
/// Account name (shared across lots, or "Multiple" if mixed).
|
|
account: []const u8 = "",
|
|
/// Note from the first lot (e.g. "VANGUARD TARGET 2035").
|
|
note: ?[]const u8 = null,
|
|
/// Price ratio for institutional share classes (from lot).
|
|
/// NOTE: If lots with different price_ratios (or a mix of ratio/no-ratio)
|
|
/// share the same priceSymbol(), the position grouping would be incorrect.
|
|
/// Currently positions() takes the ratio from the first lot that has one.
|
|
/// Supporting dual-holding of investor + institutional shares of the same
|
|
/// ticker would require a different grouping key in positions().
|
|
price_ratio: f64 = 1.0,
|
|
};
|
|
|
|
/// 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);
|
|
if (lot.underlying) |u| self.allocator.free(u);
|
|
}
|
|
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).
|
|
/// Excludes manual-price-only lots (price:: set, no ticker::) since those
|
|
/// have no API coverage and should never be fetched.
|
|
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.security_type == .stock) {
|
|
// Skip lots that have a manual price but no ticker alias —
|
|
// these are securities without API coverage (e.g. 401k CIT shares).
|
|
if (lot.price != null and lot.ticker == null) continue;
|
|
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.security_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.security_type != .stock) continue;
|
|
const sym = lot.priceSymbol();
|
|
const entry = try map.getOrPut(sym);
|
|
if (!entry.found_existing) {
|
|
entry.value_ptr.* = .{
|
|
.symbol = sym,
|
|
.lot_symbol = lot.symbol,
|
|
.shares = 0,
|
|
.avg_cost = 0,
|
|
.total_cost = 0,
|
|
.open_lots = 0,
|
|
.closed_lots = 0,
|
|
.realized_gain_loss = 0,
|
|
.account = lot.account orelse "",
|
|
.note = lot.note,
|
|
.price_ratio = lot.price_ratio,
|
|
};
|
|
} else {
|
|
// Track account: if lots have different accounts, mark as "Multiple"
|
|
const existing = entry.value_ptr.account;
|
|
const new_acct = lot.account orelse "";
|
|
if (existing.len > 0 and !std.mem.eql(u8, existing, "Multiple") and !std.mem.eql(u8, existing, new_acct)) {
|
|
entry.value_ptr.account = "Multiple";
|
|
}
|
|
// Propagate price_ratio from the first lot that has one
|
|
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
|
|
entry.value_ptr.price_ratio = lot.price_ratio;
|
|
}
|
|
}
|
|
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_gain_loss += lot.realizedGainLoss() 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);
|
|
}
|
|
|
|
/// Aggregate stock/ETF lots into positions for a single account.
|
|
/// Same logic as positions() but filtered to lots matching `account_name`.
|
|
/// Only includes positions with at least one open lot (closed-only symbols are excluded).
|
|
pub fn positionsForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8) ![]Position {
|
|
var map = std.StringHashMap(Position).init(allocator);
|
|
defer map.deinit();
|
|
|
|
for (self.lots) |lot| {
|
|
if (lot.security_type != .stock) continue;
|
|
const lot_acct = lot.account orelse continue;
|
|
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
|
|
|
|
const sym = lot.priceSymbol();
|
|
const entry = try map.getOrPut(sym);
|
|
if (!entry.found_existing) {
|
|
entry.value_ptr.* = .{
|
|
.symbol = sym,
|
|
.lot_symbol = lot.symbol,
|
|
.shares = 0,
|
|
.avg_cost = 0,
|
|
.total_cost = 0,
|
|
.open_lots = 0,
|
|
.closed_lots = 0,
|
|
.realized_gain_loss = 0,
|
|
.account = lot_acct,
|
|
.note = lot.note,
|
|
.price_ratio = lot.price_ratio,
|
|
};
|
|
} else {
|
|
if (entry.value_ptr.price_ratio == 1.0 and lot.price_ratio != 1.0) {
|
|
entry.value_ptr.price_ratio = lot.price_ratio;
|
|
}
|
|
}
|
|
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_gain_loss += lot.realizedGainLoss() orelse 0;
|
|
}
|
|
}
|
|
|
|
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| {
|
|
if (pos.open_lots == 0) continue;
|
|
try result.append(allocator, pos.*);
|
|
}
|
|
return result.toOwnedSlice(allocator);
|
|
}
|
|
|
|
/// Total cash for a single account.
|
|
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
|
|
var total: f64 = 0;
|
|
for (self.lots) |lot| {
|
|
if (lot.security_type != .cash) continue;
|
|
const lot_acct = lot.account orelse continue;
|
|
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// Total value of non-stock holdings (cash, CDs, options) for a single account.
|
|
/// Only includes open lots (respects close_date and maturity_date).
|
|
pub fn nonStockValueForAccount(self: Portfolio, account_name: []const u8) f64 {
|
|
var total: f64 = 0;
|
|
for (self.lots) |lot| {
|
|
if (!lot.isOpen()) continue;
|
|
const lot_acct = lot.account orelse continue;
|
|
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
|
|
switch (lot.security_type) {
|
|
.cash => total += lot.shares,
|
|
.cd => total += lot.shares,
|
|
.option => total += @abs(lot.shares) * lot.open_price * lot.multiplier,
|
|
else => {},
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// Total value of an account: stocks (priced from the given map, falling back to avg_cost)
|
|
/// plus cash, CDs, and options. Only includes open lots.
|
|
pub fn totalForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 {
|
|
var total: f64 = 0;
|
|
|
|
const acct_positions = self.positionsForAccount(allocator, account_name) catch return self.nonStockValueForAccount(account_name);
|
|
defer allocator.free(acct_positions);
|
|
|
|
for (acct_positions) |pos| {
|
|
const price = prices.get(pos.symbol) orelse pos.avg_cost;
|
|
total += pos.shares * price * pos.price_ratio;
|
|
}
|
|
|
|
total += self.nonStockValueForAccount(account_name);
|
|
return total;
|
|
}
|
|
|
|
/// 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.security_type == .stock) total += lot.costBasis();
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// Total realized P&L from all closed stock lots.
|
|
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
|
|
var total: f64 = 0;
|
|
for (self.lots) |lot| {
|
|
if (lot.security_type == .stock) {
|
|
if (lot.realizedGainLoss()) |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.security_type == .cash) total += lot.shares;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// Total illiquid asset value across all accounts.
|
|
pub fn totalIlliquid(self: Portfolio) f64 {
|
|
var total: f64 = 0;
|
|
for (self.lots) |lot| {
|
|
if (lot.security_type == .illiquid) 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.security_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.security_type == .option) {
|
|
// open_price is per-share option price; multiply by contract size
|
|
total += @abs(lot.shares) * lot.open_price * lot.multiplier;
|
|
}
|
|
}
|
|
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.security_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.security_type == .watch) {
|
|
try result.append(allocator, lot.symbol);
|
|
}
|
|
}
|
|
return result.toOwnedSlice(allocator);
|
|
}
|
|
};
|
|
|
|
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
|
|
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
|
|
/// This is a heuristic -- it won't catch all CUSIPs and may have false positives.
|
|
pub fn isCusipLike(s: []const u8) bool {
|
|
if (s.len != 9) return false;
|
|
// Must contain at least one digit (all-alpha would be a ticker)
|
|
var has_digit = false;
|
|
for (s) |c| {
|
|
if (!std.ascii.isAlphanumeric(c)) return false;
|
|
if (std.ascii.isDigit(c)) has_digit = true;
|
|
}
|
|
return has_digit;
|
|
}
|
|
|
|
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.unrealizedGainLoss(200.0), 0.01);
|
|
try std.testing.expect(lot.realizedGainLoss() == 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.realizedGainLoss().?, 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_gain_loss, 0.01); // 3 * (155-130)
|
|
}
|
|
|
|
test "LotType label and fromString" {
|
|
try std.testing.expectEqualStrings("Stock", LotType.stock.label());
|
|
try std.testing.expectEqualStrings("Option", LotType.option.label());
|
|
try std.testing.expectEqualStrings("CD", LotType.cd.label());
|
|
try std.testing.expectEqualStrings("Cash", LotType.cash.label());
|
|
try std.testing.expectEqualStrings("Illiquid", LotType.illiquid.label());
|
|
try std.testing.expectEqualStrings("Watch", LotType.watch.label());
|
|
|
|
try std.testing.expectEqual(LotType.option, LotType.fromString("option"));
|
|
try std.testing.expectEqual(LotType.cd, LotType.fromString("cd"));
|
|
try std.testing.expectEqual(LotType.cash, LotType.fromString("cash"));
|
|
try std.testing.expectEqual(LotType.illiquid, LotType.fromString("illiquid"));
|
|
try std.testing.expectEqual(LotType.watch, LotType.fromString("watch"));
|
|
try std.testing.expectEqual(LotType.stock, LotType.fromString("unknown"));
|
|
try std.testing.expectEqual(LotType.stock, LotType.fromString(""));
|
|
}
|
|
|
|
test "Lot.priceSymbol" {
|
|
const with_ticker = Lot{ .symbol = "9128283H2", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .ticker = "VTTHX" };
|
|
try std.testing.expectEqualStrings("VTTHX", with_ticker.priceSymbol());
|
|
const without_ticker = Lot{ .symbol = "AAPL", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 };
|
|
try std.testing.expectEqualStrings("AAPL", without_ticker.priceSymbol());
|
|
}
|
|
|
|
test "Lot.returnPct" {
|
|
// Open lot: uses current_price param
|
|
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
|
|
// Closed lot: uses close_price, ignores current_price
|
|
const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
|
|
// Zero open_price: returns 0
|
|
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
|
|
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
|
|
}
|
|
|
|
test "Portfolio totals" {
|
|
var lots = [_]Lot{
|
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150, .security_type = .stock },
|
|
.{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140, .security_type = .stock, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 160 },
|
|
.{ .symbol = "Savings", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cash },
|
|
.{ .symbol = "CD-1Y", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .cd },
|
|
.{ .symbol = "House", .shares = 500000, .open_date = Date.fromYmd(2020, 1, 1), .open_price = 0, .security_type = .illiquid },
|
|
.{ .symbol = "SPY_CALL", .shares = 2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.50, .security_type = .option },
|
|
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
|
|
};
|
|
const portfolio = Portfolio{ .lots = &lots, .allocator = std.testing.allocator };
|
|
|
|
// totalCostBasis: only open stock lots -> 10 * 150 = 1500
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(), 0.01);
|
|
// totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
|
|
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
|
|
// totalCash
|
|
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(), 0.01);
|
|
// totalIlliquid
|
|
try std.testing.expectApproxEqAbs(@as(f64, 500000.0), portfolio.totalIlliquid(), 0.01);
|
|
// totalCdFaceValue
|
|
try std.testing.expectApproxEqAbs(@as(f64, 10000.0), portfolio.totalCdFaceValue(), 0.01);
|
|
// totalOptionCost: |2| * 5.50 * 100 = 1100
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1100.0), portfolio.totalOptionCost(), 0.01);
|
|
// hasType
|
|
try std.testing.expect(portfolio.hasType(.stock));
|
|
try std.testing.expect(portfolio.hasType(.cash));
|
|
try std.testing.expect(portfolio.hasType(.cd));
|
|
try std.testing.expect(portfolio.hasType(.illiquid));
|
|
try std.testing.expect(portfolio.hasType(.option));
|
|
try std.testing.expect(portfolio.hasType(.watch));
|
|
}
|
|
|
|
test "Portfolio watchSymbols" {
|
|
const allocator = std.testing.allocator;
|
|
var lots = [_]Lot{
|
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150 },
|
|
.{ .symbol = "TSLA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
|
|
.{ .symbol = "NVDA", .shares = 0, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0, .security_type = .watch },
|
|
};
|
|
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
|
const watch = try portfolio.watchSymbols(allocator);
|
|
defer allocator.free(watch);
|
|
try std.testing.expectEqual(@as(usize, 2), watch.len);
|
|
}
|
|
|
|
test "positions propagates price_ratio from lot" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var lots = [_]Lot{
|
|
// Two institutional lots for the same CUSIP, both with ticker alias and price_ratio
|
|
.{ .symbol = "02315N600", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .ticker = "VTTHX", .price_ratio = 5.185 },
|
|
.{ .symbol = "02315N600", .shares = 50, .open_date = Date.fromYmd(2024, 6, 1), .open_price = 142.0, .ticker = "VTTHX", .price_ratio = 5.185 },
|
|
// Regular stock lot — no price_ratio
|
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 },
|
|
};
|
|
|
|
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
|
const pos = try portfolio.positions(allocator);
|
|
defer allocator.free(pos);
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), pos.len);
|
|
|
|
for (pos) |p| {
|
|
if (std.mem.eql(u8, p.symbol, "VTTHX")) {
|
|
try std.testing.expectApproxEqAbs(@as(f64, 150.0), p.shares, 0.01);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 5.185), p.price_ratio, 0.001);
|
|
} else {
|
|
try std.testing.expectEqualStrings("AAPL", p.symbol);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1.0), p.price_ratio, 0.001);
|
|
}
|
|
}
|
|
}
|
|
|
|
test "positionsForAccount excludes closed-only symbols" {
|
|
const allocator = std.testing.allocator;
|
|
|
|
var lots = [_]Lot{
|
|
// Open lot in account A
|
|
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "Acct A" },
|
|
// Closed lot in account A (was sold)
|
|
.{ .symbol = "XLV", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 150.0, .account = "Acct A" },
|
|
// Open lot for same symbol in a different account
|
|
.{ .symbol = "XLV", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 140.0, .account = "Acct B" },
|
|
};
|
|
|
|
var portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
|
|
|
// Account A: should only see AAPL (XLV is fully closed there)
|
|
const pos_a = try portfolio.positionsForAccount(allocator, "Acct A");
|
|
defer allocator.free(pos_a);
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), pos_a.len);
|
|
try std.testing.expectEqualStrings("AAPL", pos_a[0].symbol);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 10.0), pos_a[0].shares, 0.01);
|
|
|
|
// Account B: should see XLV with 50 shares
|
|
const pos_b = try portfolio.positionsForAccount(allocator, "Acct B");
|
|
defer allocator.free(pos_b);
|
|
|
|
try std.testing.expectEqual(@as(usize, 1), pos_b.len);
|
|
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
|
|
}
|
|
|
|
test "isOpen respects maturity_date" {
|
|
const past = Date.fromYmd(2024, 1, 1);
|
|
const future = Date.fromYmd(2099, 12, 31);
|
|
|
|
const expired_option = Lot{
|
|
.symbol = "AAPL 01/01/2024 150 C",
|
|
.shares = -1,
|
|
.open_date = Date.fromYmd(2023, 6, 1),
|
|
.open_price = 5.0,
|
|
.security_type = .option,
|
|
.maturity_date = past,
|
|
};
|
|
try std.testing.expect(!expired_option.isOpen());
|
|
|
|
const active_option = Lot{
|
|
.symbol = "AAPL 12/31/2099 150 C",
|
|
.shares = -1,
|
|
.open_date = Date.fromYmd(2023, 6, 1),
|
|
.open_price = 5.0,
|
|
.security_type = .option,
|
|
.maturity_date = future,
|
|
};
|
|
try std.testing.expect(active_option.isOpen());
|
|
|
|
const closed_option = Lot{
|
|
.symbol = "AAPL 12/31/2099 150 C",
|
|
.shares = -1,
|
|
.open_date = Date.fromYmd(2023, 6, 1),
|
|
.open_price = 5.0,
|
|
.security_type = .option,
|
|
.maturity_date = future,
|
|
.close_date = Date.fromYmd(2024, 6, 1),
|
|
};
|
|
try std.testing.expect(!closed_option.isOpen());
|
|
|
|
const stock = Lot{
|
|
.symbol = "AAPL",
|
|
.shares = 100,
|
|
.open_date = Date.fromYmd(2023, 1, 1),
|
|
.open_price = 150.0,
|
|
};
|
|
try std.testing.expect(stock.isOpen());
|
|
}
|
|
|
|
test "nonStockValueForAccount" {
|
|
const allocator = std.testing.allocator;
|
|
const future = Date.fromYmd(2099, 12, 31);
|
|
const past = Date.fromYmd(2024, 1, 1);
|
|
|
|
var lots = [_]Lot{
|
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
|
|
.{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
|
|
.{ .symbol = "CD123", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
|
|
.{ .symbol = "AAPL 12/31/2099 200 C", .shares = -2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.50, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
|
|
.{ .symbol = "AAPL 01/01/2024 180 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 4.0, .security_type = .option, .account = "IRA", .maturity_date = past, .multiplier = 100 },
|
|
.{ .symbol = "", .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Other" },
|
|
};
|
|
|
|
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
|
|
|
// cash(5000) + cd(50000) + open option(2*3.50*100=700) = 55700
|
|
// expired option excluded
|
|
const ns = portfolio.nonStockValueForAccount("IRA");
|
|
try std.testing.expectApproxEqAbs(@as(f64, 55700.0), ns, 0.01);
|
|
|
|
const ns_other = portfolio.nonStockValueForAccount("Other");
|
|
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), ns_other, 0.01);
|
|
}
|
|
|
|
test "totalForAccount" {
|
|
const allocator = std.testing.allocator;
|
|
const future = Date.fromYmd(2099, 12, 31);
|
|
|
|
var lots = [_]Lot{
|
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
|
|
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "IRA" },
|
|
.{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
|
|
.{ .symbol = "CD456", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
|
|
.{ .symbol = "AAPL C", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
|
|
};
|
|
|
|
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
|
|
|
var prices = std.StringHashMap(f64).init(allocator);
|
|
defer prices.deinit();
|
|
try prices.put("AAPL", 170.0);
|
|
// MSFT not in prices — should fall back to avg_cost (300.0)
|
|
|
|
// stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
|
|
// non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
|
|
// total = 44500
|
|
const total = portfolio.totalForAccount(allocator, "IRA", prices);
|
|
try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01);
|
|
}
|
|
|
|
test "stableNavCandle: fills all fields at $1" {
|
|
const c = stableNavCandle(Date.fromYmd(2026, 4, 1));
|
|
try std.testing.expectEqual(@as(f64, 1), c.close);
|
|
try std.testing.expectEqual(@as(f64, 1), c.open);
|
|
try std.testing.expectEqual(@as(f64, 1), c.high);
|
|
try std.testing.expectEqual(@as(f64, 1), c.low);
|
|
try std.testing.expectEqual(@as(f64, 1), c.adj_close);
|
|
try std.testing.expectEqual(@as(u64, 0), c.volume);
|
|
}
|