From ee98a2c4edc2f73bfadee9b0de1344ea3782352a Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 25 Apr 2026 10:22:14 -0700 Subject: [PATCH] add fidelity option matching and a summary including price ratio updates --- src/commands/audit.zig | 189 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 8d526e8..3387cec 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -195,6 +195,71 @@ fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool { return price == 1.0 and cost == 1.0; } +const Date = @import("../models/date.zig").Date; + +/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a +/// portfolio lot by comparing parsed components against the lot's structured +/// fields (underlying, maturity_date, option_type, strike). +/// +/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE} +/// The underlying length is variable, so we scan for the first position +/// where 6 consecutive digits encode a valid date. +fn fidelityOptionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool { + if (lot.security_type != .option) return false; + + // Strip leading dash (short indicator) + const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol; + + // Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9 + if (sym.len < 9) return false; + + // Scan for the date boundary: first position where 6 consecutive digits + // form a valid YYMMDD (and the character before is a letter). + var i: usize = 1; // underlying is at least 1 char + while (i + 7 < sym.len) : (i += 1) { + // All 6 chars must be digits + if (!std.ascii.isDigit(sym[i]) or + !std.ascii.isDigit(sym[i + 1]) or + !std.ascii.isDigit(sym[i + 2]) or + !std.ascii.isDigit(sym[i + 3]) or + !std.ascii.isDigit(sym[i + 4]) or + !std.ascii.isDigit(sym[i + 5])) + continue; + + // Character after the 6 digits must be C or P + const type_char = sym[i + 6]; + if (type_char != 'C' and type_char != 'P') continue; + + // Parse date components + const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue; + const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue; + const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue; + if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue; + const year = 2000 + yy; + + // Parse components + const underlying = sym[0..i]; + const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call; + const strike_str = sym[i + 7 ..]; + const strike = std.fmt.parseFloat(f64, strike_str) catch continue; + const date = Date.fromYmd(year, mm, dd); + + // Match against lot fields + const lot_underlying = lot.underlying orelse return false; + const lot_maturity = lot.maturity_date orelse return false; + + if (!std.mem.eql(u8, underlying, lot_underlying)) return false; + if (!lot_maturity.eql(date)) return false; + if (option_type != lot.option_type) return false; + if (lot.strike) |ls| { + if (@abs(ls - strike) > 0.01) return false; + } else return false; + + return true; + } + return false; +} + // ── Schwab CSV parser ─────────────────────────────────────── // // Parses the per-account positions CSV exported from Schwab's website @@ -836,7 +901,11 @@ pub fn compareAccounts( const lot_acct = lot.account orelse continue; if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue; if (!lot.isOpen()) continue; - if (!std.mem.eql(u8, lot.symbol, bp.symbol)) continue; + // Match by exact symbol, or by parsed option components + // (Fidelity uses compact OCC format like "-AMZN260515C220" + // while portfolio uses "AMZN 05/15/2026 220.00 C") + if (!std.mem.eql(u8, lot.symbol, bp.symbol) and + !fidelityOptionMatchesLot(bp.symbol, lot)) continue; switch (lot.security_type) { .cd => { pf_shares += lot.shares; @@ -851,6 +920,8 @@ pub fn compareAccounts( }, else => {}, } + // Track the lot's own symbol so the portfolio-only pass skips it + try matched_symbols.put(lot.symbol, {}); } if (pf_shares != 0) try matched_symbols.put(bp.symbol, {}); } @@ -1001,6 +1072,90 @@ pub fn compareAccounts( return results.toOwnedSlice(allocator); } +// ── Ratio suggestions ──────────────────────────────────────── + +/// After displaying audit results, check for price_ratio positions where +/// the brokerage NAV implies a different ratio than what's configured. +/// Outputs actionable suggestions for portfolio.srf updates. +fn displayRatioSuggestions( + results: []const AccountComparison, + portfolio: zfin.Portfolio, + prices: std.StringHashMap(f64), + color: bool, + out: *std.Io.Writer, +) !void { + var has_header = false; + + for (results) |acct| { + for (acct.comparisons) |cmp| { + // Skip unmatched, cash, and option rows + if (cmp.only_in_brokerage or cmp.only_in_portfolio) continue; + if (cmp.is_cash or cmp.is_option) continue; + + // Find the portfolio lot(s) for this symbol with price_ratio != 1.0 + for (portfolio.lots) |lot| { + if (lot.price_ratio == 1.0) continue; + if (lot.security_type != .stock) continue; + const lot_acct = lot.account orelse continue; + if (!std.mem.eql(u8, lot_acct, acct.account_name)) continue; + + // Match by lot_symbol (CUSIP) or ticker against brokerage symbol + const lot_sym = lot.symbol; + const price_sym = lot.priceSymbol(); + if (!std.mem.eql(u8, lot_sym, cmp.symbol) and + !std.mem.eql(u8, price_sym, cmp.symbol)) continue; + + // Get the retail price from cache + const retail_price = prices.get(price_sym) orelse continue; + // Brokerage price is the institutional NAV per share + const inst_nav = cmp.brokerage_price orelse continue; + if (retail_price == 0) continue; + + const current_ratio = lot.price_ratio; + const suggested_ratio = inst_nav / retail_price; + const drift_pct = (suggested_ratio - current_ratio) / current_ratio * 100.0; + + // Only suggest if drift is meaningful (> 0.01%) + if (current_ratio == suggested_ratio) break; + + if (!has_header) { + try out.print("\n", .{}); + try cli.setBold(out, color); + try out.print(" Ratio updates", .{}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" (for portfolio.srf)\n", .{}); + try cli.reset(out, color); + has_header = true; + } + + var cur_buf: [24]u8 = undefined; + var sug_buf: [24]u8 = undefined; + var drift_buf: [16]u8 = undefined; + const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{current_ratio}) catch "?"; + const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?"; + const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.2}%", .{drift_pct}) catch "?"; + + try out.print(" {s:<16} ", .{lot_sym}); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print("ticker {s:<6}", .{price_sym}); + try cli.reset(out, color); + try out.print(" ratio {s} -> ", .{cur_str}); + try cli.setBold(out, color); + try out.print("{s}", .{sug_str}); + try cli.reset(out, color); + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" ({s} drift)\n", .{drift_str}); + try cli.reset(out, color); + + break; // One suggestion per symbol + } + } + } + + if (has_header) try out.print("\n", .{}); +} + // ── Display ───────────────────────────────────────────────── fn displayResults(results: []const AccountComparison, color: bool, out: *std.Io.Writer) !void { @@ -1376,6 +1531,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: } try displayResults(results, color, out); + try displayRatioSuggestions(results, portfolio, prices, color, out); } // Schwab per-account CSV @@ -1401,6 +1557,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, portfolio_path: } try displayResults(results, color, out); + try displayRatioSuggestions(results, portfolio, prices, color, out); } } @@ -1745,3 +1902,33 @@ test "resolvePositionValue: ratio-1.0 position unaffected by provenance" { try std.testing.expectApproxEqAbs(@as(f64, 150.0), miss.price, 0.01); try std.testing.expectApproxEqAbs(@as(f64, 1500.0), miss.value, 0.01); } + +test "fidelityOptionMatchesLot basic call" { + const lot = portfolio_mod.Lot{ + .symbol = "AMZN 05/15/2026 220.00 C", + .security_type = .option, + .underlying = "AMZN", + .strike = 220.0, + .option_type = .call, + .maturity_date = Date.fromYmd(2026, 5, 15), + .shares = -3, + .open_date = Date.fromYmd(2025, 1, 1), + .open_price = 8.75, + }; + + // Fidelity format with leading dash (short) + try std.testing.expect(fidelityOptionMatchesLot("-AMZN260515C220", lot)); + // Without dash + try std.testing.expect(fidelityOptionMatchesLot("AMZN260515C220", lot)); + // Wrong underlying + try std.testing.expect(!fidelityOptionMatchesLot("-MSFT260515C220", lot)); + // Wrong date + try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260615C220", lot)); + // Wrong type + try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515P220", lot)); + // Wrong strike + try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C230", lot)); + // Non-option lot + const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 }; + try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C220", stock_lot)); +}