add fidelity option matching and a summary including price ratio updates
This commit is contained in:
parent
fb1178b5ca
commit
ee98a2c4ed
1 changed files with 188 additions and 1 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue