add fidelity option matching and a summary including price ratio updates

This commit is contained in:
Emil Lerch 2026-04-25 10:22:14 -07:00
parent fb1178b5ca
commit ee98a2c4ed
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

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