split audit by brokerage in prep for import command
This commit is contained in:
parent
b92447b8e7
commit
aa3ad5fe91
5 changed files with 969 additions and 869 deletions
37
TODO.md
37
TODO.md
|
|
@ -199,13 +199,23 @@ to avoid duplicated checks.
|
||||||
|
|
||||||
## Split `audit.zig` into per-broker reconcilers — priority LOW
|
## Split `audit.zig` into per-broker reconcilers — priority LOW
|
||||||
|
|
||||||
`src/commands/audit.zig` is 3438 lines — the largest command file
|
`src/commands/audit.zig` is now 2856 lines (was 3438) after the
|
||||||
by ~2x. It bundles four logically distinct responsibilities:
|
brokerage parsers moved to per-broker files under `src/brokerage/`.
|
||||||
|
It still bundles three logically distinct responsibilities:
|
||||||
|
|
||||||
- Portfolio hygiene check (no-flag mode)
|
- Portfolio hygiene check (no-flag mode)
|
||||||
- Fidelity positions CSV reconciler (`--fidelity`)
|
- Fidelity positions CSV reconciler (`--fidelity`)
|
||||||
- Schwab per-account positions CSV reconciler (`--schwab`)
|
- Schwab per-account positions CSV reconciler (`--schwab`) and
|
||||||
- Schwab account-summary stdin parser (`--schwab-summary`)
|
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
|
### Sketch
|
||||||
|
|
||||||
|
|
@ -213,23 +223,20 @@ by ~2x. It bundles four logically distinct responsibilities:
|
||||||
src/commands/audit/
|
src/commands/audit/
|
||||||
mod.zig ← thin dispatcher; current public `run()` lives here
|
mod.zig ← thin dispatcher; current public `run()` lives here
|
||||||
hygiene.zig ← portfolio hygiene check (no-flag mode)
|
hygiene.zig ← portfolio hygiene check (no-flag mode)
|
||||||
fidelity.zig ← --fidelity CSV reconciler
|
fidelity.zig ← --fidelity reconciler (uses brokerage/fidelity.zig)
|
||||||
schwab.zig ← --schwab CSV + --schwab-summary stdin reconciler
|
schwab.zig ← --schwab + --schwab-summary reconcilers
|
||||||
common.zig ← shared types (Discrepancy, ReconcileResult), formatters
|
common.zig ← shared types (Discrepancy, ReconcileResult), formatters
|
||||||
```
|
```
|
||||||
|
|
||||||
Adding a new broker (Vanguard, Robinhood, etc.) becomes a one-file
|
The hygiene check can be referenced from `zfin doctor` (above)
|
||||||
add against a documented contract. The hygiene check can be
|
without pulling in reconciler baggage.
|
||||||
referenced from `zfin doctor` (above) without pulling in CSV-parser
|
|
||||||
baggage.
|
|
||||||
|
|
||||||
### Driver
|
### Driver
|
||||||
|
|
||||||
Maintenance friction. The next person adding a broker reconciler
|
Maintenance friction. The split makes the audit-bug investigations
|
||||||
— likely future-you — has to navigate 3438 lines to find the
|
already in this TODO file (phantom discrepancy on freshly-added
|
||||||
pattern. The split also makes the audit-bug investigations already
|
lots) easier to localize, and lets a `zfin doctor` command reuse
|
||||||
in this TODO file (phantom discrepancy on freshly-added lots) easier
|
hygiene without inheriting the reconciliation surface.
|
||||||
to localize.
|
|
||||||
|
|
||||||
Pure internal refactor; no user-visible change.
|
Pure internal refactor; no user-visible change.
|
||||||
|
|
||||||
|
|
|
||||||
378
src/brokerage/fidelity.zig
Normal file
378
src/brokerage/fidelity.zig
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
//! 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.
|
||||||
|
//!
|
||||||
|
//! ## Limitations of this CSV parser
|
||||||
|
//!
|
||||||
|
//! 1. NOT a general-purpose CSV parser. It handles the specific format
|
||||||
|
//! exported by Fidelity's "Download Positions" feature.
|
||||||
|
//!
|
||||||
|
//! 2. Does NOT handle quoted fields containing commas. Fidelity's export
|
||||||
|
//! does not quote fields with commas in practice (description fields
|
||||||
|
//! use spaces, not commas), but a truly compliant RFC 4180 parser would
|
||||||
|
//! need to handle "field,with,commas" as a single value.
|
||||||
|
//!
|
||||||
|
//! 3. Does NOT handle escaped quotes ("" inside quoted fields).
|
||||||
|
//!
|
||||||
|
//! 4. Does NOT handle multi-line values (newlines inside quoted fields).
|
||||||
|
//!
|
||||||
|
//! 5. Assumes UTF-8 with optional BOM (which Fidelity includes).
|
||||||
|
//!
|
||||||
|
//! 6. Stops parsing at the first blank line, which separates position data
|
||||||
|
//! from the Fidelity legal disclaimer footer.
|
||||||
|
//!
|
||||||
|
//! 7. Hardcodes the expected column layout. If Fidelity changes the CSV
|
||||||
|
//! format (adds/removes/reorders columns), this parser will break.
|
||||||
|
//! The header row is validated to catch this.
|
||||||
|
//!
|
||||||
|
//! 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of
|
||||||
|
//! $, commas, and leading +/- signs. Negative values wrapped in
|
||||||
|
//! parentheses are NOT handled (Fidelity uses -$X.XX format).
|
||||||
|
//!
|
||||||
|
//! 9. Money market rows (symbol ending in **) are treated as cash.
|
||||||
|
//!
|
||||||
|
//! For a production-grade CSV parser, consider a library that handles
|
||||||
|
//! 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");
|
||||||
|
|
||||||
|
const BrokeragePosition = types.BrokeragePosition;
|
||||||
|
const parseDollarAmount = types.parseDollarAmount;
|
||||||
|
const isUnitPriceCash = types.isUnitPriceCash;
|
||||||
|
|
||||||
|
const expected_columns = 16;
|
||||||
|
|
||||||
|
/// Column indices in the Fidelity CSV export.
|
||||||
|
/// Based on: 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
|
||||||
|
const Col = struct {
|
||||||
|
const account_number = 0;
|
||||||
|
const account_name = 1;
|
||||||
|
const symbol = 2;
|
||||||
|
const description = 3;
|
||||||
|
const quantity = 4;
|
||||||
|
const last_price = 5;
|
||||||
|
const current_value = 7;
|
||||||
|
const cost_basis_total = 13;
|
||||||
|
const avg_cost_basis = 14;
|
||||||
|
const type_col = 15;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse a Fidelity CSV positions export into BrokeragePosition slices.
|
||||||
|
/// All string fields in the returned positions are slices into `data`,
|
||||||
|
/// so the caller must keep `data` alive for as long as the positions are used.
|
||||||
|
/// Only the returned slice itself is heap-allocated (caller must free it).
|
||||||
|
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
|
||||||
|
var positions = std.ArrayList(BrokeragePosition).empty;
|
||||||
|
errdefer positions.deinit(allocator);
|
||||||
|
|
||||||
|
// Skip UTF-8 BOM if present
|
||||||
|
var content = data;
|
||||||
|
if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) {
|
||||||
|
content = content[3..];
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = std.mem.splitScalar(u8, content, '\n');
|
||||||
|
|
||||||
|
// Validate header row
|
||||||
|
const header_line = lines.next() orelse return error.EmptyFile;
|
||||||
|
const header_trimmed = std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' });
|
||||||
|
if (header_trimmed.len == 0) return error.EmptyFile;
|
||||||
|
if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) {
|
||||||
|
return error.UnexpectedHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data rows
|
||||||
|
while (lines.next()) |line| {
|
||||||
|
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
||||||
|
if (trimmed.len == 0) break;
|
||||||
|
|
||||||
|
// Skip lines starting with " (disclaimer text)
|
||||||
|
if (trimmed[0] == '"') break;
|
||||||
|
|
||||||
|
var col_iter = std.mem.splitScalar(u8, trimmed, ',');
|
||||||
|
var cols: [expected_columns][]const u8 = undefined;
|
||||||
|
var col_count: usize = 0;
|
||||||
|
while (col_iter.next()) |col| {
|
||||||
|
if (col_count < expected_columns) {
|
||||||
|
cols[col_count] = col;
|
||||||
|
col_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (col_count < expected_columns) continue;
|
||||||
|
|
||||||
|
const symbol_raw = std.mem.trim(u8, cols[Col.symbol], &.{ ' ', '"' });
|
||||||
|
if (symbol_raw.len == 0) continue;
|
||||||
|
|
||||||
|
// Strip ** suffix from money market symbols for display
|
||||||
|
const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**"))
|
||||||
|
symbol_raw[0 .. symbol_raw.len - 2]
|
||||||
|
else
|
||||||
|
symbol_raw;
|
||||||
|
|
||||||
|
// Classify as cash if any of:
|
||||||
|
// - Fidelity's ** suffix marks a money-market position
|
||||||
|
// - The symbol appears in zfin's canonical money-market list
|
||||||
|
// (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these)
|
||||||
|
// - price and cost both equal exactly $1.00, the catch-all for
|
||||||
|
// fixed-NAV instruments that we don't have in the list yet.
|
||||||
|
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
|
||||||
|
portfolio_mod.isMoneyMarketSymbol(symbol_clean) or
|
||||||
|
isUnitPriceCash(cols[Col.last_price], cols[Col.avg_cost_basis]);
|
||||||
|
|
||||||
|
try positions.append(allocator, .{
|
||||||
|
.account_number = std.mem.trim(u8, cols[Col.account_number], &.{ ' ', '"' }),
|
||||||
|
.account_name = std.mem.trim(u8, cols[Col.account_name], &.{ ' ', '"' }),
|
||||||
|
.symbol = symbol_clean,
|
||||||
|
.description = std.mem.trim(u8, cols[Col.description], &.{ ' ', '"' }),
|
||||||
|
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
||||||
|
.current_value = parseDollarAmount(cols[Col.current_value]),
|
||||||
|
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis_total]),
|
||||||
|
.is_cash = is_cash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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" {
|
||||||
|
const csv =
|
||||||
|
"\xEF\xBB\xBF" ++ // BOM
|
||||||
|
"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\r\n" ++
|
||||||
|
"Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++
|
||||||
|
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++
|
||||||
|
"\r\n" ++
|
||||||
|
"\"Disclaimer text\"\r\n";
|
||||||
|
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, csv);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
||||||
|
|
||||||
|
// Cash position
|
||||||
|
try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
|
||||||
|
try std.testing.expect(positions[0].is_cash);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01);
|
||||||
|
try std.testing.expect(positions[0].quantity == null);
|
||||||
|
|
||||||
|
// Stock position
|
||||||
|
try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
|
||||||
|
try std.testing.expect(!positions[1].is_cash);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv treats $1.00 price+cost as cash" {
|
||||||
|
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,FDRXX,FID GOV CASH RESERVE,8500,$1.00,,$8500.00,,,,,10%,$8500.00,$1.00,Cash,\n";
|
||||||
|
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, csv);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
|
try std.testing.expectEqualStrings("FDRXX", positions[0].symbol);
|
||||||
|
try std.testing.expect(positions[0].is_cash);
|
||||||
|
try std.testing.expect(positions[0].quantity == null);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 8500.00), positions[0].current_value.?, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv stops at blank line" {
|
||||||
|
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,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++
|
||||||
|
"\n" ++
|
||||||
|
"Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n";
|
||||||
|
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, csv);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
|
try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv option row" {
|
||||||
|
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, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n";
|
||||||
|
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, csv);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
|
try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv empty file" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const result = parseCsv(allocator, "");
|
||||||
|
try std.testing.expectError(error.EmptyFile, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv wrong header" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const result = parseCsv(allocator, "Wrong,Header,Format\n");
|
||||||
|
try std.testing.expectError(error.UnexpectedHeader, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv cash account type is not cash position" {
|
||||||
|
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
|
||||||
|
// This does NOT mean the security is a cash holding — only ** suffix means that.
|
||||||
|
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" ++
|
||||||
|
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
|
||||||
|
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const positions = try parseCsv(allocator, csv);
|
||||||
|
defer allocator.free(positions);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||||
|
try std.testing.expectEqualStrings("QTUM", positions[0].symbol);
|
||||||
|
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));
|
||||||
|
}
|
||||||
423
src/brokerage/schwab.zig
Normal file
423
src/brokerage/schwab.zig
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
//! Schwab export parsers.
|
||||||
|
//!
|
||||||
|
//! Parses two distinct Schwab inputs:
|
||||||
|
//!
|
||||||
|
//! 1. The per-account positions CSV exported from Schwab's website
|
||||||
|
//! (Accounts → Positions → Export). One file per account.
|
||||||
|
//!
|
||||||
|
//! 2. The freeform account-summary text the user pastes from
|
||||||
|
//! Schwab's Accounts overview page. One paste covers all
|
||||||
|
//! accounts at once but only carries cash + total-value
|
||||||
|
//! aggregates, no per-position detail.
|
||||||
|
//!
|
||||||
|
//! ## Schwab CSV — limitations
|
||||||
|
//!
|
||||||
|
//! 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
||||||
|
//! format where every field is double-quoted.
|
||||||
|
//!
|
||||||
|
//! 2. Handles simple quoted fields ("value") but does NOT handle escaped
|
||||||
|
//! quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
||||||
|
//! Schwab's export does not use these in practice.
|
||||||
|
//!
|
||||||
|
//! 3. The account number and name are extracted from the title line:
|
||||||
|
//! "Positions for account <NAME> ...<NUM> as of ..."
|
||||||
|
//!
|
||||||
|
//! 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
||||||
|
//! The row with symbol "Positions Total" is skipped.
|
||||||
|
//!
|
||||||
|
//! 5. Hardcodes the expected column layout. If Schwab changes the CSV
|
||||||
|
//! format, this parser will break. The header row is not validated
|
||||||
|
//! beyond being skipped.
|
||||||
|
//!
|
||||||
|
//! ## Schwab summary — limitations
|
||||||
|
//!
|
||||||
|
//! The expected paste format is repeating blocks of 2-3 lines per
|
||||||
|
//! account:
|
||||||
|
//!
|
||||||
|
//! Account Name
|
||||||
|
//! Account number ending in NNN ...NNN
|
||||||
|
//! Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
||||||
|
//!
|
||||||
|
//! 1. NOT a CSV parser — parses freeform text pasted from the Schwab UI.
|
||||||
|
//!
|
||||||
|
//! 2. Identifies account blocks by the "Account number ending in" line.
|
||||||
|
//! The account name is the non-empty line immediately before it.
|
||||||
|
//!
|
||||||
|
//! 3. The values line (cash, total, change, pct) is identified by finding
|
||||||
|
//! dollar amounts. It tolerates missing or extra fields — it looks for
|
||||||
|
//! the first two dollar amounts as cash and total value.
|
||||||
|
//!
|
||||||
|
//! 4. Skips summary lines like "Investment Total", "Day Change Total",
|
||||||
|
//! and "Day Change Percent Total" which appear at the end of the paste.
|
||||||
|
//!
|
||||||
|
//! 5. Tolerant of partial pastes: if the user copies headers once but
|
||||||
|
//! not on subsequent pastes, or includes extra blank lines, the parser
|
||||||
|
//! still finds account blocks by the "Account number ending in" anchor.
|
||||||
|
//!
|
||||||
|
//! 6. The account number is extracted from "...NNN" at the end of the
|
||||||
|
//! account number line (the last whitespace-separated token).
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
|
const types = @import("types.zig");
|
||||||
|
|
||||||
|
const BrokeragePosition = types.BrokeragePosition;
|
||||||
|
const parseDollarAmount = types.parseDollarAmount;
|
||||||
|
|
||||||
|
const expected_columns = 17;
|
||||||
|
|
||||||
|
const Col = struct {
|
||||||
|
const symbol = 0;
|
||||||
|
const price = 4;
|
||||||
|
const quantity = 5;
|
||||||
|
const market_value = 8;
|
||||||
|
const cost_basis = 9;
|
||||||
|
const asset_type = 16;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Split a Schwab CSV line on commas, stripping surrounding quotes from each field.
|
||||||
|
/// Returns the number of columns parsed. Fields are slices into the input line.
|
||||||
|
fn splitCsvLine(line: []const u8, cols: *[expected_columns][]const u8) usize {
|
||||||
|
var col_count: usize = 0;
|
||||||
|
var pos: usize = 0;
|
||||||
|
while (pos < line.len and col_count < expected_columns) {
|
||||||
|
if (line[pos] == '"') {
|
||||||
|
// Quoted field: find closing quote
|
||||||
|
const start = pos + 1;
|
||||||
|
pos = start;
|
||||||
|
while (pos < line.len and line[pos] != '"') : (pos += 1) {}
|
||||||
|
cols[col_count] = line[start..pos];
|
||||||
|
col_count += 1;
|
||||||
|
if (pos < line.len) pos += 1; // skip closing quote
|
||||||
|
if (pos < line.len and line[pos] == ',') pos += 1; // skip comma
|
||||||
|
} else if (line[pos] == ',') {
|
||||||
|
cols[col_count] = "";
|
||||||
|
col_count += 1;
|
||||||
|
pos += 1;
|
||||||
|
} else {
|
||||||
|
// Unquoted field
|
||||||
|
const start = pos;
|
||||||
|
while (pos < line.len and line[pos] != ',') : (pos += 1) {}
|
||||||
|
cols[col_count] = line[start..pos];
|
||||||
|
col_count += 1;
|
||||||
|
if (pos < line.len) pos += 1; // skip comma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return col_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract account name and number from Schwab title line.
|
||||||
|
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
|
||||||
|
/// Returns {name, number} or null if the line doesn't match.
|
||||||
|
fn parseTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
|
||||||
|
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
||||||
|
const prefix = "Positions for account ";
|
||||||
|
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
||||||
|
const rest = stripped[prefix.len..];
|
||||||
|
|
||||||
|
// Find "..." which separates name from account number
|
||||||
|
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
|
||||||
|
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
|
||||||
|
|
||||||
|
// Account number: after "..." until " as of" or end
|
||||||
|
const after_dots = rest[dots_idx + 3 ..];
|
||||||
|
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
|
||||||
|
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
|
||||||
|
|
||||||
|
return .{ .name = name, .number = number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed Schwab per-account positions CSV. Returned `positions`
|
||||||
|
/// slice is heap-allocated; string fields slice into `data`.
|
||||||
|
pub const CsvResult = struct {
|
||||||
|
positions: []BrokeragePosition,
|
||||||
|
account_name: []const u8,
|
||||||
|
account_number: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) !CsvResult {
|
||||||
|
var positions = std.ArrayList(BrokeragePosition).empty;
|
||||||
|
errdefer positions.deinit(allocator);
|
||||||
|
|
||||||
|
var lines = std.mem.splitScalar(u8, data, '\n');
|
||||||
|
|
||||||
|
// Line 1: title with account name and number
|
||||||
|
const title_line = lines.next() orelse return error.EmptyFile;
|
||||||
|
const title = parseTitle(title_line) orelse return error.UnexpectedHeader;
|
||||||
|
|
||||||
|
// Line 2: blank (skip)
|
||||||
|
_ = lines.next();
|
||||||
|
// Line 3: header row (skip)
|
||||||
|
_ = lines.next();
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
while (lines.next()) |line| {
|
||||||
|
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
||||||
|
if (trimmed.len == 0) continue;
|
||||||
|
|
||||||
|
var cols: [expected_columns][]const u8 = undefined;
|
||||||
|
const col_count = splitCsvLine(trimmed, &cols);
|
||||||
|
if (col_count < expected_columns) continue;
|
||||||
|
|
||||||
|
const symbol = cols[Col.symbol];
|
||||||
|
if (symbol.len == 0) continue;
|
||||||
|
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
|
||||||
|
|
||||||
|
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
||||||
|
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
||||||
|
// with their real ticker and price — treat those as cash too so
|
||||||
|
// the reconciliation matches what brokerage users think of as
|
||||||
|
// "cash" in the account.
|
||||||
|
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
||||||
|
portfolio_mod.isMoneyMarketSymbol(symbol);
|
||||||
|
|
||||||
|
try positions.append(allocator, .{
|
||||||
|
.account_number = title.number,
|
||||||
|
.account_name = title.name,
|
||||||
|
.symbol = symbol,
|
||||||
|
.description = if (col_count > 1) cols[1] else "",
|
||||||
|
.quantity = if (is_cash) null else parseDollarAmount(cols[Col.quantity]),
|
||||||
|
.current_value = parseDollarAmount(cols[Col.market_value]),
|
||||||
|
.cost_basis = if (is_cash) null else parseDollarAmount(cols[Col.cost_basis]),
|
||||||
|
.is_cash = is_cash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.positions = try positions.toOwnedSlice(allocator),
|
||||||
|
.account_name = title.name,
|
||||||
|
.account_number = title.number,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account-level summary from a Schwab paste (no per-position detail).
|
||||||
|
pub const AccountSummary = struct {
|
||||||
|
account_name: []const u8,
|
||||||
|
account_number: []const u8,
|
||||||
|
cash: ?f64,
|
||||||
|
total_value: ?f64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse Schwab account summary from pasted text.
|
||||||
|
/// All string fields in the returned summaries are slices into `data`.
|
||||||
|
/// Only the returned slice itself is heap-allocated (caller must free it).
|
||||||
|
pub fn parseSummary(allocator: std.mem.Allocator, data: []const u8) ![]AccountSummary {
|
||||||
|
var accounts = std.ArrayList(AccountSummary).empty;
|
||||||
|
errdefer accounts.deinit(allocator);
|
||||||
|
|
||||||
|
// Collect all lines, trimmed
|
||||||
|
var all_lines = std.ArrayList([]const u8).empty;
|
||||||
|
defer all_lines.deinit(allocator);
|
||||||
|
|
||||||
|
var line_iter = std.mem.splitScalar(u8, data, '\n');
|
||||||
|
while (line_iter.next()) |line| {
|
||||||
|
const trimmed = std.mem.trim(u8, line, &.{ '\r', ' ', '\t' });
|
||||||
|
try all_lines.append(allocator, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = all_lines.items;
|
||||||
|
|
||||||
|
// Scan for "Account number ending in" anchors
|
||||||
|
for (lines, 0..) |line, i| {
|
||||||
|
if (!std.mem.startsWith(u8, line, "Account number ending in")) continue;
|
||||||
|
|
||||||
|
// Extract account number: last token on the line (e.g. "...5678" -> "5678")
|
||||||
|
var acct_num: []const u8 = "";
|
||||||
|
var tok_iter = std.mem.tokenizeAny(u8, line, &.{ ' ', '\t' });
|
||||||
|
while (tok_iter.next()) |tok| {
|
||||||
|
acct_num = tok;
|
||||||
|
}
|
||||||
|
// Strip leading dots
|
||||||
|
while (acct_num.len > 0 and acct_num[0] == '.') {
|
||||||
|
acct_num = acct_num[1..];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account name: nearest non-empty line before the anchor
|
||||||
|
var acct_name: []const u8 = "";
|
||||||
|
if (i > 0) {
|
||||||
|
var j: usize = i - 1;
|
||||||
|
while (true) {
|
||||||
|
if (lines[j].len > 0 and
|
||||||
|
!std.mem.startsWith(u8, lines[j], "Account number") and
|
||||||
|
!std.mem.startsWith(u8, lines[j], "Investment Total") and
|
||||||
|
!std.mem.startsWith(u8, lines[j], "Day Change"))
|
||||||
|
{
|
||||||
|
acct_name = lines[j];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (j == 0) break;
|
||||||
|
j -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values line: look at lines after the anchor for dollar amounts.
|
||||||
|
// The format is "Type XXX $CASH $TOTAL +$CHANGE +PCT%"
|
||||||
|
// We want the first two dollar amounts (cash and total).
|
||||||
|
var cash: ?f64 = null;
|
||||||
|
var total: ?f64 = null;
|
||||||
|
if (i + 1 < lines.len) {
|
||||||
|
var dollar_values = std.ArrayList(f64).empty;
|
||||||
|
defer dollar_values.deinit(allocator);
|
||||||
|
|
||||||
|
var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' });
|
||||||
|
while (val_iter.next()) |tok| {
|
||||||
|
if (parseDollarAmount(tok)) |v| {
|
||||||
|
dollar_values.append(allocator, v) catch {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dollar_values.items.len >= 2) {
|
||||||
|
cash = dollar_values.items[0];
|
||||||
|
total = dollar_values.items[1];
|
||||||
|
} else if (dollar_values.items.len == 1) {
|
||||||
|
total = dollar_values.items[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try accounts.append(allocator, .{
|
||||||
|
.account_name = acct_name,
|
||||||
|
.account_number = acct_num,
|
||||||
|
.cash = cash,
|
||||||
|
.total_value = total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.items.len == 0) return error.NoAccountsFound;
|
||||||
|
|
||||||
|
return accounts.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "parseTitle" {
|
||||||
|
const t1 = parseTitle("\"Positions for account Joint Account ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
||||||
|
try std.testing.expect(t1 != null);
|
||||||
|
try std.testing.expectEqualStrings("Joint Account", t1.?.name);
|
||||||
|
try std.testing.expectEqualStrings("1234", t1.?.number);
|
||||||
|
|
||||||
|
const t2 = parseTitle("\"Positions for account Emil IRA ...7890 as of 3:00 PM ET, 2026/04/10\"");
|
||||||
|
try std.testing.expect(t2 != null);
|
||||||
|
try std.testing.expectEqualStrings("Emil IRA", t2.?.name);
|
||||||
|
try std.testing.expectEqualStrings("7890", t2.?.number);
|
||||||
|
|
||||||
|
try std.testing.expect(parseTitle("some random text") == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "splitCsvLine" {
|
||||||
|
var cols: [expected_columns][]const u8 = undefined;
|
||||||
|
|
||||||
|
const n = splitCsvLine("\"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\",", &cols);
|
||||||
|
try std.testing.expectEqual(@as(usize, 17), n);
|
||||||
|
try std.testing.expectEqualStrings("AMZN", cols[0]);
|
||||||
|
try std.testing.expectEqualStrings("AMAZON.COM INC", cols[1]);
|
||||||
|
try std.testing.expectEqualStrings("1,488", cols[5]);
|
||||||
|
try std.testing.expectEqualStrings("$355,941.50", cols[8]);
|
||||||
|
try std.testing.expectEqualStrings("Equity", cols[16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseCsv basic" {
|
||||||
|
const csv =
|
||||||
|
"\"Positions for account Joint Account ...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" ++
|
||||||
|
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
||||||
|
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
||||||
|
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const parsed = try parseCsv(allocator, csv);
|
||||||
|
defer allocator.free(parsed.positions);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("Joint Account", parsed.account_name);
|
||||||
|
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
||||||
|
|
||||||
|
// Stock position
|
||||||
|
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
||||||
|
try std.testing.expect(!parsed.positions[0].is_cash);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
|
||||||
|
|
||||||
|
// Cash
|
||||||
|
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
|
||||||
|
try std.testing.expect(parsed.positions[1].is_cash);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
|
||||||
|
try std.testing.expect(parsed.positions[1].quantity == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseSummary basic" {
|
||||||
|
const data =
|
||||||
|
\\Emil Roth
|
||||||
|
\\Account number ending in 5678 ...5678
|
||||||
|
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
||||||
|
\\Sample Inherited IRA
|
||||||
|
\\Account number ending in 9012 ...9012
|
||||||
|
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
||||||
|
;
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const accounts = try parseSummary(allocator, data);
|
||||||
|
defer allocator.free(accounts);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), accounts.len);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name);
|
||||||
|
try std.testing.expectEqualStrings("5678", accounts[0].account_number);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 46.44), accounts[0].cash.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("Sample Inherited IRA", accounts[1].account_name);
|
||||||
|
try std.testing.expectEqualStrings("9012", accounts[1].account_number);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 2461.82), accounts[1].cash.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseSummary tolerates missing headers and extra blank lines" {
|
||||||
|
const data =
|
||||||
|
\\
|
||||||
|
\\Joint Account
|
||||||
|
\\Account number ending in 1234 ...1234
|
||||||
|
\\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47%
|
||||||
|
\\
|
||||||
|
\\Tax Loss
|
||||||
|
\\Account number ending in 2345 ...2345
|
||||||
|
\\$4,654.15 $488,481.18 +$1,686.91 +0.35%
|
||||||
|
;
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const accounts = try parseSummary(allocator, data);
|
||||||
|
defer allocator.free(accounts);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), accounts.len);
|
||||||
|
try std.testing.expectEqualStrings("Joint Account", accounts[0].account_name);
|
||||||
|
try std.testing.expectEqualStrings("1234", accounts[0].account_number);
|
||||||
|
|
||||||
|
// Second account has no "Type" prefix — parser still finds dollar amounts
|
||||||
|
try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 4654.15), accounts[1].cash.?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseSummary skips summary footer" {
|
||||||
|
const data =
|
||||||
|
\\Mom
|
||||||
|
\\Account number ending in 152 ...152
|
||||||
|
\\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47%
|
||||||
|
\\Investment Total
|
||||||
|
\\$22,070.35
|
||||||
|
\\$4,338,116.38
|
||||||
|
\\Day Change Total
|
||||||
|
\\+$31,633.86
|
||||||
|
;
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const accounts = try parseSummary(allocator, data);
|
||||||
|
defer allocator.free(accounts);
|
||||||
|
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), accounts.len);
|
||||||
|
try std.testing.expectEqualStrings("Mom", accounts[0].account_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parseSummary no accounts" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const result = parseSummary(allocator, "some random text\nno accounts here\n");
|
||||||
|
try std.testing.expectError(error.NoAccountsFound, result);
|
||||||
|
}
|
||||||
134
src/brokerage/types.zig
Normal file
134
src/brokerage/types.zig
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
//! Shared types and helpers for brokerage exports.
|
||||||
|
//!
|
||||||
|
//! This file holds the cross-broker shape — the normalized
|
||||||
|
//! `BrokeragePosition` record and the dollar-string parser every
|
||||||
|
//! broker needs. Per-broker parsers (`fidelity.zig`, `schwab.zig`)
|
||||||
|
//! build on top of these.
|
||||||
|
//!
|
||||||
|
//! ## Why not reuse `Lot` / `Account` from the portfolio model?
|
||||||
|
//!
|
||||||
|
//! Brokerage exports describe positions at a different granularity
|
||||||
|
//! and identity than zfin's portfolio file:
|
||||||
|
//!
|
||||||
|
//! - **Aggregate, not atomic.** A brokerage row says "100 AAPL @
|
||||||
|
//! $150 avg cost" — that single row can correspond to N lots
|
||||||
|
//! opened on different dates. `Lot` is per-buy; conflating them
|
||||||
|
//! would force a synthetic open_date every parser would have to
|
||||||
|
//! invent.
|
||||||
|
//! - **No buy date.** Positions CSVs don't include open_date /
|
||||||
|
//! open_price; you'd need a separate transactions export for
|
||||||
|
//! that. `Lot` requires both.
|
||||||
|
//! - **Current value lives on the row.** Brokerage rows carry
|
||||||
|
//! `current_value` as fact-from-the-export; lots compute value
|
||||||
|
//! from `shares × current_price` at query time.
|
||||||
|
//! - **Account identity differs.** Brokerage rows reference an
|
||||||
|
//! account *number* (the trailing N digits the export shows);
|
||||||
|
//! portfolio lots reference the human-readable account *name*.
|
||||||
|
//! The whole point of `accounts.srf` is to map between the two.
|
||||||
|
//!
|
||||||
|
//! `BrokeragePosition` is intentionally the unmapped, point-in-time
|
||||||
|
//! shape — exactly what the audit reconciler needs to compare
|
||||||
|
//! against the portfolio's mapped view.
|
||||||
|
//!
|
||||||
|
//! ## Memory & lifetime contract
|
||||||
|
//!
|
||||||
|
//! All string fields in records produced by the per-broker parsers
|
||||||
|
//! are slices INTO the caller's input bytes. The caller must keep
|
||||||
|
//! the input alive as long as it uses the returned records. The
|
||||||
|
//! returned slices themselves are heap-allocated against the caller's
|
||||||
|
//! allocator.
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
// ── Brokerage position (normalized from any source) ─────────
|
||||||
|
|
||||||
|
/// A single position row from a brokerage export, normalized to a
|
||||||
|
/// common shape so reconcilers and the import command don't need
|
||||||
|
/// per-broker branching.
|
||||||
|
pub const BrokeragePosition = struct {
|
||||||
|
account_number: []const u8,
|
||||||
|
account_name: []const u8,
|
||||||
|
symbol: []const u8,
|
||||||
|
description: []const u8,
|
||||||
|
quantity: ?f64,
|
||||||
|
current_value: ?f64,
|
||||||
|
cost_basis: ?f64,
|
||||||
|
is_cash: bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dollar-string parsing ────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
|
||||||
|
/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values.
|
||||||
|
///
|
||||||
|
/// Both Fidelity and Schwab use the same dollar-string format in their
|
||||||
|
/// exports, so this helper lives at the cross-broker level. The upcoming
|
||||||
|
/// `import` command also reuses it for synthesizing lots from any
|
||||||
|
/// brokerage export.
|
||||||
|
pub fn parseDollarAmount(raw: []const u8) ?f64 {
|
||||||
|
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' });
|
||||||
|
if (trimmed.len == 0) return null;
|
||||||
|
|
||||||
|
// Strip leading +/- and $, remove commas
|
||||||
|
var buf: [64]u8 = undefined;
|
||||||
|
var pos: usize = 0;
|
||||||
|
var negative = false;
|
||||||
|
for (trimmed) |c| {
|
||||||
|
if (c == '-') {
|
||||||
|
negative = true;
|
||||||
|
} else if (c == '$' or c == '+' or c == ',') {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
if (pos >= buf.len) return null;
|
||||||
|
buf[pos] = c;
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos == 0) return null;
|
||||||
|
|
||||||
|
const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null;
|
||||||
|
return if (negative) -val else val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when both the last price and average cost basis parse
|
||||||
|
/// to exactly $1.00, indicating a money-market or cash-equivalent
|
||||||
|
/// position (e.g. FDRXX). Used by the Fidelity parser to catch cash
|
||||||
|
/// holdings that aren't tagged with the `**` suffix Fidelity normally
|
||||||
|
/// uses.
|
||||||
|
pub fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool {
|
||||||
|
const price = parseDollarAmount(price_raw) orelse return false;
|
||||||
|
const cost = parseDollarAmount(cost_raw) orelse return false;
|
||||||
|
return price == 1.0 and cost == 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test "parseDollarAmount" {
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01);
|
||||||
|
try std.testing.expect(parseDollarAmount("") == null);
|
||||||
|
try std.testing.expect(parseDollarAmount(" ") == null);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "isUnitPriceCash: $1.00 + $1.00 returns true" {
|
||||||
|
try std.testing.expect(isUnitPriceCash("$1.00", "$1.00"));
|
||||||
|
try std.testing.expect(isUnitPriceCash("1.00", "1.00"));
|
||||||
|
try std.testing.expect(isUnitPriceCash("$1", "$1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "isUnitPriceCash: non-$1 price returns false" {
|
||||||
|
try std.testing.expect(!isUnitPriceCash("$1.01", "$1.00"));
|
||||||
|
try std.testing.expect(!isUnitPriceCash("$1.00", "$1.01"));
|
||||||
|
try std.testing.expect(!isUnitPriceCash("$150.00", "$120.00"));
|
||||||
|
try std.testing.expect(!isUnitPriceCash("$0.99", "$1.00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "isUnitPriceCash: unparseable inputs return false" {
|
||||||
|
try std.testing.expect(!isUnitPriceCash("", "$1.00"));
|
||||||
|
try std.testing.expect(!isUnitPriceCash("$1.00", ""));
|
||||||
|
try std.testing.expect(!isUnitPriceCash("N/A", "$1.00"));
|
||||||
|
}
|
||||||
|
|
@ -7,529 +7,21 @@ const Money = @import("../Money.zig");
|
||||||
const analysis = @import("../analytics/analysis.zig");
|
const analysis = @import("../analytics/analysis.zig");
|
||||||
const portfolio_mod = @import("../models/portfolio.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
const contributions = @import("contributions.zig");
|
const contributions = @import("contributions.zig");
|
||||||
|
const brokerage_types = @import("../brokerage/types.zig");
|
||||||
// ── Brokerage position (normalized from any source) ─────────
|
const fidelity = @import("../brokerage/fidelity.zig");
|
||||||
|
const schwab = @import("../brokerage/schwab.zig");
|
||||||
/// A single position row from a brokerage export, normalized to a common format.
|
|
||||||
pub const BrokeragePosition = struct {
|
|
||||||
account_number: []const u8,
|
|
||||||
account_name: []const u8,
|
|
||||||
symbol: []const u8,
|
|
||||||
description: []const u8,
|
|
||||||
quantity: ?f64,
|
|
||||||
current_value: ?f64,
|
|
||||||
cost_basis: ?f64,
|
|
||||||
is_cash: bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Parsed brokerage data grouped by account.
|
|
||||||
pub const BrokerageAccount = struct {
|
|
||||||
account_number: []const u8,
|
|
||||||
account_name: []const u8,
|
|
||||||
portfolio_account: ?[]const u8,
|
|
||||||
positions: []const BrokeragePosition,
|
|
||||||
total_value: f64,
|
|
||||||
cash_value: f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Fidelity CSV parser ─────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Limitations of this CSV parser:
|
|
||||||
//
|
|
||||||
// 1. NOT a general-purpose CSV parser. It handles the specific format
|
|
||||||
// exported by Fidelity's "Download Positions" feature.
|
|
||||||
//
|
|
||||||
// 2. Does NOT handle quoted fields containing commas. Fidelity's export
|
|
||||||
// does not quote fields with commas in practice (description fields
|
|
||||||
// use spaces, not commas), but a truly compliant RFC 4180 parser would
|
|
||||||
// need to handle "field,with,commas" as a single value.
|
|
||||||
//
|
|
||||||
// 3. Does NOT handle escaped quotes ("" inside quoted fields).
|
|
||||||
//
|
|
||||||
// 4. Does NOT handle multi-line values (newlines inside quoted fields).
|
|
||||||
//
|
|
||||||
// 5. Assumes UTF-8 with optional BOM (which Fidelity includes).
|
|
||||||
//
|
|
||||||
// 6. Stops parsing at the first blank line, which separates position data
|
|
||||||
// from the Fidelity legal disclaimer footer.
|
|
||||||
//
|
|
||||||
// 7. Hardcodes the expected column layout. If Fidelity changes the CSV
|
|
||||||
// format (adds/removes/reorders columns), this parser will break.
|
|
||||||
// The header row is validated to catch this.
|
|
||||||
//
|
|
||||||
// 8. Dollar values like "$1,234.56" and "+$1,234.56" are stripped of
|
|
||||||
// $, commas, and leading +/- signs. Negative values wrapped in
|
|
||||||
// parentheses are NOT handled (Fidelity uses -$X.XX format).
|
|
||||||
//
|
|
||||||
// 9. Money market rows (symbol ending in **) are treated as cash.
|
|
||||||
//
|
|
||||||
// For a production-grade CSV parser, consider a library that handles
|
|
||||||
// RFC 4180 fully (quoted fields, escaping, multi-line values).
|
|
||||||
|
|
||||||
const fidelity_expected_columns = 16;
|
|
||||||
|
|
||||||
/// Column indices in the Fidelity CSV export.
|
|
||||||
/// Based on: 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
|
|
||||||
const FidelityCol = struct {
|
|
||||||
const account_number = 0;
|
|
||||||
const account_name = 1;
|
|
||||||
const symbol = 2;
|
|
||||||
const description = 3;
|
|
||||||
const quantity = 4;
|
|
||||||
const last_price = 5;
|
|
||||||
const current_value = 7;
|
|
||||||
const cost_basis_total = 13;
|
|
||||||
const avg_cost_basis = 14;
|
|
||||||
const type_col = 15;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Parse a Fidelity CSV positions export into BrokeragePosition slices.
|
|
||||||
/// All string fields in the returned positions are slices into `data`,
|
|
||||||
/// so the caller must keep `data` alive for as long as the positions are used.
|
|
||||||
/// Only the returned slice itself is heap-allocated (caller must free it).
|
|
||||||
pub fn parseFidelityCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosition {
|
|
||||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
|
||||||
errdefer positions.deinit(allocator);
|
|
||||||
|
|
||||||
// Skip UTF-8 BOM if present
|
|
||||||
var content = data;
|
|
||||||
if (content.len >= 3 and content[0] == 0xEF and content[1] == 0xBB and content[2] == 0xBF) {
|
|
||||||
content = content[3..];
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = std.mem.splitScalar(u8, content, '\n');
|
|
||||||
|
|
||||||
// Validate header row
|
|
||||||
const header_line = lines.next() orelse return error.EmptyFile;
|
|
||||||
const header_trimmed = std.mem.trimEnd(u8, header_line, &.{ '\r', ' ' });
|
|
||||||
if (header_trimmed.len == 0) return error.EmptyFile;
|
|
||||||
if (!std.mem.startsWith(u8, header_trimmed, "Account Number")) {
|
|
||||||
return error.UnexpectedHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse data rows
|
|
||||||
while (lines.next()) |line| {
|
|
||||||
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
|
||||||
if (trimmed.len == 0) break;
|
|
||||||
|
|
||||||
// Skip lines starting with " (disclaimer text)
|
|
||||||
if (trimmed[0] == '"') break;
|
|
||||||
|
|
||||||
var col_iter = std.mem.splitScalar(u8, trimmed, ',');
|
|
||||||
var cols: [fidelity_expected_columns][]const u8 = undefined;
|
|
||||||
var col_count: usize = 0;
|
|
||||||
while (col_iter.next()) |col| {
|
|
||||||
if (col_count < fidelity_expected_columns) {
|
|
||||||
cols[col_count] = col;
|
|
||||||
col_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (col_count < fidelity_expected_columns) continue;
|
|
||||||
|
|
||||||
const symbol_raw = std.mem.trim(u8, cols[FidelityCol.symbol], &.{ ' ', '"' });
|
|
||||||
if (symbol_raw.len == 0) continue;
|
|
||||||
|
|
||||||
// Strip ** suffix from money market symbols for display
|
|
||||||
const symbol_clean = if (std.mem.endsWith(u8, symbol_raw, "**"))
|
|
||||||
symbol_raw[0 .. symbol_raw.len - 2]
|
|
||||||
else
|
|
||||||
symbol_raw;
|
|
||||||
|
|
||||||
// Classify as cash if any of:
|
|
||||||
// - Fidelity's ** suffix marks a money-market position
|
|
||||||
// - The symbol appears in zfin's canonical money-market list
|
|
||||||
// (e.g. FDRXX, SPAXX — Fidelity omits ** for some of these)
|
|
||||||
// - price and cost both equal exactly $1.00, the catch-all for
|
|
||||||
// fixed-NAV instruments that we don't have in the list yet.
|
|
||||||
const is_cash = std.mem.endsWith(u8, symbol_raw, "**") or
|
|
||||||
portfolio_mod.isMoneyMarketSymbol(symbol_clean) or
|
|
||||||
isUnitPriceCash(cols[FidelityCol.last_price], cols[FidelityCol.avg_cost_basis]);
|
|
||||||
|
|
||||||
try positions.append(allocator, .{
|
|
||||||
.account_number = std.mem.trim(u8, cols[FidelityCol.account_number], &.{ ' ', '"' }),
|
|
||||||
.account_name = std.mem.trim(u8, cols[FidelityCol.account_name], &.{ ' ', '"' }),
|
|
||||||
.symbol = symbol_clean,
|
|
||||||
.description = std.mem.trim(u8, cols[FidelityCol.description], &.{ ' ', '"' }),
|
|
||||||
.quantity = if (is_cash) null else parseDollarAmount(cols[FidelityCol.quantity]),
|
|
||||||
.current_value = parseDollarAmount(cols[FidelityCol.current_value]),
|
|
||||||
.cost_basis = if (is_cash) null else parseDollarAmount(cols[FidelityCol.cost_basis_total]),
|
|
||||||
.is_cash = is_cash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return positions.toOwnedSlice(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a dollar amount string like "$1,234.56", "+$3,732.40", "-$6,300.00".
|
|
||||||
/// Strips $, commas, and +/- prefix. Returns null for empty or unparseable values.
|
|
||||||
fn parseDollarAmount(raw: []const u8) ?f64 {
|
|
||||||
const trimmed = std.mem.trim(u8, raw, &.{ ' ', '"' });
|
|
||||||
if (trimmed.len == 0) return null;
|
|
||||||
|
|
||||||
// Strip leading +/- and $, remove commas
|
|
||||||
var buf: [64]u8 = undefined;
|
|
||||||
var pos: usize = 0;
|
|
||||||
var negative = false;
|
|
||||||
for (trimmed) |c| {
|
|
||||||
if (c == '-') {
|
|
||||||
negative = true;
|
|
||||||
} else if (c == '$' or c == '+' or c == ',') {
|
|
||||||
continue;
|
|
||||||
} else {
|
|
||||||
if (pos >= buf.len) return null;
|
|
||||||
buf[pos] = c;
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pos == 0) return null;
|
|
||||||
|
|
||||||
const val = std.fmt.parseFloat(f64, buf[0..pos]) catch return null;
|
|
||||||
return if (negative) -val else val;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true when both the last price and average cost basis parse to exactly $1.00,
|
|
||||||
/// indicating a money-market or cash-equivalent position (e.g. FDRXX).
|
|
||||||
fn isUnitPriceCash(price_raw: []const u8, cost_raw: []const u8) bool {
|
|
||||||
const price = parseDollarAmount(price_raw) orelse return false;
|
|
||||||
const cost = parseDollarAmount(cost_raw) orelse return false;
|
|
||||||
return price == 1.0 and cost == 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Date = @import("../Date.zig");
|
const Date = @import("../Date.zig");
|
||||||
|
|
||||||
/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a
|
// Local aliases used inside this file. The brokerage parsers and
|
||||||
/// portfolio lot by comparing parsed components against the lot's structured
|
// their record types live under `src/brokerage/`; this file is the
|
||||||
/// fields (underlying, maturity_date, option_type, strike).
|
// reconciler that compares those records against the portfolio.
|
||||||
///
|
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
||||||
/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}
|
const SchwabAccountSummary = schwab.AccountSummary;
|
||||||
/// The underlying length is variable, so we scan for the first position
|
const parseFidelityCsv = fidelity.parseCsv;
|
||||||
/// where 6 consecutive digits encode a valid date.
|
const parseSchwabCsv = schwab.parseCsv;
|
||||||
fn fidelityOptionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool {
|
const parseSchwabSummary = schwab.parseSummary;
|
||||||
if (lot.security_type != .option) return false;
|
const fidelityOptionMatchesLot = fidelity.optionMatchesLot;
|
||||||
|
|
||||||
// Strip leading dash (short indicator)
|
|
||||||
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
|
|
||||||
|
|
||||||
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
|
|
||||||
if (sym.len < 9) return false;
|
|
||||||
|
|
||||||
// Scan for the date boundary: first position where 6 consecutive digits
|
|
||||||
// form a valid YYMMDD (and the character before is a letter).
|
|
||||||
var i: usize = 1; // underlying is at least 1 char
|
|
||||||
while (i + 7 < sym.len) : (i += 1) {
|
|
||||||
// All 6 chars must be digits
|
|
||||||
if (!std.ascii.isDigit(sym[i]) or
|
|
||||||
!std.ascii.isDigit(sym[i + 1]) or
|
|
||||||
!std.ascii.isDigit(sym[i + 2]) or
|
|
||||||
!std.ascii.isDigit(sym[i + 3]) or
|
|
||||||
!std.ascii.isDigit(sym[i + 4]) or
|
|
||||||
!std.ascii.isDigit(sym[i + 5]))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Character after the 6 digits must be C or P
|
|
||||||
const type_char = sym[i + 6];
|
|
||||||
if (type_char != 'C' and type_char != 'P') continue;
|
|
||||||
|
|
||||||
// Parse date components
|
|
||||||
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
|
|
||||||
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
|
|
||||||
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
|
|
||||||
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
|
|
||||||
const year = 2000 + yy;
|
|
||||||
|
|
||||||
// Parse components
|
|
||||||
const underlying = sym[0..i];
|
|
||||||
const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call;
|
|
||||||
const strike_str = sym[i + 7 ..];
|
|
||||||
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
|
|
||||||
const date = Date.fromYmd(year, mm, dd);
|
|
||||||
|
|
||||||
// Match against lot fields
|
|
||||||
const lot_underlying = lot.underlying orelse return false;
|
|
||||||
const lot_maturity = lot.maturity_date orelse return false;
|
|
||||||
|
|
||||||
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
|
|
||||||
if (!lot_maturity.eql(date)) return false;
|
|
||||||
if (option_type != lot.option_type) return false;
|
|
||||||
if (lot.strike) |ls| {
|
|
||||||
if (@abs(ls - strike) > 0.01) return false;
|
|
||||||
} else return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Schwab CSV parser ───────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Parses the per-account positions CSV exported from Schwab's website
|
|
||||||
// (Accounts → Positions → Export for a single account).
|
|
||||||
//
|
|
||||||
// Limitations:
|
|
||||||
//
|
|
||||||
// 1. NOT a general-purpose CSV parser. Handles Schwab's specific export
|
|
||||||
// format where every field is double-quoted.
|
|
||||||
//
|
|
||||||
// 2. Handles simple quoted fields ("value") but does NOT handle escaped
|
|
||||||
// quotes ("value with ""quotes"" inside") or multi-line quoted fields.
|
|
||||||
// Schwab's export does not use these in practice.
|
|
||||||
//
|
|
||||||
// 3. The account number and name are extracted from the title line:
|
|
||||||
// "Positions for account <NAME> ...<NUM> as of ..."
|
|
||||||
//
|
|
||||||
// 4. Rows with symbol "Cash & Cash Investments" are treated as cash.
|
|
||||||
// The row with symbol "Positions Total" is skipped.
|
|
||||||
//
|
|
||||||
// 5. Hardcodes the expected column layout. If Schwab changes the CSV
|
|
||||||
// format, this parser will break. The header row is not validated
|
|
||||||
// beyond being skipped.
|
|
||||||
|
|
||||||
const schwab_expected_columns = 17;
|
|
||||||
|
|
||||||
const SchwabCol = struct {
|
|
||||||
const symbol = 0;
|
|
||||||
const price = 4;
|
|
||||||
const quantity = 5;
|
|
||||||
const market_value = 8;
|
|
||||||
const cost_basis = 9;
|
|
||||||
const asset_type = 16;
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Split a Schwab CSV line on commas, stripping surrounding quotes from each field.
|
|
||||||
/// Returns the number of columns parsed. Fields are slices into the input line.
|
|
||||||
fn splitSchwabCsvLine(line: []const u8, cols: *[schwab_expected_columns][]const u8) usize {
|
|
||||||
var col_count: usize = 0;
|
|
||||||
var pos: usize = 0;
|
|
||||||
while (pos < line.len and col_count < schwab_expected_columns) {
|
|
||||||
if (line[pos] == '"') {
|
|
||||||
// Quoted field: find closing quote
|
|
||||||
const start = pos + 1;
|
|
||||||
pos = start;
|
|
||||||
while (pos < line.len and line[pos] != '"') : (pos += 1) {}
|
|
||||||
cols[col_count] = line[start..pos];
|
|
||||||
col_count += 1;
|
|
||||||
if (pos < line.len) pos += 1; // skip closing quote
|
|
||||||
if (pos < line.len and line[pos] == ',') pos += 1; // skip comma
|
|
||||||
} else if (line[pos] == ',') {
|
|
||||||
cols[col_count] = "";
|
|
||||||
col_count += 1;
|
|
||||||
pos += 1;
|
|
||||||
} else {
|
|
||||||
// Unquoted field
|
|
||||||
const start = pos;
|
|
||||||
while (pos < line.len and line[pos] != ',') : (pos += 1) {}
|
|
||||||
cols[col_count] = line[start..pos];
|
|
||||||
col_count += 1;
|
|
||||||
if (pos < line.len) pos += 1; // skip comma
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return col_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract account name and number from Schwab title line.
|
|
||||||
/// Format: "Positions for account <NAME> ...<NUM> as of <TIME>, <DATE>"
|
|
||||||
/// Returns {name, number} or null if the line doesn't match.
|
|
||||||
fn parseSchwabTitle(line: []const u8) ?struct { name: []const u8, number: []const u8 } {
|
|
||||||
const stripped = std.mem.trim(u8, line, &.{ '"', ' ', '\r' });
|
|
||||||
const prefix = "Positions for account ";
|
|
||||||
if (!std.mem.startsWith(u8, stripped, prefix)) return null;
|
|
||||||
const rest = stripped[prefix.len..];
|
|
||||||
|
|
||||||
// Find "..." which separates name from account number
|
|
||||||
const dots_idx = std.mem.indexOf(u8, rest, "...") orelse return null;
|
|
||||||
const name = std.mem.trimEnd(u8, rest[0..dots_idx], &.{' '});
|
|
||||||
|
|
||||||
// Account number: after "..." until " as of" or end
|
|
||||||
const after_dots = rest[dots_idx + 3 ..];
|
|
||||||
const as_of_idx = std.mem.indexOf(u8, after_dots, " as of") orelse after_dots.len;
|
|
||||||
const number = std.mem.trim(u8, after_dots[0..as_of_idx], &.{' '});
|
|
||||||
|
|
||||||
return .{ .name = name, .number = number };
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parseSchwabCsv(allocator: std.mem.Allocator, data: []const u8) !struct { positions: []BrokeragePosition, account_name: []const u8, account_number: []const u8 } {
|
|
||||||
var positions = std.ArrayList(BrokeragePosition).empty;
|
|
||||||
errdefer positions.deinit(allocator);
|
|
||||||
|
|
||||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
|
||||||
|
|
||||||
// Line 1: title with account name and number
|
|
||||||
const title_line = lines.next() orelse return error.EmptyFile;
|
|
||||||
const title = parseSchwabTitle(title_line) orelse return error.UnexpectedHeader;
|
|
||||||
|
|
||||||
// Line 2: blank (skip)
|
|
||||||
_ = lines.next();
|
|
||||||
// Line 3: header row (skip)
|
|
||||||
_ = lines.next();
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
while (lines.next()) |line| {
|
|
||||||
const trimmed = std.mem.trimEnd(u8, line, &.{ '\r', ' ' });
|
|
||||||
if (trimmed.len == 0) continue;
|
|
||||||
|
|
||||||
var cols: [schwab_expected_columns][]const u8 = undefined;
|
|
||||||
const col_count = splitSchwabCsvLine(trimmed, &cols);
|
|
||||||
if (col_count < schwab_expected_columns) continue;
|
|
||||||
|
|
||||||
const symbol = cols[SchwabCol.symbol];
|
|
||||||
if (symbol.len == 0) continue;
|
|
||||||
if (std.mem.eql(u8, symbol, "Positions Total")) continue;
|
|
||||||
|
|
||||||
// "Cash & Cash Investments" is Schwab's aggregate cash line.
|
|
||||||
// Actual money-market holdings (SWVXX, etc.) appear as normal rows
|
|
||||||
// with their real ticker and price — treat those as cash too so
|
|
||||||
// the reconciliation matches what brokerage users think of as
|
|
||||||
// "cash" in the account.
|
|
||||||
const is_cash = std.mem.eql(u8, symbol, "Cash & Cash Investments") or
|
|
||||||
portfolio_mod.isMoneyMarketSymbol(symbol);
|
|
||||||
|
|
||||||
try positions.append(allocator, .{
|
|
||||||
.account_number = title.number,
|
|
||||||
.account_name = title.name,
|
|
||||||
.symbol = symbol,
|
|
||||||
.description = if (col_count > 1) cols[1] else "",
|
|
||||||
.quantity = if (is_cash) null else parseDollarAmount(cols[SchwabCol.quantity]),
|
|
||||||
.current_value = parseDollarAmount(cols[SchwabCol.market_value]),
|
|
||||||
.cost_basis = if (is_cash) null else parseDollarAmount(cols[SchwabCol.cost_basis]),
|
|
||||||
.is_cash = is_cash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return .{
|
|
||||||
.positions = try positions.toOwnedSlice(allocator),
|
|
||||||
.account_name = title.name,
|
|
||||||
.account_number = title.number,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Schwab summary parser ───────────────────────────────────
|
|
||||||
//
|
|
||||||
// Parses the account summary paste from Schwab's web interface.
|
|
||||||
// The expected format is repeating blocks of 2-3 lines per account:
|
|
||||||
//
|
|
||||||
// Account Name
|
|
||||||
// Account number ending in NNN ...NNN
|
|
||||||
// Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
|
||||||
//
|
|
||||||
// Limitations:
|
|
||||||
//
|
|
||||||
// 1. NOT a CSV parser — parses freeform text pasted from the Schwab UI.
|
|
||||||
//
|
|
||||||
// 2. Identifies account blocks by the "Account number ending in" line.
|
|
||||||
// The account name is the non-empty line immediately before it.
|
|
||||||
//
|
|
||||||
// 3. The values line (cash, total, change, pct) is identified by finding
|
|
||||||
// dollar amounts. It tolerates missing or extra fields — it looks for
|
|
||||||
// the first two dollar amounts as cash and total value.
|
|
||||||
//
|
|
||||||
// 4. Skips summary lines like "Investment Total", "Day Change Total",
|
|
||||||
// and "Day Change Percent Total" which appear at the end of the paste.
|
|
||||||
//
|
|
||||||
// 5. Tolerant of partial pastes: if the user copies headers once but
|
|
||||||
// not on subsequent pastes, or includes extra blank lines, the parser
|
|
||||||
// still finds account blocks by the "Account number ending in" anchor.
|
|
||||||
//
|
|
||||||
// 6. The account number is extracted from "...NNN" at the end of the
|
|
||||||
// account number line (the last whitespace-separated token).
|
|
||||||
|
|
||||||
/// Account-level summary from a Schwab paste (no per-position detail).
|
|
||||||
pub const SchwabAccountSummary = struct {
|
|
||||||
account_name: []const u8,
|
|
||||||
account_number: []const u8,
|
|
||||||
cash: ?f64,
|
|
||||||
total_value: ?f64,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Parse Schwab account summary from pasted text.
|
|
||||||
/// All string fields in the returned summaries are slices into `data`.
|
|
||||||
/// Only the returned slice itself is heap-allocated (caller must free it).
|
|
||||||
pub fn parseSchwabSummary(allocator: std.mem.Allocator, data: []const u8) ![]SchwabAccountSummary {
|
|
||||||
var accounts = std.ArrayList(SchwabAccountSummary).empty;
|
|
||||||
errdefer accounts.deinit(allocator);
|
|
||||||
|
|
||||||
// Collect all lines, trimmed
|
|
||||||
var all_lines = std.ArrayList([]const u8).empty;
|
|
||||||
defer all_lines.deinit(allocator);
|
|
||||||
|
|
||||||
var line_iter = std.mem.splitScalar(u8, data, '\n');
|
|
||||||
while (line_iter.next()) |line| {
|
|
||||||
const trimmed = std.mem.trim(u8, line, &.{ '\r', ' ', '\t' });
|
|
||||||
try all_lines.append(allocator, trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = all_lines.items;
|
|
||||||
|
|
||||||
// Scan for "Account number ending in" anchors
|
|
||||||
for (lines, 0..) |line, i| {
|
|
||||||
if (!std.mem.startsWith(u8, line, "Account number ending in")) continue;
|
|
||||||
|
|
||||||
// Extract account number: last token on the line (e.g. "...5678" -> "5678")
|
|
||||||
var acct_num: []const u8 = "";
|
|
||||||
var tok_iter = std.mem.tokenizeAny(u8, line, &.{ ' ', '\t' });
|
|
||||||
while (tok_iter.next()) |tok| {
|
|
||||||
acct_num = tok;
|
|
||||||
}
|
|
||||||
// Strip leading dots
|
|
||||||
while (acct_num.len > 0 and acct_num[0] == '.') {
|
|
||||||
acct_num = acct_num[1..];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account name: nearest non-empty line before the anchor
|
|
||||||
var acct_name: []const u8 = "";
|
|
||||||
if (i > 0) {
|
|
||||||
var j: usize = i - 1;
|
|
||||||
while (true) {
|
|
||||||
if (lines[j].len > 0 and
|
|
||||||
!std.mem.startsWith(u8, lines[j], "Account number") and
|
|
||||||
!std.mem.startsWith(u8, lines[j], "Investment Total") and
|
|
||||||
!std.mem.startsWith(u8, lines[j], "Day Change"))
|
|
||||||
{
|
|
||||||
acct_name = lines[j];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (j == 0) break;
|
|
||||||
j -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Values line: look at lines after the anchor for dollar amounts.
|
|
||||||
// The format is "Type XXX $CASH $TOTAL +$CHANGE +PCT%"
|
|
||||||
// We want the first two dollar amounts (cash and total).
|
|
||||||
var cash: ?f64 = null;
|
|
||||||
var total: ?f64 = null;
|
|
||||||
if (i + 1 < lines.len) {
|
|
||||||
var dollar_values = std.ArrayList(f64).empty;
|
|
||||||
defer dollar_values.deinit(allocator);
|
|
||||||
|
|
||||||
var val_iter = std.mem.tokenizeAny(u8, lines[i + 1], &.{ ' ', '\t' });
|
|
||||||
while (val_iter.next()) |tok| {
|
|
||||||
if (parseDollarAmount(tok)) |v| {
|
|
||||||
dollar_values.append(allocator, v) catch {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (dollar_values.items.len >= 2) {
|
|
||||||
cash = dollar_values.items[0];
|
|
||||||
total = dollar_values.items[1];
|
|
||||||
} else if (dollar_values.items.len == 1) {
|
|
||||||
total = dollar_values.items[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try accounts.append(allocator, .{
|
|
||||||
.account_name = acct_name,
|
|
||||||
.account_number = acct_num,
|
|
||||||
.cash = cash,
|
|
||||||
.total_value = total,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accounts.items.len == 0) return error.NoAccountsFound;
|
|
||||||
|
|
||||||
return accounts.toOwnedSlice(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Account-level comparison result for Schwab summary audit.
|
/// Account-level comparison result for Schwab summary audit.
|
||||||
pub const SchwabAccountComparison = struct {
|
pub const SchwabAccountComparison = struct {
|
||||||
|
|
@ -2509,121 +2001,6 @@ test "parseArgs: --stale-days parses integer" {
|
||||||
try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
|
try std.testing.expectEqual(@as(u32, 5), parsed.stale_days);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseDollarAmount" {
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1234.56), parseDollarAmount("$1,234.56").?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 7140.33), parseDollarAmount("$7140.33").?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 3732.40), parseDollarAmount("+$3,732.40").?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), parseDollarAmount("-$6,300.00").?, 0.01);
|
|
||||||
try std.testing.expect(parseDollarAmount("") == null);
|
|
||||||
try std.testing.expect(parseDollarAmount(" ") == null);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 301), parseDollarAmount("301").?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 2387.616), parseDollarAmount("2387.616").?, 0.001);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv basic" {
|
|
||||||
const csv =
|
|
||||||
"\xEF\xBB\xBF" ++ // BOM
|
|
||||||
"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\r\n" ++
|
|
||||||
"Z123,Individual,FZFXX**,HELD IN MONEY MARKET,,,,$5000.00,,,,,50%,,,Cash,\r\n" ++
|
|
||||||
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,50%,$10000.00,$100.00,Margin,\r\n" ++
|
|
||||||
"\r\n" ++
|
|
||||||
"\"Disclaimer text\"\r\n";
|
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const positions = try parseFidelityCsv(allocator, csv);
|
|
||||||
defer allocator.free(positions);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), positions.len);
|
|
||||||
|
|
||||||
// Cash position
|
|
||||||
try std.testing.expectEqualStrings("FZFXX", positions[0].symbol);
|
|
||||||
try std.testing.expect(positions[0].is_cash);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 5000.00), positions[0].current_value.?, 0.01);
|
|
||||||
try std.testing.expect(positions[0].quantity == null);
|
|
||||||
|
|
||||||
// Stock position
|
|
||||||
try std.testing.expectEqualStrings("AAPL", positions[1].symbol);
|
|
||||||
try std.testing.expect(!positions[1].is_cash);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 100), positions[1].quantity.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 15000.00), positions[1].current_value.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 10000.00), positions[1].cost_basis.?, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv treats $1.00 price+cost as cash" {
|
|
||||||
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,FDRXX,FID GOV CASH RESERVE,8500,$1.00,,$8500.00,,,,,10%,$8500.00,$1.00,Cash,\n";
|
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const positions = try parseFidelityCsv(allocator, csv);
|
|
||||||
defer allocator.free(positions);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
|
||||||
try std.testing.expectEqualStrings("FDRXX", positions[0].symbol);
|
|
||||||
try std.testing.expect(positions[0].is_cash);
|
|
||||||
try std.testing.expect(positions[0].quantity == null);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 8500.00), positions[0].current_value.?, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv stops at blank line" {
|
|
||||||
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,50,$150.00,+$2.00,$7500.00,+$100.00,+1.35%,+$2500.00,+50.00%,100%,$5000.00,$100.00,Margin,\n" ++
|
|
||||||
"\n" ++
|
|
||||||
"Z123,Individual,MSFT,SHOULD NOT APPEAR,10,$300.00,,,$3000.00,,,,,,,,\n";
|
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const positions = try parseFidelityCsv(allocator, csv);
|
|
||||||
defer allocator.free(positions);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
|
||||||
try std.testing.expectEqualStrings("AAPL", positions[0].symbol);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv option row" {
|
|
||||||
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, -AMZN260515C220,AMZN MAY 15 2026 $220 CALL,-3,$21.00,+$8.60,-$6300.00,-$2580.00,-69.36%,-$3674.02,-139.92%,-8.85%,$2625.98,$8.75,Margin,\n";
|
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const positions = try parseFidelityCsv(allocator, csv);
|
|
||||||
defer allocator.free(positions);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
|
||||||
try std.testing.expectEqualStrings("-AMZN260515C220", positions[0].symbol);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, -3), positions[0].quantity.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, -6300.00), positions[0].current_value.?, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv empty file" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const result = parseFidelityCsv(allocator, "");
|
|
||||||
try std.testing.expectError(error.EmptyFile, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv wrong header" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const result = parseFidelityCsv(allocator, "Wrong,Header,Format\n");
|
|
||||||
try std.testing.expectError(error.UnexpectedHeader, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseFidelityCsv cash account type is not cash position" {
|
|
||||||
// Fidelity's Type column says "Cash" for cash-account positions (vs "Margin").
|
|
||||||
// This does NOT mean the security is a cash holding — only ** suffix means that.
|
|
||||||
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" ++
|
|
||||||
"X99,HSA,QTUM,DEFIANCE QUANTUM ETF,190,$116.14,+$0.31,$22066.60,+$58.90,+0.26%,+$1185.60,+5.67%,99.64%,$20881.00,$109.90,Cash,\n";
|
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const positions = try parseFidelityCsv(allocator, csv);
|
|
||||||
defer allocator.free(positions);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
|
||||||
try std.testing.expectEqualStrings("QTUM", positions[0].symbol);
|
|
||||||
try std.testing.expect(!positions[0].is_cash);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "consolidateBySymbol: distinct symbols pass through unchanged" {
|
test "consolidateBySymbol: distinct symbols pass through unchanged" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
const rows = [_]BrokeragePosition{
|
const rows = [_]BrokeragePosition{
|
||||||
|
|
@ -2690,140 +2067,6 @@ test "consolidateBySymbol: empty input returns empty" {
|
||||||
try std.testing.expectEqual(@as(usize, 0), out.items.len);
|
try std.testing.expectEqual(@as(usize, 0), out.items.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parseSchwabSummary basic" {
|
|
||||||
const data =
|
|
||||||
\\Emil Roth
|
|
||||||
\\Account number ending in 5678 ...5678
|
|
||||||
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
|
||||||
\\Sample Inherited IRA
|
|
||||||
\\Account number ending in 9012 ...9012
|
|
||||||
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
|
||||||
;
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const accounts = try parseSchwabSummary(allocator, data);
|
|
||||||
defer allocator.free(accounts);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), accounts.len);
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("Emil Roth", accounts[0].account_name);
|
|
||||||
try std.testing.expectEqualStrings("5678", accounts[0].account_number);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 46.44), accounts[0].cash.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 227058.15), accounts[0].total_value.?, 0.01);
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("Sample Inherited IRA", accounts[1].account_name);
|
|
||||||
try std.testing.expectEqualStrings("9012", accounts[1].account_number);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 2461.82), accounts[1].cash.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 167544.08), accounts[1].total_value.?, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseSchwabSummary tolerates missing headers and extra blank lines" {
|
|
||||||
const data =
|
|
||||||
\\
|
|
||||||
\\Joint Account
|
|
||||||
\\Account number ending in 1234 ...1234
|
|
||||||
\\Type Brokerage $8,271.12 $849,087.12 +$20,488.80 +2.47%
|
|
||||||
\\
|
|
||||||
\\Tax Loss
|
|
||||||
\\Account number ending in 2345 ...2345
|
|
||||||
\\$4,654.15 $488,481.18 +$1,686.91 +0.35%
|
|
||||||
;
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const accounts = try parseSchwabSummary(allocator, data);
|
|
||||||
defer allocator.free(accounts);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), accounts.len);
|
|
||||||
try std.testing.expectEqualStrings("Joint Account", accounts[0].account_name);
|
|
||||||
try std.testing.expectEqualStrings("1234", accounts[0].account_number);
|
|
||||||
|
|
||||||
// Second account has no "Type" prefix — parser still finds dollar amounts
|
|
||||||
try std.testing.expectEqualStrings("Tax Loss", accounts[1].account_name);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 4654.15), accounts[1].cash.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 488481.18), accounts[1].total_value.?, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseSchwabSummary skips summary footer" {
|
|
||||||
const data =
|
|
||||||
\\Mom
|
|
||||||
\\Account number ending in 152 ...152
|
|
||||||
\\Type Brokerage $3,492.85 $161,676.14 +$749.40 +0.47%
|
|
||||||
\\Investment Total
|
|
||||||
\\$22,070.35
|
|
||||||
\\$4,338,116.38
|
|
||||||
\\Day Change Total
|
|
||||||
\\+$31,633.86
|
|
||||||
;
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const accounts = try parseSchwabSummary(allocator, data);
|
|
||||||
defer allocator.free(accounts);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), accounts.len);
|
|
||||||
try std.testing.expectEqualStrings("Mom", accounts[0].account_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseSchwabSummary no accounts" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const result = parseSchwabSummary(allocator, "some random text\nno accounts here\n");
|
|
||||||
try std.testing.expectError(error.NoAccountsFound, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseSchwabTitle" {
|
|
||||||
const t1 = parseSchwabTitle("\"Positions for account Joint Account ...1234 as of 10:47 AM ET, 2026/04/10\"");
|
|
||||||
try std.testing.expect(t1 != null);
|
|
||||||
try std.testing.expectEqualStrings("Joint Account", t1.?.name);
|
|
||||||
try std.testing.expectEqualStrings("1234", t1.?.number);
|
|
||||||
|
|
||||||
const t2 = parseSchwabTitle("\"Positions for account Emil IRA ...7890 as of 3:00 PM ET, 2026/04/10\"");
|
|
||||||
try std.testing.expect(t2 != null);
|
|
||||||
try std.testing.expectEqualStrings("Emil IRA", t2.?.name);
|
|
||||||
try std.testing.expectEqualStrings("7890", t2.?.number);
|
|
||||||
|
|
||||||
try std.testing.expect(parseSchwabTitle("some random text") == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "splitSchwabCsvLine" {
|
|
||||||
var cols: [schwab_expected_columns][]const u8 = undefined;
|
|
||||||
|
|
||||||
const n = splitSchwabCsvLine("\"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\",", &cols);
|
|
||||||
try std.testing.expectEqual(@as(usize, 17), n);
|
|
||||||
try std.testing.expectEqualStrings("AMZN", cols[0]);
|
|
||||||
try std.testing.expectEqualStrings("AMAZON.COM INC", cols[1]);
|
|
||||||
try std.testing.expectEqualStrings("1,488", cols[5]);
|
|
||||||
try std.testing.expectEqualStrings("$355,941.50", cols[8]);
|
|
||||||
try std.testing.expectEqualStrings("Equity", cols[16]);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "parseSchwabCsv basic" {
|
|
||||||
const csv =
|
|
||||||
"\"Positions for account Joint Account ...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" ++
|
|
||||||
"\"Cash & Cash Investments\",\"--\",\"--\",\"--\",\"--\",\"--\",\"$0.00\",\"0%\",\"$8,271.12\",\"--\",\"--\",\"--\",\"--\",\"--\",\"--\",\"0.97%\",\"Cash and Money Market\",\n" ++
|
|
||||||
"\"Positions Total\",\"\",\"--\",\"--\",\"--\",\"--\",\"$7,718.87\",\"0.9%\",\"$856,805.99\",\"$348,440.61\",\"$500,094.26\",\"143.52%\",\"--\",\"--\",\"--\",\"--\",\"--\",\n";
|
|
||||||
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const parsed = try parseSchwabCsv(allocator, csv);
|
|
||||||
defer allocator.free(parsed.positions);
|
|
||||||
|
|
||||||
try std.testing.expectEqualStrings("Joint Account", parsed.account_name);
|
|
||||||
try std.testing.expectEqualStrings("1234", parsed.account_number);
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), parsed.positions.len);
|
|
||||||
|
|
||||||
// Stock position
|
|
||||||
try std.testing.expectEqualStrings("AMZN", parsed.positions[0].symbol);
|
|
||||||
try std.testing.expect(!parsed.positions[0].is_cash);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1488), parsed.positions[0].quantity.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 355941.50), parsed.positions[0].current_value.?, 0.01);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 110243.38), parsed.positions[0].cost_basis.?, 0.01);
|
|
||||||
|
|
||||||
// Cash
|
|
||||||
try std.testing.expectEqualStrings("Cash & Cash Investments", parsed.positions[1].symbol);
|
|
||||||
try std.testing.expect(parsed.positions[1].is_cash);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 8271.12), parsed.positions[1].current_value.?, 0.01);
|
|
||||||
try std.testing.expect(parsed.positions[1].quantity == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── resolvePositionValue ──────────────────────────────────────
|
// ── resolvePositionValue ──────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Pins the audit-side price-provenance rule: live-from-cache prices
|
// Pins the audit-side price-provenance rule: live-from-cache prices
|
||||||
|
|
@ -2915,72 +2158,6 @@ test "resolvePositionValue: ratio-1.0 position unaffected by provenance" {
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), miss.value, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), miss.value, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fidelityOptionMatchesLot basic call" {
|
|
||||||
const lot = portfolio_mod.Lot{
|
|
||||||
.symbol = "AMZN 05/15/2026 220.00 C",
|
|
||||||
.security_type = .option,
|
|
||||||
.underlying = "AMZN",
|
|
||||||
.strike = 220.0,
|
|
||||||
.option_type = .call,
|
|
||||||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
|
||||||
.shares = -3,
|
|
||||||
.open_date = Date.fromYmd(2025, 1, 1),
|
|
||||||
.open_price = 8.75,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fidelity format with leading dash (short)
|
|
||||||
try std.testing.expect(fidelityOptionMatchesLot("-AMZN260515C220", lot));
|
|
||||||
// Without dash
|
|
||||||
try std.testing.expect(fidelityOptionMatchesLot("AMZN260515C220", lot));
|
|
||||||
// Wrong underlying
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-MSFT260515C220", lot));
|
|
||||||
// Wrong date
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260615C220", lot));
|
|
||||||
// Wrong type
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515P220", lot));
|
|
||||||
// Wrong strike
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C230", lot));
|
|
||||||
// Non-option lot
|
|
||||||
const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-AMZN260515C220", stock_lot));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "fidelityOptionMatchesLot 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(fidelityOptionMatchesLot("-AAPL260620P220.50", lot));
|
|
||||||
try std.testing.expect(fidelityOptionMatchesLot("AAPL260620P220.50", lot));
|
|
||||||
// Call doesn't match put
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-AAPL260620C220.50", lot));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "fidelityOptionMatchesLot 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(fidelityOptionMatchesLot("-A260320C150", lot));
|
|
||||||
try std.testing.expect(!fidelityOptionMatchesLot("-A260320P150", lot));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "option delta tracking in compareAccounts" {
|
test "option delta tracking in compareAccounts" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
|
|
@ -3405,25 +2582,6 @@ test "printLargeLotWarning: stock destination emits dest_lot::SYM@DATE template"
|
||||||
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
|
try std.testing.expect(std.mem.indexOf(u8, output, "transfer::2026-05-03,type::cash,amount:num:25000,from::<SOURCE>,to::Acct B,dest_lot::SYM@2026-05-03") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "isUnitPriceCash: $1.00 + $1.00 returns true" {
|
|
||||||
try std.testing.expect(isUnitPriceCash("$1.00", "$1.00"));
|
|
||||||
try std.testing.expect(isUnitPriceCash("1.00", "1.00"));
|
|
||||||
try std.testing.expect(isUnitPriceCash("$1", "$1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "isUnitPriceCash: non-$1 price returns false" {
|
|
||||||
try std.testing.expect(!isUnitPriceCash("$1.01", "$1.00"));
|
|
||||||
try std.testing.expect(!isUnitPriceCash("$1.00", "$1.01"));
|
|
||||||
try std.testing.expect(!isUnitPriceCash("$150.00", "$120.00"));
|
|
||||||
try std.testing.expect(!isUnitPriceCash("$0.99", "$1.00"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "isUnitPriceCash: unparseable inputs return false" {
|
|
||||||
try std.testing.expect(!isUnitPriceCash("", "$1.00"));
|
|
||||||
try std.testing.expect(!isUnitPriceCash("$1.00", ""));
|
|
||||||
try std.testing.expect(!isUnitPriceCash("N/A", "$1.00"));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "strLessThan: orders strings lexicographically" {
|
test "strLessThan: orders strings lexicographically" {
|
||||||
try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
|
try std.testing.expect(strLessThan({}, "AAPL", "MSFT"));
|
||||||
try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
|
try std.testing.expect(!strLessThan({}, "MSFT", "AAPL"));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue