diff --git a/src/commands/audit.zig b/src/commands/audit.zig index d3d88a6..7ecbcf8 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -3,6 +3,7 @@ const zfin = @import("../root.zig"); const cli = @import("common.zig"); const fmt = cli.fmt; const analysis = @import("../analytics/analysis.zig"); +const portfolio_mod = @import("../models/portfolio.zig"); // ── Brokerage position (normalized from any source) ───────── @@ -128,20 +129,22 @@ pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]Broke const symbol_raw = std.mem.trim(u8, cols[FidelityCol.symbol], &.{ ' ', '"' }); if (symbol_raw.len == 0) continue; - // Money market funds (symbol ending in **) are cash positions. - // Note: Fidelity's Type column says "Cash" vs "Margin" to indicate - // the account settlement type, NOT that the security is cash. - // Only the ** suffix reliably identifies money market / cash holdings. - // Also treat positions as cash when both price and cost basis are $1.00 - // (e.g. FDRXX "FID GOV CASH RESERVE" — no ** suffix). - const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]); - // Strip ** suffix from money market symbols for display const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**")) symbol_raw[0 .. symbol_raw.len - 2] else symbol_raw; + // Classify as cash if any of: + // - Fidelity's ** suffix marks a money-market position + // - The symbol appears in zfin's canonical money-market list + // (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these) + // - price and cost both equal exactly $1.00, the catch-all for + // fixed-NAV instruments that we don't have in the list yet. + const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or + portfolio_mod.isMoneyMarketSymbol(symbol_clean) or + isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]); + try positions.append(allocator, .{ .account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }), .account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }), @@ -307,7 +310,13 @@ pub fn parseSchwabCsv(allocator: std.mem.Allocator, data: []const u8) !struct { if (symbol.len == 0) continue; if (std.mem.eql(u8, symbol, "Positions Total")) continue; - const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments"); + // "Cash & Cash Investments" is Schwab's aggregate cash line. + // Actual money-market holdings (SWVXX, etc.) appear as normal rows + // with their real ticker and price — treat those as cash too so + // the reconciliation matches what brokerage users think of as + // "cash" in the account. + const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or + portfolio_mod.isMoneyMarketSymbol(symbol); try positions.append(allocator, .{ .account_number = title.number, @@ -1218,17 +1227,31 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: }; defer account_map.deinit(); - // Build cached prices (shared by all audit modes) + // Build prices map, shared by all audit modes. + // + // Route through `cli.loadPortfolioPrices` so the audit gets the same + // TTL-based cache refresh behavior `zfin portfolio` uses. Previously + // this read cached last-closes directly, which silently used stale + // data after long weekends / when the cache hadn't been refreshed. + // TTL-driven refetch keeps numbers current without forcing a full + // provider hit every run. var prices = std.StringHashMap(f64).init(allocator); defer prices.deinit(); { - const positions = try portfolio.positions(allocator); - defer allocator.free(positions); - for (positions) |pos| { - if (svc.getCachedLastClose(pos.symbol)) |close| { - try prices.put(pos.symbol, close); + const pos_syms = try portfolio.stockSymbols(allocator); + defer allocator.free(pos_syms); + + if (pos_syms.len > 0) { + var load_result = cli.loadPortfolioPrices(svc, pos_syms, &.{}, false, color); + defer load_result.deinit(); + var it = load_result.prices.iterator(); + while (it.next()) |entry| { + try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } + + // Manual `price::` overrides from portfolio.srf still win for lots + // that carry them (e.g. 401k CIT shares with no API coverage). for (portfolio.lots) |lot| { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig index 18be790..ee22773 100644 --- a/src/models/portfolio.zig +++ b/src/models/portfolio.zig @@ -2,6 +2,53 @@ const std = @import("std"); const Date = @import("date.zig").Date; const Candle = @import("candle.zig").Candle; +// ── Money-market / stable-NAV classification ──────────────── +// +// Centralized so that audit.zig, the Fidelity/Schwab parsers, and the +// planned snapshot writer all agree on which symbols are fixed-$1-NAV +// instruments. Prior to this the classification lived in three places +// with three different heuristics that disagreed on edge cases. + +/// Well-known US money-market fund tickers. Schwab (SWVXX/SWTXX), +/// Vanguard (VMFXX/VMRXX/VUSXX/VMSXX/VYFXX), Fidelity +/// (SPAXX/SPRXX/FDRXX/FDLXX/FZFXX/FZDXX/FTEXX), and a handful of +/// BlackRock / Federated / JPM common tickers. Extend as new funds +/// appear. +pub const money_market_symbols = [_][]const u8{ + // Schwab + "SWVXX", "SWTXX", "SNAXX", "SNVXX", "SNOXX", "SNSXX", + // Vanguard + "VMFXX", "VMRXX", "VUSXX", "VMSXX", "VYFXX", + // Fidelity prime/gov/treasury + "SPAXX", + "SPRXX", "FDRXX", "FDLXX", "FZFXX", "FZDXX", "FTEXX", + "FDIXX", + // Federated, BlackRock, JPM common tickers + "GOFXX", "TSCXX", "MJLXX", +}; + +/// Returns true when `symbol` appears in the well-known money-market +/// ticker list (case-insensitive). Use this anywhere you need to know +/// "is this a fixed-$1-NAV cash equivalent?" based on the ticker alone. +/// +/// Symbols not in the list (including unknown MM funds) return false. +/// Callers that have candle data on hand can supplement this with a +/// trailing-$1-close check of their own if they need to catch funds +/// missing from the whitelist. +pub fn isMoneyMarketSymbol(symbol: []const u8) bool { + if (symbol.len == 0) return false; + // All tickers in `money_market_symbols` are uppercase; upper-case the + // input once into a fixed-size buffer for the comparison. + var buf: [16]u8 = undefined; + if (symbol.len > buf.len) return false; + for (symbol, 0..) |c, i| buf[i] = std.ascii.toUpper(c); + const up = buf[0..symbol.len]; + for (money_market_symbols) |mm| { + if (std.mem.eql(u8, up, mm)) return true; + } + return false; +} + /// 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 @@ -816,6 +863,27 @@ test "totalForAccount" { try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01); } +// ── Money-market predicate tests ───────────────────────────── + +test "isMoneyMarketSymbol: known Schwab and Fidelity tickers" { + try std.testing.expect(isMoneyMarketSymbol("SWVXX")); + try std.testing.expect(isMoneyMarketSymbol("VMFXX")); + try std.testing.expect(isMoneyMarketSymbol("SPAXX")); + try std.testing.expect(isMoneyMarketSymbol("FDRXX")); + // Case-insensitive + try std.testing.expect(isMoneyMarketSymbol("swvxx")); + try std.testing.expect(isMoneyMarketSymbol("Swvxx")); +} + +test "isMoneyMarketSymbol: non-MM tickers reject" { + try std.testing.expect(!isMoneyMarketSymbol("AAPL")); + try std.testing.expect(!isMoneyMarketSymbol("VTI")); + try std.testing.expect(!isMoneyMarketSymbol("VSTCX")); // mutual fund, not MM + try std.testing.expect(!isMoneyMarketSymbol("")); + // Very long strings don't fit the buffer — safely rejected. + try std.testing.expect(!isMoneyMarketSymbol("THIS_IS_NOT_A_TICKER_AT_ALL")); +} + test "stableNavCandle: fills all fields at $1" { const c = stableNavCandle(Date.fromYmd(2026, 4, 1)); try std.testing.expectEqual(@as(f64, 1), c.close);