split audit by broker
This commit is contained in:
parent
1fa9649bd6
commit
be888069c0
9 changed files with 4397 additions and 3568 deletions
43
TODO.md
43
TODO.md
|
|
@ -160,49 +160,6 @@ settles on. Starting points (grep `\.note` and `note::`):
|
||||||
- `transaction_log` transfer `note` (annotation).
|
- `transaction_log` transfer `note` (annotation).
|
||||||
- audit / contributions matchers (do any key off notes?).
|
- 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
|
## 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
|
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
//! Fidelity export parsers.
|
//! Fidelity export parsers.
|
||||||
//!
|
//!
|
||||||
//! Parses the CSV produced by Fidelity's "Download Positions" feature,
|
//! Parses the CSV produced by Fidelity's "Download Positions" feature.
|
||||||
//! plus the structured option-symbol matching used to tie a Fidelity
|
//! (Compact option-symbol matching against a portfolio lot now lives
|
||||||
//! option row back to a portfolio lot.
|
//! in `models/option.zig` as `symbolMatchesLot`, since the audit
|
||||||
|
//! reconciler applies it across brokers, not just Fidelity.)
|
||||||
//!
|
//!
|
||||||
//! ## Limitations of this CSV parser
|
//! ## Limitations of this CSV parser
|
||||||
//!
|
//!
|
||||||
|
|
@ -37,7 +38,6 @@
|
||||||
//! RFC 4180 fully (quoted fields, escaping, multi-line values).
|
//! RFC 4180 fully (quoted fields, escaping, multi-line values).
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const Date = @import("../Date.zig");
|
|
||||||
const portfolio_mod = @import("../models/portfolio.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
const types = @import("types.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);
|
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 ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "parseCsv basic" {
|
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.expect(!positions[0].is_cash);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
|
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
1672
src/commands/audit/common.zig
Normal file
1672
src/commands/audit/common.zig
Normal file
File diff suppressed because it is too large
Load diff
91
src/commands/audit/fidelity.zig
Normal file
91
src/commands/audit/fidelity.zig
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
1536
src/commands/audit/hygiene.zig
Normal file
1536
src/commands/audit/hygiene.zig
Normal file
File diff suppressed because it is too large
Load diff
832
src/commands/audit/schwab.zig
Normal file
832
src/commands/audit/schwab.zig
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const Date = @import("../Date.zig");
|
const Date = @import("../Date.zig");
|
||||||
|
const portfolio = @import("portfolio.zig");
|
||||||
|
|
||||||
pub const ContractType = enum {
|
pub const ContractType = enum {
|
||||||
call,
|
call,
|
||||||
|
|
@ -50,3 +51,202 @@ pub const OptionsChain = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
const std = @import("std");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle;
|
||||||
//
|
//
|
||||||
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
|
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
|
||||||
// on `Position` for the canonical implementation. All callers in
|
// 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.
|
// not reintroduce inline `price * price_ratio` expressions.
|
||||||
//
|
//
|
||||||
// ## Caching pre-multiply pattern
|
// ## 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
|
// When manual overrides (2b) get folded into a shared `prices` map
|
||||||
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
|
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
|
||||||
// time (see `commands/snapshot.zig:buildSnapshot` and
|
// 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
|
// readers can treat every entry uniformly as "price in whichever terms
|
||||||
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
|
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
|
||||||
// tells readers which entries are preadjusted.
|
// tells readers which entries are preadjusted.
|
||||||
|
|
@ -66,7 +66,7 @@ const Candle = @import("candle.zig").Candle;
|
||||||
|
|
||||||
// ── Money-market / stable-NAV classification ────────────────
|
// ── 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
|
// planned snapshot writer all agree on which symbols are fixed-$1-NAV
|
||||||
// instruments. Prior to this the classification lived in three places
|
// instruments. Prior to this the classification lived in three places
|
||||||
// with three different heuristics that disagreed on edge cases.
|
// with three different heuristics that disagreed on edge cases.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue