split audit by broker
This commit is contained in:
parent
1fa9649bd6
commit
be888069c0
9 changed files with 4397 additions and 3568 deletions
43
TODO.md
43
TODO.md
|
|
@ -160,49 +160,6 @@ settles on. Starting points (grep `\.note` and `note::`):
|
|||
- `transaction_log` transfer `note` (annotation).
|
||||
- audit / contributions matchers (do any key off notes?).
|
||||
|
||||
## Split `audit.zig` into per-broker reconcilers — priority LOW
|
||||
|
||||
`src/commands/audit.zig` is now 2856 lines (was 3438) after the
|
||||
brokerage parsers moved to per-broker files under `src/brokerage/`.
|
||||
It still bundles three logically distinct responsibilities:
|
||||
|
||||
- Portfolio hygiene check (no-flag mode)
|
||||
- Fidelity positions CSV reconciler (`--fidelity`)
|
||||
- Schwab per-account positions CSV reconciler (`--schwab`) and
|
||||
Schwab account-summary stdin reconciler (`--schwab-summary`)
|
||||
|
||||
The brokerage parsers themselves are split per broker:
|
||||
`src/brokerage/types.zig` (shared `BrokeragePosition` +
|
||||
`parseDollarAmount`), `src/brokerage/fidelity.zig` (Fidelity CSV +
|
||||
option-symbol matcher), `src/brokerage/schwab.zig` (per-account
|
||||
CSV + summary paste). Adding a new broker is a one-file add next
|
||||
to those. What's left is splitting the *reconciler*
|
||||
(compare-portfolio-vs-brokerage) and *display* code in audit.zig
|
||||
into per-broker files that consume those parsers.
|
||||
|
||||
### Sketch
|
||||
|
||||
```
|
||||
src/commands/audit/
|
||||
mod.zig ← thin dispatcher; current public `run()` lives here
|
||||
hygiene.zig ← portfolio hygiene check (no-flag mode)
|
||||
fidelity.zig ← --fidelity reconciler (uses brokerage/fidelity.zig)
|
||||
schwab.zig ← --schwab + --schwab-summary reconcilers
|
||||
common.zig ← shared types (Discrepancy, ReconcileResult), formatters
|
||||
```
|
||||
|
||||
The hygiene check can be referenced from `zfin doctor` (above)
|
||||
without pulling in reconciler baggage.
|
||||
|
||||
### Driver
|
||||
|
||||
Maintenance friction. The split makes the audit-bug investigations
|
||||
already in this TODO file (phantom discrepancy on freshly-added
|
||||
lots) easier to localize, and lets a `zfin doctor` command reuse
|
||||
hygiene without inheriting the reconciliation surface.
|
||||
|
||||
Pure internal refactor; no user-visible change.
|
||||
|
||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers — priority LOW
|
||||
|
||||
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
//! Fidelity export parsers.
|
||||
//!
|
||||
//! Parses the CSV produced by Fidelity's "Download Positions" feature,
|
||||
//! plus the structured option-symbol matching used to tie a Fidelity
|
||||
//! option row back to a portfolio lot.
|
||||
//! Parses the CSV produced by Fidelity's "Download Positions" feature.
|
||||
//! (Compact option-symbol matching against a portfolio lot now lives
|
||||
//! in `models/option.zig` as `symbolMatchesLot`, since the audit
|
||||
//! reconciler applies it across brokers, not just Fidelity.)
|
||||
//!
|
||||
//! ## Limitations of this CSV parser
|
||||
//!
|
||||
|
|
@ -37,7 +38,6 @@
|
|||
//! RFC 4180 fully (quoted fields, escaping, multi-line values).
|
||||
|
||||
const std = @import("std");
|
||||
const Date = @import("../Date.zig");
|
||||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const types = @import("types.zig");
|
||||
|
||||
|
|
@ -142,69 +142,6 @@ pub fn parseCsv(allocator: std.mem.Allocator, data: []const u8) ![]BrokeragePosi
|
|||
return positions.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Check if a Fidelity option symbol (e.g. "-AMZN260515C220") matches a
|
||||
/// portfolio lot by comparing parsed components against the lot's structured
|
||||
/// fields (underlying, maturity_date, option_type, strike).
|
||||
///
|
||||
/// Fidelity format: [-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}
|
||||
/// The underlying length is variable, so we scan for the first position
|
||||
/// where 6 consecutive digits encode a valid date.
|
||||
pub fn optionMatchesLot(symbol: []const u8, lot: portfolio_mod.Lot) bool {
|
||||
if (lot.security_type != .option) return false;
|
||||
|
||||
// Strip leading dash (short indicator)
|
||||
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
|
||||
|
||||
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
|
||||
if (sym.len < 9) return false;
|
||||
|
||||
// Scan for the date boundary: first position where 6 consecutive digits
|
||||
// form a valid YYMMDD (and the character before is a letter).
|
||||
var i: usize = 1; // underlying is at least 1 char
|
||||
while (i + 7 < sym.len) : (i += 1) {
|
||||
// All 6 chars must be digits
|
||||
if (!std.ascii.isDigit(sym[i]) or
|
||||
!std.ascii.isDigit(sym[i + 1]) or
|
||||
!std.ascii.isDigit(sym[i + 2]) or
|
||||
!std.ascii.isDigit(sym[i + 3]) or
|
||||
!std.ascii.isDigit(sym[i + 4]) or
|
||||
!std.ascii.isDigit(sym[i + 5]))
|
||||
continue;
|
||||
|
||||
// Character after the 6 digits must be C or P
|
||||
const type_char = sym[i + 6];
|
||||
if (type_char != 'C' and type_char != 'P') continue;
|
||||
|
||||
// Parse date components
|
||||
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
|
||||
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
|
||||
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
|
||||
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
|
||||
const year = 2000 + yy;
|
||||
|
||||
// Parse components
|
||||
const underlying = sym[0..i];
|
||||
const option_type: portfolio_mod.OptionType = if (type_char == 'P') .put else .call;
|
||||
const strike_str = sym[i + 7 ..];
|
||||
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
|
||||
const date = Date.fromYmd(year, mm, dd);
|
||||
|
||||
// Match against lot fields
|
||||
const lot_underlying = lot.underlying orelse return false;
|
||||
const lot_maturity = lot.maturity_date orelse return false;
|
||||
|
||||
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
|
||||
if (!lot_maturity.eql(date)) return false;
|
||||
if (option_type != lot.option_type) return false;
|
||||
if (lot.strike) |ls| {
|
||||
if (@abs(ls - strike) > 0.01) return false;
|
||||
} else return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "parseCsv basic" {
|
||||
|
|
@ -310,69 +247,3 @@ test "parseCsv cash account type is not cash position" {
|
|||
try std.testing.expect(!positions[0].is_cash);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 190), positions[0].quantity.?, 0.01);
|
||||
}
|
||||
|
||||
test "optionMatchesLot basic call" {
|
||||
const lot = portfolio_mod.Lot{
|
||||
.symbol = "AMZN 05/15/2026 220.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AMZN",
|
||||
.strike = 220.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
||||
.shares = -3,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 8.75,
|
||||
};
|
||||
|
||||
// Fidelity format with leading dash (short)
|
||||
try std.testing.expect(optionMatchesLot("-AMZN260515C220", lot));
|
||||
// Without dash
|
||||
try std.testing.expect(optionMatchesLot("AMZN260515C220", lot));
|
||||
// Wrong underlying
|
||||
try std.testing.expect(!optionMatchesLot("-MSFT260515C220", lot));
|
||||
// Wrong date
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260615C220", lot));
|
||||
// Wrong type
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260515P220", lot));
|
||||
// Wrong strike
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260515C230", lot));
|
||||
// Non-option lot
|
||||
const stock_lot = portfolio_mod.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
|
||||
try std.testing.expect(!optionMatchesLot("-AMZN260515C220", stock_lot));
|
||||
}
|
||||
|
||||
test "optionMatchesLot put option and decimal strike" {
|
||||
const lot = portfolio_mod.Lot{
|
||||
.symbol = "AAPL 06/20/2026 220.50 P",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = 220.50,
|
||||
.option_type = .put,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(optionMatchesLot("-AAPL260620P220.50", lot));
|
||||
try std.testing.expect(optionMatchesLot("AAPL260620P220.50", lot));
|
||||
// Call doesn't match put
|
||||
try std.testing.expect(!optionMatchesLot("-AAPL260620C220.50", lot));
|
||||
}
|
||||
|
||||
test "optionMatchesLot single-char underlying" {
|
||||
const lot = portfolio_mod.Lot{
|
||||
.symbol = "A 03/20/2026 150.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "A",
|
||||
.strike = 150.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 3, 20),
|
||||
.shares = -2,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 3.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(optionMatchesLot("-A260320C150", lot));
|
||||
try std.testing.expect(!optionMatchesLot("-A260320P150", lot));
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1672
src/commands/audit/common.zig
Normal file
1672
src/commands/audit/common.zig
Normal file
File diff suppressed because it is too large
Load diff
91
src/commands/audit/fidelity.zig
Normal file
91
src/commands/audit/fidelity.zig
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! Fidelity reconciler for the `audit` command.
|
||||
//!
|
||||
//! Fidelity exports a single "all accounts" positions CSV. Parsing
|
||||
//! lives in `brokerage/fidelity.zig`; this module wires that parser
|
||||
//! into the shared per-account comparison engine in `common.zig`.
|
||||
//! Because the Fidelity export is a plain per-account positions list,
|
||||
//! the only Fidelity-specific knowledge here is "use the Fidelity CSV
|
||||
//! parser" and "the institution key is `fidelity`" — everything else
|
||||
//! (comparison, display) is shared.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const common = @import("common.zig");
|
||||
const fidelity_parser = @import("../../brokerage/fidelity.zig");
|
||||
|
||||
/// Parse a Fidelity positions CSV and reconcile it against the
|
||||
/// portfolio. Returns owned `AccountComparison` results (free each
|
||||
/// `.comparisons` slice, then the results slice). String fields in
|
||||
/// the results borrow from `csv_data`, which must outlive them.
|
||||
///
|
||||
/// Propagates the parser's errors (`EmptyFile` / `UnexpectedHeader`)
|
||||
/// and allocation failures so the caller can decide between a
|
||||
/// user-facing message (explicit `--fidelity`) and silently skipping
|
||||
/// the file (flagless auto-reconcile).
|
||||
pub fn reconcile(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
csv_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const positions = try fidelity_parser.parseCsv(allocator, csv_data);
|
||||
// The result strings borrow from `csv_data`, not from this slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(positions);
|
||||
return common.compareAccounts(allocator, portfolio, positions, account_map, "fidelity", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
|
||||
test "reconcile: parses Fidelity CSV and matches against portfolio" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
const csv =
|
||||
"Account Number,Account Name,Symbol,Description,Quantity,Last Price,Last Price Change,Current Value,Today's Gain/Loss Dollar,Today's Gain/Loss Percent,Total Gain/Loss Dollar,Total Gain/Loss Percent,Percent Of Account,Cost Basis Total,Average Cost Basis,Type\n" ++
|
||||
"Z123,Individual,AAPL,APPLE INC,100,$150.00,+$2.00,$15000.00,+$200.00,+1.35%,+$5000.00,+50.00%,100%,$10000.00,$100.00,Margin,\n";
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100.0, .account = "Sample Brokerage" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "fidelity", .account_number = "Z123" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 150.0);
|
||||
|
||||
const results = try reconcile(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 5, 8));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||
// 100 shares @ $150 on both sides → no discrepancy.
|
||||
try std.testing.expect(!results[0].has_discrepancies);
|
||||
}
|
||||
|
||||
test "reconcile: propagates parser errors" {
|
||||
const allocator = std.testing.allocator;
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
try std.testing.expectError(
|
||||
error.EmptyFile,
|
||||
reconcile(allocator, portfolio, "", acct_map, prices, Date.fromYmd(2026, 5, 8)),
|
||||
);
|
||||
}
|
||||
1536
src/commands/audit/hygiene.zig
Normal file
1536
src/commands/audit/hygiene.zig
Normal file
File diff suppressed because it is too large
Load diff
832
src/commands/audit/schwab.zig
Normal file
832
src/commands/audit/schwab.zig
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
//! Schwab reconcilers for the `audit` command.
|
||||
//!
|
||||
//! Schwab has two export shapes, so this module carries more than
|
||||
//! the Fidelity one:
|
||||
//!
|
||||
//! 1. **Per-account positions CSV** (`--schwab`) — same per-account
|
||||
//! positions shape Fidelity uses, so it feeds the shared
|
||||
//! `common.compareAccounts` engine via `reconcileCsv`.
|
||||
//! 2. **Account summary paste** (`--schwab-summary`) — a
|
||||
//! per-account totals-only view with no per-symbol detail. This
|
||||
//! is the one genuinely broker-specific reconciler, with its own
|
||||
//! comparison type (`SchwabAccountComparison`), comparator
|
||||
//! (`compareSchwabSummary`), and display.
|
||||
//!
|
||||
//! Parsing for both lives in `brokerage/schwab.zig`.
|
||||
|
||||
const std = @import("std");
|
||||
const zfin = @import("../../root.zig");
|
||||
const cli = @import("../common.zig");
|
||||
const Money = @import("../../Money.zig");
|
||||
const analysis = @import("../../analytics/analysis.zig");
|
||||
const Date = @import("../../Date.zig");
|
||||
const common = @import("common.zig");
|
||||
const schwab_parser = @import("../../brokerage/schwab.zig");
|
||||
|
||||
const AccountSummary = schwab_parser.AccountSummary;
|
||||
|
||||
/// Account-level comparison result for Schwab summary audit.
|
||||
pub const SchwabAccountComparison = struct {
|
||||
account_name: []const u8,
|
||||
schwab_name: []const u8,
|
||||
account_number: []const u8,
|
||||
portfolio_cash: f64,
|
||||
schwab_cash: ?f64,
|
||||
cash_delta: ?f64,
|
||||
portfolio_total: f64,
|
||||
schwab_total: ?f64,
|
||||
total_delta: ?f64,
|
||||
has_discrepancy: bool,
|
||||
};
|
||||
|
||||
// ── Per-account positions CSV (--schwab) ─────────────────────
|
||||
|
||||
/// Parse a Schwab per-account positions CSV and reconcile it against
|
||||
/// the portfolio via the shared engine. Returns owned
|
||||
/// `AccountComparison` results (free each `.comparisons` slice, then
|
||||
/// the results slice). String fields borrow from `csv_data`, which
|
||||
/// must outlive them. Propagates parser and allocation errors.
|
||||
pub fn reconcileCsv(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
csv_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]common.AccountComparison {
|
||||
const parsed = try schwab_parser.parseCsv(allocator, csv_data);
|
||||
// Result strings borrow from `csv_data`, not the positions slice,
|
||||
// so freeing the slice array here is safe.
|
||||
defer allocator.free(parsed.positions);
|
||||
return common.compareAccounts(allocator, portfolio, parsed.positions, account_map, "schwab", prices, as_of);
|
||||
}
|
||||
|
||||
// ── Account summary paste (--schwab-summary) ─────────────────
|
||||
|
||||
/// Parse a Schwab account summary and reconcile its per-account
|
||||
/// totals against portfolio.srf. Returns owned results (free the
|
||||
/// slice). String fields borrow from `summary_data`, which must
|
||||
/// outlive them. Propagates parser (`NoAccountsFound`) and
|
||||
/// allocation errors.
|
||||
pub fn reconcileSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
summary_data: []const u8,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
const schwab_accounts = try schwab_parser.parseSummary(allocator, summary_data);
|
||||
defer allocator.free(schwab_accounts);
|
||||
return compareSchwabSummary(allocator, portfolio, schwab_accounts, account_map, prices, as_of);
|
||||
}
|
||||
|
||||
/// Compare Schwab summary against portfolio.srf account totals.
|
||||
pub fn compareSchwabSummary(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
schwab_accounts: []const AccountSummary,
|
||||
account_map: analysis.AccountMap,
|
||||
prices: std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) ![]SchwabAccountComparison {
|
||||
var results = std.ArrayList(SchwabAccountComparison).empty;
|
||||
errdefer results.deinit(allocator);
|
||||
|
||||
for (schwab_accounts) |sa| {
|
||||
const portfolio_acct = account_map.findByInstitutionAccount("schwab", sa.account_number);
|
||||
|
||||
var pf_cash: f64 = 0;
|
||||
var pf_total: f64 = 0;
|
||||
|
||||
if (portfolio_acct) |pa| {
|
||||
pf_cash = portfolio.cashForAccount(pa);
|
||||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||||
}
|
||||
|
||||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
||||
const total_delta = if (sa.total_value) |st| st - pf_total else null;
|
||||
|
||||
const cash_ok = if (cash_delta) |d| @abs(d) < common.cash_tolerance else true;
|
||||
const total_ok = if (total_delta) |d| @abs(d) < common.value_tolerance else true;
|
||||
|
||||
try results.append(allocator, .{
|
||||
.account_name = portfolio_acct orelse "",
|
||||
.schwab_name = sa.account_name,
|
||||
.account_number = sa.account_number,
|
||||
.portfolio_cash = pf_cash,
|
||||
.schwab_cash = sa.cash,
|
||||
.cash_delta = cash_delta,
|
||||
.portfolio_total = pf_total,
|
||||
.schwab_total = sa.total_value,
|
||||
.total_delta = total_delta,
|
||||
.has_discrepancy = !cash_ok or !total_ok or portfolio_acct == null,
|
||||
});
|
||||
}
|
||||
|
||||
return results.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn displaySchwabResults(results: []const SchwabAccountComparison, color: bool, out: *std.Io.Writer) !void {
|
||||
try cli.printBold(out, color, "\nSchwab Account Audit", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (brokerage is source of truth)\n", .{});
|
||||
try out.print("========================================\n\n", .{});
|
||||
|
||||
// Column headers
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " {s:<24} {s:>14} {s:>14} {s:>14} {s:>14}\n", .{
|
||||
"Account", "PF Cash", "BR Cash", "PF Total", "BR Total",
|
||||
});
|
||||
|
||||
var grand_pf: f64 = 0;
|
||||
var grand_br: f64 = 0;
|
||||
var discrepancy_count: usize = 0;
|
||||
|
||||
for (results) |r| {
|
||||
const label = if (r.account_name.len > 0) r.account_name else r.schwab_name;
|
||||
|
||||
var br_cash_buf: [24]u8 = undefined;
|
||||
var br_total_buf: [24]u8 = undefined;
|
||||
|
||||
const br_cash_str = if (r.schwab_cash) |c|
|
||||
std.fmt.bufPrint(&br_cash_buf, "{f}", .{Money.from(c)}) catch "$?"
|
||||
else
|
||||
"--";
|
||||
const br_total_str = if (r.schwab_total) |t|
|
||||
std.fmt.bufPrint(&br_total_buf, "{f}", .{Money.from(t)}) catch "$?"
|
||||
else
|
||||
"--";
|
||||
|
||||
const cash_ok = if (r.cash_delta) |d| @abs(d) < common.cash_tolerance else true;
|
||||
const total_ok = if (r.total_delta) |d| @abs(d) < common.value_tolerance else true;
|
||||
const is_unmapped = r.account_name.len == 0;
|
||||
const is_real_mismatch = !cash_ok or is_unmapped;
|
||||
|
||||
if (is_real_mismatch) discrepancy_count += 1;
|
||||
|
||||
// Account label
|
||||
try out.print(" ", .{});
|
||||
if (is_unmapped) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, "{s:<24}", .{label});
|
||||
} else {
|
||||
try out.print("{s:<24}", .{label});
|
||||
}
|
||||
|
||||
// PF Cash — colored if mismatched (brokerage is truth)
|
||||
try out.print(" ", .{});
|
||||
if (!cash_ok) {
|
||||
const rgb = if (r.cash_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_cash).padRight(14)});
|
||||
} else {
|
||||
try out.print("{f}", .{Money.from(r.portfolio_cash).padRight(14)});
|
||||
}
|
||||
|
||||
// BR Cash
|
||||
try out.print(" {s:>14}", .{br_cash_str});
|
||||
|
||||
// PF Total — colored if not just stale prices
|
||||
try out.print(" ", .{});
|
||||
if (!total_ok and !cash_ok) {
|
||||
const rgb = if (r.total_delta.? > 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||
try cli.printFg(out, color, rgb, "{f}", .{Money.from(r.portfolio_total).padRight(14)});
|
||||
} else {
|
||||
try out.print("{f}", .{Money.from(r.portfolio_total).padRight(14)});
|
||||
}
|
||||
|
||||
// BR Total
|
||||
try out.print(" {s:>14}", .{br_total_str});
|
||||
|
||||
// Status
|
||||
if (is_unmapped) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " Unmapped", .{});
|
||||
} else if (!cash_ok) {
|
||||
const d = r.cash_delta.?;
|
||||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " Cash {s}{f}", .{ sign, Money.from(@abs(d)) });
|
||||
} else if (!total_ok) {
|
||||
const d = r.total_delta.?;
|
||||
const sign: []const u8 = if (d >= 0) "+" else "-";
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " Value {s}{f}", .{ sign, Money.from(@abs(d)) });
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
|
||||
grand_pf += r.portfolio_total;
|
||||
if (r.schwab_total) |t| grand_br += t;
|
||||
}
|
||||
|
||||
// Grand totals
|
||||
try out.print("\n", .{});
|
||||
const grand_delta = grand_br - grand_pf;
|
||||
|
||||
try cli.printBold(out, color, " Total: portfolio {f} schwab {f}", .{
|
||||
Money.from(grand_pf),
|
||||
Money.from(grand_br),
|
||||
});
|
||||
|
||||
if (@abs(grand_delta) < 1.0) {
|
||||
// no delta
|
||||
} else {
|
||||
const sign: []const u8 = if (grand_delta >= 0) "+" else "-";
|
||||
const rgb = if (grand_delta >= 0) cli.CLR_NEGATIVE else cli.CLR_POSITIVE;
|
||||
try cli.printFg(out, color, rgb, " delta {s}{f}", .{ sign, Money.from(@abs(grand_delta)) });
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
|
||||
if (discrepancy_count > 0) {
|
||||
try cli.printFg(out, color, cli.CLR_WARNING, " {d} {s} — drill down with: zfin audit --schwab <account.csv>\n", .{
|
||||
discrepancy_count, if (discrepancy_count == 1) @as([]const u8, "mismatch") else @as([]const u8, "mismatches"),
|
||||
});
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Direct-indexing ratio suggestions from Schwab-summary data.
|
||||
///
|
||||
/// The Schwab summary path only gives us per-account totals, not
|
||||
/// per-symbol detail. For a direct-indexing account with exactly one
|
||||
/// stock lot (the common case — the account is the proxy basket,
|
||||
/// tracked as a single benchmark lot), we can still emit a ratio
|
||||
/// suggestion from the account-level `total_delta`:
|
||||
///
|
||||
/// current_stock_value = portfolio_total - portfolio_cash
|
||||
/// target_stock_value = current_stock_value + total_delta
|
||||
/// suggested_ratio = target_stock_value / (shares × price)
|
||||
///
|
||||
/// Where `price` is `shares × current_cached_price`. The math
|
||||
/// assumes the full account delta lands on the single tracked lot,
|
||||
/// which is the semantics of a direct-indexing proxy.
|
||||
///
|
||||
/// Skips accounts with more than one stock lot (can't allocate the
|
||||
/// delta) or zero stock lots (nothing to adjust).
|
||||
pub fn displaySchwabSummaryRatioSuggestions(
|
||||
results: []const SchwabAccountComparison,
|
||||
portfolio: zfin.Portfolio,
|
||||
prices: std.StringHashMap(f64),
|
||||
account_map: ?analysis.AccountMap,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const am = account_map orelse return;
|
||||
var has_header = false;
|
||||
|
||||
for (results) |r| {
|
||||
if (r.account_name.len == 0) continue;
|
||||
if (!am.isDirectIndexing(r.account_name)) continue;
|
||||
const total_delta = r.total_delta orelse continue;
|
||||
if (@abs(total_delta) < 0.01) continue;
|
||||
|
||||
// Find the single stock lot for this account.
|
||||
var stock_lot: ?zfin.Lot = null;
|
||||
var stock_lot_count: usize = 0;
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .stock) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (!std.mem.eql(u8, lot_acct, r.account_name)) continue;
|
||||
stock_lot = lot;
|
||||
stock_lot_count += 1;
|
||||
}
|
||||
if (stock_lot_count != 1) continue;
|
||||
const lot = stock_lot.?;
|
||||
|
||||
const price_sym = lot.priceSymbol();
|
||||
const retail_price = prices.get(price_sym) orelse continue;
|
||||
if (retail_price == 0) continue;
|
||||
if (lot.shares == 0) continue;
|
||||
|
||||
const current_stock_value = lot.shares * retail_price * lot.price_ratio;
|
||||
if (current_stock_value == 0) continue;
|
||||
const target_stock_value = current_stock_value + total_delta;
|
||||
const suggested_ratio = target_stock_value / (lot.shares * retail_price);
|
||||
const drift_pct = (suggested_ratio - lot.price_ratio) / lot.price_ratio * 100.0;
|
||||
|
||||
if (!has_header) {
|
||||
try out.print("\n", .{});
|
||||
try cli.printBold(out, color, " Ratio updates", .{});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " (for portfolio.srf; direct-indexing accounts)\n", .{});
|
||||
has_header = true;
|
||||
}
|
||||
|
||||
var cur_buf: [24]u8 = undefined;
|
||||
var sug_buf: [24]u8 = undefined;
|
||||
var drift_buf: [16]u8 = undefined;
|
||||
const cur_str = std.fmt.bufPrint(&cur_buf, "{d}", .{lot.price_ratio}) catch "?";
|
||||
const sug_str = std.fmt.bufPrint(&sug_buf, "{d}", .{suggested_ratio}) catch "?";
|
||||
const drift_str = std.fmt.bufPrint(&drift_buf, "{d:.4}%", .{drift_pct}) catch "?";
|
||||
|
||||
try out.print(" {s:<16} ", .{lot.symbol});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, "ticker {s:<6}", .{price_sym});
|
||||
try out.print(" ratio {s} -> ", .{cur_str});
|
||||
try cli.printBold(out, color, "{s}", .{sug_str});
|
||||
try cli.printFg(out, color, cli.CLR_MUTED, " ({s} drift)\n", .{drift_str});
|
||||
}
|
||||
|
||||
if (has_header) try out.print("\n", .{});
|
||||
}
|
||||
|
||||
/// Check if any Schwab summary results have discrepancies.
|
||||
pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||||
for (results) |r| {
|
||||
if (r.has_discrepancy) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||
|
||||
test "hasSchwabDiscrepancies" {
|
||||
const clean = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 100,
|
||||
.cash_delta = 0,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5000,
|
||||
.total_delta = 0,
|
||||
.has_discrepancy = false,
|
||||
}};
|
||||
try std.testing.expect(!hasSchwabDiscrepancies(&clean));
|
||||
|
||||
const dirty = [_]SchwabAccountComparison{.{
|
||||
.account_name = "IRA",
|
||||
.schwab_name = "Roth IRA",
|
||||
.account_number = "1234",
|
||||
.portfolio_cash = 100,
|
||||
.schwab_cash = 200,
|
||||
.cash_delta = 100,
|
||||
.portfolio_total = 5000,
|
||||
.schwab_total = 5100,
|
||||
.total_delta = 100,
|
||||
.has_discrepancy = true,
|
||||
}};
|
||||
try std.testing.expect(hasSchwabDiscrepancies(&dirty));
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: matching account → no discrepancy" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio: $5000 cash + 10 AAPL @ open_price 150 = $1500 cost basis.
|
||||
// With AAPL price=200, total = 5000 + 10*200 = 7000.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 150,
|
||||
.account = "Sample Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Sample Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5000.0,
|
||||
.total_value = 7000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Sample Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", results[0].account_name);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 5000), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 7000), results[0].portfolio_total, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: cash mismatch → has_discrepancy true" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
// Portfolio cash = 5000, Schwab reports 5500 → $500 delta.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "Brokerage",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Brokerage",
|
||||
.account_number = "1234",
|
||||
.cash = 5500.0,
|
||||
.total_value = 5500.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Brokerage",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500), results[0].cash_delta.?, 0.01);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: sub-dollar cash drift is flagged (cash matches to the penny)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 6, 19);
|
||||
|
||||
// Portfolio cash $38.75; Schwab reports $38.97 — a $0.22 accrual.
|
||||
// Below the $1 securities tolerance, but a real cash drift that
|
||||
// must surface.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "CASH", .shares = 38.75, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Sample Brokerage" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{ .account_name = "Sample Brokerage", .account_number = "1234", .cash = 38.97, .total_value = 38.97 },
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.22), results[0].cash_delta.?, 0.001);
|
||||
try std.testing.expect(results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: account_number with no match → empty account_name" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Unknown Acct",
|
||||
.account_number = "9999",
|
||||
.cash = 1000.0,
|
||||
.total_value = 1000.0,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("Unknown Acct", results[0].schwab_name);
|
||||
// No portfolio match → cash and total are zero, schwab values become deltas
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_cash, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), results[0].cash_delta.?, 0.01);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: null cash/total fields produce null deltas (within tolerance)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const today = Date.fromYmd(2026, 5, 8);
|
||||
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "CASH",
|
||||
.shares = 5000,
|
||||
.open_date = Date.fromYmd(2024, 1, 1),
|
||||
.open_price = 1.0,
|
||||
.security_type = .cash,
|
||||
.account = "X",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
// Schwab summary missing cash + total fields (.cash = null, .total_value = null).
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "X",
|
||||
.account_number = "1234",
|
||||
.cash = null,
|
||||
.total_value = null,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "X",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, today);
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expect(results[0].cash_delta == null);
|
||||
try std.testing.expect(results[0].total_delta == null);
|
||||
// Null deltas are treated as "ok" (no discrepancy possible to assert).
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
|
||||
test "compareSchwabSummary: today affects valuation of held assets" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Lot opens 2024-06-01 with 10 shares. With today=2024-01-01 (before
|
||||
// open), it's not held → portfolio_total excludes it. With
|
||||
// today=2025-01-01 (after open), portfolio_total includes 10 * price.
|
||||
const lots = [_]portfolio_mod.Lot{
|
||||
.{
|
||||
.symbol = "AAPL",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2024, 6, 1),
|
||||
.open_price = 150,
|
||||
.account = "Acct",
|
||||
},
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = @constCast(&lots), .allocator = allocator };
|
||||
|
||||
const schwab_accounts = [_]AccountSummary{
|
||||
.{
|
||||
.account_name = "Acct",
|
||||
.account_number = "1234",
|
||||
.cash = 0,
|
||||
.total_value = 2000,
|
||||
},
|
||||
};
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{
|
||||
.account = "Acct",
|
||||
.tax_type = .taxable,
|
||||
.institution = "schwab",
|
||||
.account_number = "1234",
|
||||
},
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AAPL", 200.0);
|
||||
|
||||
// Before open: portfolio holds nothing for this account.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2024, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].portfolio_total, 0.01);
|
||||
}
|
||||
|
||||
// After open: portfolio holds 10 * 200 = 2000.
|
||||
{
|
||||
const results = try compareSchwabSummary(allocator, portfolio, &schwab_accounts, acct_map, prices, Date.fromYmd(2025, 1, 1));
|
||||
defer allocator.free(results);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000), results[0].portfolio_total, 0.01);
|
||||
// Matches schwab → no discrepancy.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0), results[0].total_delta.?, 0.01);
|
||||
try std.testing.expect(!results[0].has_discrepancy);
|
||||
}
|
||||
}
|
||||
|
||||
// ── reconcile wrappers (parse + compare wiring) ──────────────
|
||||
|
||||
test "reconcileCsv: parses a Schwab positions CSV and reconciles it" {
|
||||
const allocator = std.testing.allocator;
|
||||
const csv =
|
||||
"\"Positions for account Sample Trust ...1234 as of 10:47 AM ET, 2026/04/10\"\n" ++
|
||||
"\n" ++
|
||||
"\"Symbol\",\"Description\",\"Price Chng $\",\"Price Chng %\",\"Price\",\"Qty\",\"Day Chng $\",\"Day Chng %\",\"Mkt Val\",\"Cost Basis\",\"Gain $\",\"Gain %\",\"Ratings\",\"Reinvest?\",\"Reinvest Capital Gains?\",\"% of Acct\",\"Asset Type\",\n" ++
|
||||
"\"AMZN\",\"AMAZON.COM INC\",\"5.558\",\"2.38%\",\"239.208\",\"1,488\",\"$8,270.30\",\"2.38%\",\"$355,941.50\",\"$110,243.38\",\"$245,698.12\",\"222.87%\",\"C\",\"No\",\"N/A\",\"41.54%\",\"Equity\",\n";
|
||||
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "AMZN", .shares = 1488, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 74, .account = "Sample Trust" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Trust", .tax_type = .taxable, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("AMZN", 239.208);
|
||||
|
||||
const results = try reconcileCsv(allocator, portfolio, csv, acct_map, prices, Date.fromYmd(2026, 4, 10));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
try std.testing.expectEqualStrings("Sample Trust", results[0].account_name);
|
||||
var found_amzn = false;
|
||||
for (results[0].comparisons) |c| {
|
||||
if (std.mem.eql(u8, c.symbol, "AMZN")) found_amzn = true;
|
||||
}
|
||||
try std.testing.expect(found_amzn);
|
||||
}
|
||||
|
||||
test "reconcileSummary: parses a Schwab summary paste and reconciles per-account" {
|
||||
const allocator = std.testing.allocator;
|
||||
const data =
|
||||
\\Sample Roth
|
||||
\\Account number ending in 1234 ...1234
|
||||
\\Type IRA $46.44 $227,058.15 +$1,072.88 +0.47%
|
||||
\\Sample Inherited IRA
|
||||
\\Account number ending in 5678 ...5678
|
||||
\\Type IRA $2,461.82 $167,544.08 +$1,208.34 +0.73%
|
||||
;
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Roth IRA", .tax_type = .roth, .institution = "schwab", .account_number = "1234" },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = try reconcileSummary(allocator, portfolio, data, acct_map, prices, Date.fromYmd(2026, 4, 10));
|
||||
defer allocator.free(results);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), results.len);
|
||||
// 1234 maps; 5678 is absent from the map → unmapped (empty name).
|
||||
try std.testing.expectEqualStrings("Sample Roth IRA", results[0].account_name);
|
||||
try std.testing.expectEqualStrings("", results[1].account_name);
|
||||
}
|
||||
|
||||
// ── displaySchwabResults rendering ───────────────────────────
|
||||
|
||||
test "displaySchwabResults: renders mapped/cash/value/unmapped rows and totals" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
// clean mapped → no status
|
||||
.{ .account_name = "Sample Roth", .schwab_name = "Roth IRA", .account_number = "1234", .portfolio_cash = 100, .schwab_cash = 100, .cash_delta = 0, .portfolio_total = 5000, .schwab_total = 5000, .total_delta = 0, .has_discrepancy = false },
|
||||
// cash mismatch → "Cash +$5.00", counts as a real mismatch
|
||||
.{ .account_name = "Sample Trust", .schwab_name = "Trust", .account_number = "5678", .portfolio_cash = 95, .schwab_cash = 100, .cash_delta = 5, .portfolio_total = 8000, .schwab_total = 8005, .total_delta = 5, .has_discrepancy = true },
|
||||
// value-only mismatch (cash ok) → muted "Value +$100.00", not a real mismatch
|
||||
.{ .account_name = "Sample HSA", .schwab_name = "HSA", .account_number = "9012", .portfolio_cash = 50, .schwab_cash = 50, .cash_delta = 0, .portfolio_total = 1000, .schwab_total = 1100, .total_delta = 100, .has_discrepancy = false },
|
||||
// unmapped, null broker fields → "Unmapped" + "--", counts as a real mismatch
|
||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 3456", .account_number = "3456", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabResults(&results, false, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Roth") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Cash +") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Value +") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Unmapped") != null);
|
||||
// unmapped row falls back to the schwab_name label
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Sample Brokerage 3456") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "--") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Total: portfolio") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "schwab") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "delta") != null);
|
||||
// cash-mismatch + unmapped = 2 real mismatches → plural
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "mismatches") != null);
|
||||
}
|
||||
|
||||
test "displaySchwabResults: color=true emits ANSI and singular label" {
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "", .schwab_name = "Sample Brokerage 9999", .account_number = "9999", .portfolio_cash = 0, .schwab_cash = null, .cash_delta = null, .portfolio_total = 0, .schwab_total = null, .total_delta = null, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabResults(&results, true, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Schwab Account Audit") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") != null);
|
||||
// single mismatch → singular "1 mismatch" label
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "1 mismatch") != null);
|
||||
}
|
||||
|
||||
// ── displaySchwabSummaryRatioSuggestions ─────────────────────
|
||||
|
||||
test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot direct-indexing account" {
|
||||
const allocator = std.testing.allocator;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("SPY", 500.0);
|
||||
|
||||
// total_delta 1000 on a 50000 stock value → suggested ratio 1.02 vs 1.0.
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
|
||||
var buf: [2048]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
|
||||
const out = w.buffered();
|
||||
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Ratio updates") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "SPY") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "ratio 1 -> ") != null);
|
||||
}
|
||||
|
||||
test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
|
||||
const allocator = std.testing.allocator;
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = allocator };
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, null, false, &w);
|
||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||
}
|
||||
|
||||
test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipped" {
|
||||
const allocator = std.testing.allocator;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "SPY", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 400, .account = "Sample Brokerage", .price_ratio = 1.0 },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
|
||||
};
|
||||
const acct_map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("SPY", 500.0);
|
||||
|
||||
const results = [_]SchwabAccountComparison{
|
||||
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 51000, .total_delta = 1000, .has_discrepancy = true },
|
||||
};
|
||||
var buf: [512]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
try displaySchwabSummaryRatioSuggestions(&results, portfolio, prices, acct_map, false, &w);
|
||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const Date = @import("../Date.zig");
|
||||
const portfolio = @import("portfolio.zig");
|
||||
|
||||
pub const ContractType = enum {
|
||||
call,
|
||||
|
|
@ -50,3 +51,202 @@ pub const OptionsChain = struct {
|
|||
};
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Match a compact broker-display option symbol against a portfolio
|
||||
/// lot by parsing the symbol's components (underlying, expiration,
|
||||
/// call/put, strike) and comparing them to the lot's structured
|
||||
/// fields.
|
||||
///
|
||||
/// Compact format: `[-]{UNDERLYING}{YYMMDD}{C|P}{STRIKE}`
|
||||
/// (e.g. "-AMZN260515C220"). This is the form brokers like Fidelity
|
||||
/// and Schwab use in their positions exports — distinct from the
|
||||
/// canonical 21-char OCC symbol with a zero-padded strike. The
|
||||
/// underlying length is variable, so we scan for the first position
|
||||
/// where 6 consecutive digits encode a valid date.
|
||||
///
|
||||
/// Lives here (the option model) rather than in any one broker's
|
||||
/// parser because the audit reconciler applies it across brokers —
|
||||
/// keeping it broker-neutral lets the shared comparison engine in
|
||||
/// `commands/audit/common.zig` stay decoupled from `brokerage/*`.
|
||||
pub fn symbolMatchesLot(symbol: []const u8, lot: portfolio.Lot) bool {
|
||||
if (lot.security_type != .option) return false;
|
||||
|
||||
// Strip leading dash (short indicator)
|
||||
const sym = if (symbol.len > 0 and symbol[0] == '-') symbol[1..] else symbol;
|
||||
|
||||
// Need at least: 1 char underlying + 6 date + 1 type + 1 strike = 9
|
||||
if (sym.len < 9) return false;
|
||||
|
||||
// Scan for the date boundary: first position where 6 consecutive digits
|
||||
// form a valid YYMMDD (and the character before is a letter).
|
||||
var i: usize = 1; // underlying is at least 1 char
|
||||
while (i + 7 < sym.len) : (i += 1) {
|
||||
// All 6 chars must be digits
|
||||
if (!std.ascii.isDigit(sym[i]) or
|
||||
!std.ascii.isDigit(sym[i + 1]) or
|
||||
!std.ascii.isDigit(sym[i + 2]) or
|
||||
!std.ascii.isDigit(sym[i + 3]) or
|
||||
!std.ascii.isDigit(sym[i + 4]) or
|
||||
!std.ascii.isDigit(sym[i + 5]))
|
||||
continue;
|
||||
|
||||
// Character after the 6 digits must be C or P
|
||||
const type_char = sym[i + 6];
|
||||
if (type_char != 'C' and type_char != 'P') continue;
|
||||
|
||||
// Parse date components
|
||||
const yy = std.fmt.parseInt(i16, sym[i..][0..2], 10) catch continue;
|
||||
const mm = std.fmt.parseInt(u8, sym[i + 2 ..][0..2], 10) catch continue;
|
||||
const dd = std.fmt.parseInt(u8, sym[i + 4 ..][0..2], 10) catch continue;
|
||||
if (mm < 1 or mm > 12 or dd < 1 or dd > 31) continue;
|
||||
const year = 2000 + yy;
|
||||
|
||||
// Parse components
|
||||
const underlying = sym[0..i];
|
||||
const option_type: portfolio.OptionType = if (type_char == 'P') .put else .call;
|
||||
const strike_str = sym[i + 7 ..];
|
||||
const strike = std.fmt.parseFloat(f64, strike_str) catch continue;
|
||||
const date = Date.fromYmd(year, mm, dd);
|
||||
|
||||
// Match against lot fields
|
||||
const lot_underlying = lot.underlying orelse return false;
|
||||
const lot_maturity = lot.maturity_date orelse return false;
|
||||
|
||||
if (!std.mem.eql(u8, underlying, lot_underlying)) return false;
|
||||
if (!lot_maturity.eql(date)) return false;
|
||||
if (option_type != lot.option_type) return false;
|
||||
if (lot.strike) |ls| {
|
||||
if (@abs(ls - strike) > 0.01) return false;
|
||||
} else return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test "symbolMatchesLot basic call" {
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AMZN 05/15/2026 220.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AMZN",
|
||||
.strike = 220.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 5, 15),
|
||||
.shares = -3,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 8.75,
|
||||
};
|
||||
|
||||
// Compact format with leading dash (short)
|
||||
try std.testing.expect(symbolMatchesLot("-AMZN260515C220", lot));
|
||||
// Without dash
|
||||
try std.testing.expect(symbolMatchesLot("AMZN260515C220", lot));
|
||||
// Wrong underlying
|
||||
try std.testing.expect(!symbolMatchesLot("-MSFT260515C220", lot));
|
||||
// Wrong date
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260615C220", lot));
|
||||
// Wrong type
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260515P220", lot));
|
||||
// Wrong strike
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260515C230", lot));
|
||||
// Non-option lot
|
||||
const stock_lot = portfolio.Lot{ .symbol = "AMZN", .security_type = .stock, .shares = 100, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 100 };
|
||||
try std.testing.expect(!symbolMatchesLot("-AMZN260515C220", stock_lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot put option and decimal strike" {
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AAPL 06/20/2026 220.50 P",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = 220.50,
|
||||
.option_type = .put,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(symbolMatchesLot("-AAPL260620P220.50", lot));
|
||||
try std.testing.expect(symbolMatchesLot("AAPL260620P220.50", lot));
|
||||
// Call doesn't match put
|
||||
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220.50", lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot single-char underlying" {
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "A 03/20/2026 150.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "A",
|
||||
.strike = 150.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 3, 20),
|
||||
.shares = -2,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 3.0,
|
||||
};
|
||||
|
||||
try std.testing.expect(symbolMatchesLot("-A260320C150", lot));
|
||||
try std.testing.expect(!symbolMatchesLot("-A260320P150", lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot: option lot with null strike never matches" {
|
||||
// Everything else lines up (underlying/date/type), but a lot with
|
||||
// no strike can't be a strike match → the `else return false` arm.
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AAPL 06/20/2026 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = null,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
try std.testing.expect(!symbolMatchesLot("-AAPL260620C220", lot));
|
||||
}
|
||||
|
||||
test "symbolMatchesLot: symbol with no valid date boundary falls through to false" {
|
||||
// Long enough to enter the scan loop, but no 6-digit run followed
|
||||
// by C/P → the loop exhausts and returns false.
|
||||
const lot = portfolio.Lot{
|
||||
.symbol = "AAPL 06/20/2026 220.00 C",
|
||||
.security_type = .option,
|
||||
.underlying = "AAPL",
|
||||
.strike = 220.0,
|
||||
.option_type = .call,
|
||||
.maturity_date = Date.fromYmd(2026, 6, 20),
|
||||
.shares = -1,
|
||||
.open_date = Date.fromYmd(2025, 1, 1),
|
||||
.open_price = 5.0,
|
||||
};
|
||||
try std.testing.expect(!symbolMatchesLot("NODATEHERE", lot));
|
||||
}
|
||||
|
||||
test "OptionsChain.deinit and freeSlice free owned memory" {
|
||||
const allocator = std.testing.allocator;
|
||||
|
||||
// Single-chain deinit.
|
||||
{
|
||||
const chain = OptionsChain{
|
||||
.underlying_symbol = try allocator.dupe(u8, "AAPL"),
|
||||
.expiration = Date.fromYmd(2026, 6, 20),
|
||||
.calls = try allocator.alloc(OptionContract, 0),
|
||||
.puts = try allocator.alloc(OptionContract, 0),
|
||||
};
|
||||
chain.deinit(allocator);
|
||||
}
|
||||
|
||||
// Slice freeSlice (deinits each element first).
|
||||
{
|
||||
const chains = try allocator.alloc(OptionsChain, 1);
|
||||
chains[0] = .{
|
||||
.underlying_symbol = try allocator.dupe(u8, "MSFT"),
|
||||
.expiration = Date.fromYmd(2026, 6, 20),
|
||||
.calls = try allocator.alloc(OptionContract, 0),
|
||||
.puts = try allocator.alloc(OptionContract, 0),
|
||||
};
|
||||
OptionsChain.freeSlice(allocator, chains);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const Candle = @import("candle.zig").Candle;
|
|||
//
|
||||
// See `Lot.effectivePrice`, `Lot.marketValue`, and the matching methods
|
||||
// on `Position` for the canonical implementation. All callers in
|
||||
// snapshot.zig, audit.zig, and valuation.zig route through these — do
|
||||
// snapshot.zig, audit/, and valuation.zig route through these — do
|
||||
// not reintroduce inline `price * price_ratio` expressions.
|
||||
//
|
||||
// ## Caching pre-multiply pattern
|
||||
|
|
@ -49,7 +49,7 @@ const Candle = @import("candle.zig").Candle;
|
|||
// When manual overrides (2b) get folded into a shared `prices` map
|
||||
// keyed by symbol, they're PRE-MULTIPLIED by `price_ratio` at insert
|
||||
// time (see `commands/snapshot.zig:buildSnapshot` and
|
||||
// `commands/audit.zig`). This normalizes the cached value so later
|
||||
// `commands/audit/`). This normalizes the cached value so later
|
||||
// readers can treat every entry uniformly as "price in whichever terms
|
||||
// the lot needs." The `manual_set` (from `buildFallbackPrices`) then
|
||||
// tells readers which entries are preadjusted.
|
||||
|
|
@ -66,7 +66,7 @@ const Candle = @import("candle.zig").Candle;
|
|||
|
||||
// ── Money-market / stable-NAV classification ────────────────
|
||||
//
|
||||
// Centralized so that audit.zig, the Fidelity/Schwab parsers, and the
|
||||
// Centralized so that audit/, the Fidelity/Schwab parsers, and the
|
||||
// planned snapshot writer all agree on which symbols are fixed-$1-NAV
|
||||
// instruments. Prior to this the classification lived in three places
|
||||
// with three different heuristics that disagreed on edge cases.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue