clean up audit: centralize money market detection and load portfolio prices

This commit is contained in:
Emil Lerch 2026-04-21 12:43:07 -07:00
parent fbe8320399
commit 7f8e430b59
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 106 additions and 15 deletions

View file

@ -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())) {

View file

@ -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);