clean up audit: centralize money market detection and load portfolio prices
This commit is contained in:
parent
fbe8320399
commit
7f8e430b59
2 changed files with 106 additions and 15 deletions
|
|
@ -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())) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue