split audit by broker

This commit is contained in:
Emil Lerch 2026-06-24 15:50:27 -07:00
parent 1fa9649bd6
commit be888069c0
Signed by: lobo
GPG key ID: A7B62D657EF764F8
9 changed files with 4397 additions and 3568 deletions

43
TODO.md
View file

@ -160,49 +160,6 @@ settles on. Starting points (grep `\.note` and `note::`):
- `transaction_log` transfer `note` (annotation).
- audit / contributions matchers (do any key off notes?).
## Split `audit.zig` into per-broker reconcilers — priority LOW
`src/commands/audit.zig` is now 2856 lines (was 3438) after the
brokerage parsers moved to per-broker files under `src/brokerage/`.
It still bundles three logically distinct responsibilities:
- Portfolio hygiene check (no-flag mode)
- Fidelity positions CSV reconciler (`--fidelity`)
- Schwab per-account positions CSV reconciler (`--schwab`) and
Schwab account-summary stdin reconciler (`--schwab-summary`)
The brokerage parsers themselves are split per broker:
`src/brokerage/types.zig` (shared `BrokeragePosition` +
`parseDollarAmount`), `src/brokerage/fidelity.zig` (Fidelity CSV +
option-symbol matcher), `src/brokerage/schwab.zig` (per-account
CSV + summary paste). Adding a new broker is a one-file add next
to those. What's left is splitting the *reconciler*
(compare-portfolio-vs-brokerage) and *display* code in audit.zig
into per-broker files that consume those parsers.
### Sketch
```
src/commands/audit/
mod.zig ← thin dispatcher; current public `run()` lives here
hygiene.zig ← portfolio hygiene check (no-flag mode)
fidelity.zig ← --fidelity reconciler (uses brokerage/fidelity.zig)
schwab.zig ← --schwab + --schwab-summary reconcilers
common.zig ← shared types (Discrepancy, ReconcileResult), formatters
```
The hygiene check can be referenced from `zfin doctor` (above)
without pulling in reconciler baggage.
### Driver
Maintenance friction. The split makes the audit-bug investigations
already in this TODO file (phantom discrepancy on freshly-added
lots) easier to localize, and lets a `zfin doctor` command reuse
hygiene without inheriting the reconciliation surface.
Pure internal refactor; no user-visible change.
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
`src/format.zig` is still a ~1700-line grab-bag, but the money- and

View file

@ -1,8 +1,9 @@
//! Fidelity export parsers.
//!
//! Parses the CSV produced by Fidelity's "Download Positions" feature,
//! plus the structured option-symbol matching used to tie a Fidelity
//! option row back to a portfolio lot.
//! Parses the CSV produced by Fidelity's "Download Positions" feature.
//! (Compact option-symbol matching against a portfolio lot now lives
//! in `models/option.zig` as `symbolMatchesLot`, since the audit
//! reconciler applies it across brokers, not just Fidelity.)
//!
//! ## Limitations of this CSV parser
//!
@ -37,7 +38,6 @@
//! RFC 4180 fully (quoted fields, escaping, multi-line values).
const std = @import("std");
const Date = @import("../Date.zig");
const portfolio_mod = @import("../models/portfolio.zig");
const types = @import("types.zig");
@ -142,69 +142,6 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
return positions.toOwnedSlice(allocator);
}
/// 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.
pub fn optionMatchesLot(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;
}
// Tests
test "parseCsv basic" {
@ -310,69 +247,3 @@ test "parseCsv cash account type is not cash position" {
try std.testing.expect(!positions[0].is_cash);
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
}
test "optionMatchesLot 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(optionMatchesLot("-AMZN260515C220", lot));
// Without dash
try std.testing.expect(optionMatchesLot("AMZN260515C220", lot));
// Wrong underlying
try std.testing.expect(!optionMatchesLot("-MSFT260515C220", lot));
// Wrong date
try std.testing.expect(!optionMatchesLot("-AMZN260615C220", lot));
// Wrong type
try std.testing.expect(!optionMatchesLot("-AMZN260515P220", lot));
// Wrong strike
try std.testing.expect(!optionMatchesLot("-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(!optionMatchesLot("-AMZN260515C220", stock_lot));
}
test "optionMatchesLot put option and decimal strike" {
const lot = portfolio_mod.Lot{
.symbol = "AAPL 06/20/2026 220.50 P",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.50,
.option_type = .put,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(optionMatchesLot("-AAPL260620P220.50", lot));
try std.testing.expect(optionMatchesLot("AAPL260620P220.50", lot));
// Call doesn't match put
try std.testing.expect(!optionMatchesLot("-AAPL260620C220.50", lot));
}
test "optionMatchesLot single-char underlying" {
const lot = portfolio_mod.Lot{
.symbol = "A 03/20/2026 150.00 C",
.security_type = .option,
.underlying = "A",
.strike = 150.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 3, 20),
.shares = -2,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 3.0,
};
try std.testing.expect(optionMatchesLot("-A260320C150", lot));
try std.testing.expect(!optionMatchesLot("-A260320P150", lot));
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
//! Fidelity reconciler for the `audit` command.
//!
//! Fidelity exports a single "all accounts" positions CSV. Parsing
//! lives in `brokerage/fidelity.zig`; this module wires that parser
//! into the shared per-account comparison engine in `common.zig`.
//! Because the Fidelity export is a plain per-account positions list,
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
//! parser" and "the institution key is `fidelity`" — everything else
//! (comparison, display) is shared.
const std = @import("std");
const zfin = @import("../../root.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const fidelity_parser = @import("../../brokerage/fidelity.zig");
/// Parse a Fidelity positions CSV and reconcile it against the
/// portfolio. Returns owned `AccountComparison` results (free each
/// `.comparisons` slice, then the results slice). String fields in
/// the results borrow from `csv_data`, which must outlive them.
///
/// Propagates the parser's errors (`EmptyFile` / `UnexpectedHeader`)
/// and allocation failures so the caller can decide between a
/// user-facing message (explicit `--fidelity`) and silently skipping
/// the file (flagless auto-reconcile).
pub fn reconcile(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
csv_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const positions = try fidelity_parser.parseCsv(allocator, csv_data);
// The result strings borrow from `csv_data`, not from this slice,
// so freeing the slice array here is safe.
defer allocator.free(positions);
return common.compareAccounts(allocator, portfolio, positions, account_map, "fidelity", prices, as_of);
}
// Tests
const portfolio_mod = @import("../../models/portfolio.zig");
test "reconcile: parses Fidelity CSV and matches against portfolio" {
const allocator = std.testing.allocator;
const csv =
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,100%,$10000.00,$100.00,Margin,\n";
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, .account = "Sample Brokerage" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 150.0);
const results = try reconcile(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 5, 8));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
// 100 shares @ $150 on both sides no discrepancy.
try std.testing.expect(!results[0].has_discrepancies);
}
test "reconcile: propagates parser errors" {
const allocator = std.testing.allocator;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try std.testing.expectError(
error.EmptyFile,
reconcile(allocator, portfolio, "", acct_map, prices, Date.fromYmd(2026, 5, 8)),
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,832 @@
//! Schwab reconcilers for the `audit` command.
//!
//! Schwab has two export shapes, so this module carries more than
//! the Fidelity one:
//!
//! 1. **Per-account positions CSV** (`--schwab`) same per-account
//! positions shape Fidelity uses, so it feeds the shared
//! `common.compareAccounts` engine via `reconcileCsv`.
//! 2. **Account summary paste** (`--schwab-summary`) a
//! per-account totals-only view with no per-symbol detail. This
//! is the one genuinely broker-specific reconciler, with its own
//! comparison type (`SchwabAccountComparison`), comparator
//! (`compareSchwabSummary`), and display.
//!
//! Parsing for both lives in `brokerage/schwab.zig`.
const std = @import("std");
const zfin = @import("../../root.zig");
const cli = @import("../common.zig");
const Money = @import("../../Money.zig");
const analysis = @import("../../analytics/analysis.zig");
const Date = @import("../../Date.zig");
const common = @import("common.zig");
const schwab_parser = @import("../../brokerage/schwab.zig");
const AccountSummary = schwab_parser.AccountSummary;
/// Account-level comparison result for Schwab summary audit.
pub const SchwabAccountComparison = struct {
account_name: []const u8,
schwab_name: []const u8,
account_number: []const u8,
portfolio_cash: f64,
schwab_cash: ?f64,
cash_delta: ?f64,
portfolio_total: f64,
schwab_total: ?f64,
total_delta: ?f64,
has_discrepancy: bool,
};
// Per-account positions CSV (--schwab)
/// Parse a Schwab per-account positions CSV and reconcile it against
/// the portfolio via the shared engine. Returns owned
/// `AccountComparison` results (free each `.comparisons` slice, then
/// the results slice). String fields borrow from `csv_data`, which
/// must outlive them. Propagates parser and allocation errors.
pub fn reconcileCsv(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
csv_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]common.AccountComparison {
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
// Result strings borrow from `csv_data`, not the positions slice,
// so freeing the slice array here is safe.
defer allocator.free(parsed.positions);
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
}
// Account summary paste (--schwab-summary)
/// Parse a Schwab account summary and reconcile its per-account
/// totals against portfolio.srf. Returns owned results (free the
/// slice). String fields borrow from `summary_data`, which must
/// outlive them. Propagates parser (`NoAccountsFound`) and
/// allocation errors.
pub fn reconcileSummary(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
summary_data: []const u8,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]SchwabAccountComparison {
const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
defer allocator.free(schwab_accounts);
return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
}
/// Compare Schwab summary against portfolio.srf account totals.
pub fn compareSchwabSummary(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
schwab_accounts: []const AccountSummary,
account_map: analysis.AccountMap,
prices: std.StringHashMap(f64),
as_of: Date,
) ![]SchwabAccountComparison {
var results = std.ArrayList(SchwabAccountComparison).empty;
errdefer results.deinit(allocator);
for (schwab_accounts) |sa| {
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
var pf_cash: f64 = 0;
var pf_total: f64 = 0;
if (portfolio_acct) |pa| {
pf_cash = portfolio.cashForAccount(pa);
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
}
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
const total_delta = if (sa.total_value) |st| st - pf_total else null;
const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;
try results.append(allocator, .{
.account_name = portfolio_acct orelse "",
.schwab_name = sa.account_name,
.account_number = sa.account_number,
.portfolio_cash = pf_cash,
.schwab_cash = sa.cash,
.cash_delta = cash_delta,
.portfolio_total = pf_total,
.schwab_total = sa.total_value,
.total_delta = total_delta,
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
});
}
return results.toOwnedSlice(allocator);
}
pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
try out.print("========================================\n\n", .{});
// Column headers
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
});
var grand_pf: f64 = 0;
var grand_br: f64 = 0;
var discrepancy_count: usize = 0;
for (results) |r| {
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
var br_cash_buf: [24]u8 = undefined;
var br_total_buf: [24]u8 = undefined;
const br_cash_str = if (r.schwab_cash) |c|
std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
else
"--";
const br_total_str = if (r.schwab_total) |t|
std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
else
"--";
const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true;
const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
const is_unmapped = r.account_name.len == 0;
const is_real_mismatch = !cash_ok or is_unmapped;
if (is_real_mismatch) discrepancy_count += 1;
// Account label
try out.print(" ", .{});
if (is_unmapped) {
try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
} else {
try out.print("{s:<24}", .{label});
}
// PF Cash colored if mismatched (brokerage is truth)
try out.print(" ", .{});
if (!cash_ok) {
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
} else {
try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
}
// BR Cash
try out.print(" {s:>14}", .{br_cash_str});
// PF Total colored if not just stale prices
try out.print(" ", .{});
if (!total_ok and !cash_ok) {
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
} else {
try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
}
// BR Total
try out.print(" {s:>14}", .{br_total_str});
// Status
if (is_unmapped) {
try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
} else if (!cash_ok) {
const d = r.cash_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
} else if (!total_ok) {
const d = r.total_delta.?;
const sign: []const u8 = if (d >= 0) "+" else "-";
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
}
try out.print("\n", .{});
grand_pf += r.portfolio_total;
if (r.schwab_total) |t| grand_br += t;
}
// Grand totals
try out.print("\n", .{});
const grand_delta = grand_br - grand_pf;
try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{
Money.from(grand_pf),
Money.from(grand_br),
});
if (@abs(grand_delta) < 1.0) {
// no delta
} else {
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
}
try out.print("\n", .{});
if (discrepancy_count > 0) {
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
});
}
try out.print("\n", .{});
}
/// Direct-indexing ratio suggestions from Schwab-summary data.
///
/// The Schwab summary path only gives us per-account totals, not
/// per-symbol detail. For a direct-indexing account with exactly one
/// stock lot (the common case the account is the proxy basket,
/// tracked as a single benchmark lot), we can still emit a ratio
/// suggestion from the account-level `total_delta`:
///
/// current_stock_value = portfolio_total - portfolio_cash
/// target_stock_value = current_stock_value + total_delta
/// suggested_ratio = target_stock_value / (shares × price)
///
/// Where `price` is `shares × current_cached_price`. The math
/// assumes the full account delta lands on the single tracked lot,
/// which is the semantics of a direct-indexing proxy.
///
/// Skips accounts with more than one stock lot (can't allocate the
/// delta) or zero stock lots (nothing to adjust).
pub fn displaySchwabSummaryRatioSuggestions(
results: []const SchwabAccountComparison,
portfolio: zfin.Portfolio,
prices: std.StringHashMap(f64),
account_map: ?analysis.AccountMap,
color: bool,
out: *std.Io.Writer,
) !void {
const am = account_map orelse return;
var has_header = false;
for (results) |r| {
if (r.account_name.len == 0) continue;
if (!am.isDirectIndexing(r.account_name)) continue;
const total_delta = r.total_delta orelse continue;
if (@abs(total_delta) < 0.01) continue;
// Find the single stock lot for this account.
var stock_lot: ?zfin.Lot = null;
var stock_lot_count: usize = 0;
for (portfolio.lots) |lot| {
if (lot.security_type != .stock) continue;
const lot_acct = lot.account orelse continue;
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
stock_lot = lot;
stock_lot_count += 1;
}
if (stock_lot_count != 1) continue;
const lot = stock_lot.?;
const price_sym = lot.priceSymbol();
const retail_price = prices.get(price_sym) orelse continue;
if (retail_price == 0) continue;
if (lot.shares == 0) continue;
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
if (current_stock_value == 0) continue;
const target_stock_value = current_stock_value + total_delta;
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
if (!has_header) {
try out.print("\n", .{});
try cli.printBold(out, color, " Ratio updates", .{});
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
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}", .{lot.price_ratio}) catch "?";
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";
try out.print(" {s:<16} ", .{lot.symbol});
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
try out.print(" ratio {s} -> ", .{cur_str});
try cli.printBold(out, color, "{s}", .{sug_str});
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
}
if (has_header) try out.print("\n", .{});
}
/// Check if any Schwab summary results have discrepancies.
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
for (results) |r| {
if (r.has_discrepancy) return true;
}
return false;
}
// Tests
const portfolio_mod = @import("../../models/portfolio.zig");
test "hasSchwabDiscrepancies" {
const clean = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.account_number = "1234",
.portfolio_cash = 100,
.schwab_cash = 100,
.cash_delta = 0,
.portfolio_total = 5000,
.schwab_total = 5000,
.total_delta = 0,
.has_discrepancy = false,
}};
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
const dirty = [_]SchwabAccountComparison{.{
.account_name = "IRA",
.schwab_name = "Roth IRA",
.account_number = "1234",
.portfolio_cash = 100,
.schwab_cash = 200,
.cash_delta = 100,
.portfolio_total = 5000,
.schwab_total = 5100,
.total_delta = 100,
.has_discrepancy = true,
}};
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
}
test "compareSchwabSummary: matching account → no discrepancy" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
// With AAPL price=200, total = 5000 + 10*200 = 7000.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Sample Brokerage",
},
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 150,
.account = "Sample Brokerage",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Sample Brokerage",
.account_number = "1234",
.cash = 5000.0,
.total_value = 7000.0,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Sample Brokerage",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: cash mismatch → has_discrepancy true" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
// Portfolio cash = 5000, Schwab reports 5500 $500 delta.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "Brokerage",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Brokerage",
.account_number = "1234",
.cash = 5500.0,
.total_value = 5500.0,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Brokerage",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
try std.testing.expect(results[0].has_discrepancy);
}
test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 6, 19);
// Portfolio cash $38.75; Schwab reports $38.97 a $0.22 accrual.
// Below the $1 securities tolerance, but a real cash drift that
// must surface.
const lots = [_]portfolio_mod.Lot{
.{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
};
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
try std.testing.expect(results[0].has_discrepancy);
}
test "compareSchwabSummary: account_number with no match → empty account_name" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]portfolio_mod.Lot{};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Unknown Acct",
.account_number = "9999",
.cash = 1000.0,
.total_value = 1000.0,
},
};
var entries = [_]analysis.AccountTaxEntry{};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("", results[0].account_name);
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
// No portfolio match cash and total are zero, schwab values become deltas
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
}
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
const allocator = std.testing.allocator;
const today = Date.fromYmd(2026, 5, 8);
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "CASH",
.shares = 5000,
.open_date = Date.fromYmd(2024, 1, 1),
.open_price = 1.0,
.security_type = .cash,
.account = "X",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "X",
.account_number = "1234",
.cash = null,
.total_value = null,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "X",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expect(results[0].cash_delta == null);
try std.testing.expect(results[0].total_delta == null);
// Null deltas are treated as "ok" (no discrepancy possible to assert).
try std.testing.expect(!results[0].has_discrepancy);
}
test "compareSchwabSummary: today affects valuation of held assets" {
const allocator = std.testing.allocator;
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
// open), it's not held portfolio_total excludes it. With
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
const lots = [_]portfolio_mod.Lot{
.{
.symbol = "AAPL",
.shares = 10,
.open_date = Date.fromYmd(2024, 6, 1),
.open_price = 150,
.account = "Acct",
},
};
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
const schwab_accounts = [_]AccountSummary{
.{
.account_name = "Acct",
.account_number = "1234",
.cash = 0,
.total_value = 2000,
},
};
var entries = [_]analysis.AccountTaxEntry{
.{
.account = "Acct",
.tax_type = .taxable,
.institution = "schwab",
.account_number = "1234",
},
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AAPL", 200.0);
// Before open: portfolio holds nothing for this account.
{
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
defer allocator.free(results);
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
}
// After open: portfolio holds 10 * 200 = 2000.
{
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
defer allocator.free(results);
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
// Matches schwab no discrepancy.
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
try std.testing.expect(!results[0].has_discrepancy);
}
}
// reconcile wrappers (parse + compare wiring)
test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
const allocator = std.testing.allocator;
const csv =
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
"\n" ++
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("AMZN", 239.208);
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
defer {
for (results) |r| allocator.free(r.comparisons);
allocator.free(results);
}
try std.testing.expectEqual(@as(usize, 1), results.len);
try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
var found_amzn = false;
for (results[0].comparisons) |c| {
if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
}
try std.testing.expect(found_amzn);
}
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
const allocator = std.testing.allocator;
const data =
\\Sample Roth
\\Account number ending in 1234 ...1234
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
\\Sample Inherited IRA
\\Account number ending in 5678 ...5678
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10));
defer allocator.free(results);
try std.testing.expectEqual(@as(usize, 2), results.len);
// 1234 maps; 5678 is absent from the map unmapped (empty name).
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
try std.testing.expectEqualStrings("", results[1].account_name);
}
// displaySchwabResults rendering
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
const results = [_]SchwabAccountComparison{
// clean mapped no status
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
// cash mismatch "Cash +$5.00", counts as a real mismatch
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
// value-only mismatch (cash ok) muted "Value +$100.00", not a real mismatch
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
// unmapped, null broker fields "Unmapped" + "--", counts as a real mismatch
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
// unmapped row falls back to the schwab_name label
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null);
// cash-mismatch + unmapped = 2 real mismatches plural
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
}
test "displaySchwabResults: color=true emits ANSI and singular label" {
const results = [_]SchwabAccountComparison{
.{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabResults(&results, true, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
// single mismatch singular "1 mismatch" label
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
}
// displaySchwabSummaryRatioSuggestions
test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
// total_delta 1000 on a 50000 stock value suggested ratio 1.02 vs 1.0.
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [2048]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
}
test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
const allocator = std.testing.allocator;
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, null, false, &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}
test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipped" {
const allocator = std.testing.allocator;
var lots = [_]portfolio_mod.Lot{
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
};
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
var entries = [_]analysis.AccountTaxEntry{
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
};
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
try prices.put("SPY", 500.0);
const results = [_]SchwabAccountComparison{
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
};
var buf: [512]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
}

View file

@ -1,4 +1,5 @@
const Date = @import("../Date.zig");
const portfolio = @import("portfolio.zig");
pub const ContractType = enum {
call,
@ -50,3 +51,202 @@ pub const OptionsChain = struct {
};
const std = @import("std");
/// Match a compact broker-display option symbol against a portfolio
/// lot by parsing the symbol's components (underlying, expiration,
/// call/put, strike) and comparing them to the lot's structured
/// fields.
///
/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}`
/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity
/// and Schwab use in their positions exports distinct from the
/// canonical 21-char OCC symbol with a zero-padded strike. The
/// underlying length is variable, so we scan for the first position
/// where 6 consecutive digits encode a valid date.
///
/// Lives here (the option model) rather than in any one broker's
/// parser because the audit reconciler applies it across brokers
/// keeping it broker-neutral lets the shared comparison engine in
/// `commands/audit/common.zig` stay decoupled from `brokerage/*`.
pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.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.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;
}
test "symbolMatchesLot basic call" {
const lot = portfolio.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,
};
// Compact format with leading dash (short)
try std.testing.expect(symbolMatchesLot("-AMZN260515C220", lot));
// Without dash
try std.testing.expect(symbolMatchesLot("AMZN260515C220", lot));
// Wrong underlying
try std.testing.expect(!symbolMatchesLot("-MSFT260515C220", lot));
// Wrong date
try std.testing.expect(!symbolMatchesLot("-AMZN260615C220", lot));
// Wrong type
try std.testing.expect(!symbolMatchesLot("-AMZN260515P220", lot));
// Wrong strike
try std.testing.expect(!symbolMatchesLot("-AMZN260515C230", lot));
// Non-option lot
const stock_lot = portfolio.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
try std.testing.expect(!symbolMatchesLot("-AMZN260515C220", stock_lot));
}
test "symbolMatchesLot put option and decimal strike" {
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 220.50 P",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.50,
.option_type = .put,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(symbolMatchesLot("-AAPL260620P220.50", lot));
try std.testing.expect(symbolMatchesLot("AAPL260620P220.50", lot));
// Call doesn't match put
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220.50", lot));
}
test "symbolMatchesLot single-char underlying" {
const lot = portfolio.Lot{
.symbol = "A 03/20/2026 150.00 C",
.security_type = .option,
.underlying = "A",
.strike = 150.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 3, 20),
.shares = -2,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 3.0,
};
try std.testing.expect(symbolMatchesLot("-A260320C150", lot));
try std.testing.expect(!symbolMatchesLot("-A260320P150", lot));
}
test "symbolMatchesLot: option lot with null strike never matches" {
// Everything else lines up (underlying/date/type), but a lot with
// no strike can't be a strike match the `else return false` arm.
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 C",
.security_type = .option,
.underlying = "AAPL",
.strike = null,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220", lot));
}
test "symbolMatchesLot: symbol with no valid date boundary falls through to false" {
// Long enough to enter the scan loop, but no 6-digit run followed
// by C/P the loop exhausts and returns false.
const lot = portfolio.Lot{
.symbol = "AAPL 06/20/2026 220.00 C",
.security_type = .option,
.underlying = "AAPL",
.strike = 220.0,
.option_type = .call,
.maturity_date = Date.fromYmd(2026, 6, 20),
.shares = -1,
.open_date = Date.fromYmd(2025, 1, 1),
.open_price = 5.0,
};
try std.testing.expect(!symbolMatchesLot("NODATEHERE", lot));
}
test "OptionsChain.deinit and freeSlice free owned memory" {
const allocator = std.testing.allocator;
// Single-chain deinit.
{
const chain = OptionsChain{
.underlying_symbol = try allocator.dupe(u8, "AAPL"),
.expiration = Date.fromYmd(2026, 6, 20),
.calls = try allocator.alloc(OptionContract, 0),
.puts = try allocator.alloc(OptionContract, 0),
};
chain.deinit(allocator);
}
// Slice freeSlice (deinits each element first).
{
const chains = try allocator.alloc(OptionsChain, 1);
chains[0] = .{
.underlying_symbol = try allocator.dupe(u8, "MSFT"),
.expiration = Date.fromYmd(2026, 6, 20),
.calls = try allocator.alloc(OptionContract, 0),
.puts = try allocator.alloc(OptionContract, 0),
};
OptionsChain.freeSlice(allocator, chains);
}
}

View file

@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle;
//
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
// on `Position` for the canonical implementation. All callers in
// snapshot.zig, audit.zig, and valuation.zig route through these do
// snapshot.zig, audit/, and valuation.zig route through these do
// not reintroduce inline `price * price_ratio` expressions.
//
// ## Caching pre-multiply pattern
@ -49,7 +49,7 @@ const Candle = @import("candle.zig").Candle;
// When manual overrides (2b) get folded into a shared `prices` map
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
// time (see `commands/snapshot.zig:buildSnapshot` and
// `commands/audit.zig`). This normalizes the cached value so later
// `commands/audit/`). This normalizes the cached value so later
// readers can treat every entry uniformly as "price in whichever terms
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
// tells readers which entries are preadjusted.
@ -66,7 +66,7 @@ const Candle = @import("candle.zig").Candle;
// Money-market / stable-NAV classification
//
// Centralized so that audit.zig, the Fidelity/Schwab parsers, and the
// Centralized so that audit/, 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.