Compare commits
No commits in common. "858e9874b4473f5b50fefd9411b2ef0e88e548e8" and "2876358c5a7d171356802ab7033a21a172c8e415" have entirely different histories.
858e9874b4
...
2876358c5a
12 changed files with 33 additions and 1004 deletions
|
|
@ -345,27 +345,10 @@ Portfolio Audit (brokerage is source of truth)
|
||||||
If a lot is priced through a retail-ticker `ticker::` alias while the
|
If a lot is priced through a retail-ticker `ticker::` alias while the
|
||||||
account actually holds an institutional class (a different NAV), audit
|
account actually holds an institutional class (a different NAV), audit
|
||||||
compares against the broker's NAV and can **suggest a `price_ratio`** to
|
compares against the broker's NAV and can **suggest a `price_ratio`** to
|
||||||
bridge the gap. A suggestion is only made when it would move the lot's
|
bridge the gap. Accounts flagged `direct_indexing::true` get the same
|
||||||
value by at least a dollar, the same slack the comparison allows. See
|
treatment to track drift. See
|
||||||
[price resolution](../reference/config/portfolio-srf.md#advanced-and-option-fields).
|
[price resolution](../reference/config/portfolio-srf.md#advanced-and-option-fields).
|
||||||
|
|
||||||
### Direct-indexing accounts
|
|
||||||
|
|
||||||
An account flagged `direct_indexing:bool:true` in `accounts.srf` is held
|
|
||||||
in the portfolio as one proxy lot (see
|
|
||||||
[tracking a synthetic account](set-up-accounts.md#5-track-tax-loss-harvested-on-a-synthetic-account)),
|
|
||||||
while the export lists every real holding. Audit compares the two as
|
|
||||||
**one row**: all of the account's non-cash export rows are summed onto
|
|
||||||
the proxy, and cash is compared as usual. The row's value delta is the
|
|
||||||
proxy's drift, and the `price_ratio` suggestion is exactly the ratio
|
|
||||||
that closes it.
|
|
||||||
|
|
||||||
This needs the account's one open stock lot to be its proxy. With zero
|
|
||||||
or several, audit compares holding by holding, which shows the mismatch
|
|
||||||
plainly instead of guessing. For a Wells Fargo account the proxy is
|
|
||||||
kept current by [`zfin import`](../reference/cli/import.md#direct-indexing-accounts),
|
|
||||||
so auditing against the export you just imported shows no drift.
|
|
||||||
|
|
||||||
## Why it's finicky
|
## Why it's finicky
|
||||||
|
|
||||||
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
- The parsers are **broker-specific**. The CSV parsers hardcode each
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,6 @@ a `ticker::` alias standing in for the whole sleeve:
|
||||||
symbol::DI-SPX,ticker::SPY,shares:num:1000,open_date::2024-01-15,open_price:num:400,account::Sample Tax Loss
|
symbol::DI-SPX,ticker::SPY,shares:num:1000,open_date::2024-01-15,open_price:num:400,account::Sample Tax Loss
|
||||||
```
|
```
|
||||||
|
|
||||||
If the account comes in through [`zfin import`](../reference/cli/import.md)
|
|
||||||
(and is flagged `direct_indexing:bool:true`, below), you only seed this
|
|
||||||
lot once: each import keeps it and re-sets its `price_ratio` so it is
|
|
||||||
worth what the export says the account holds. See
|
|
||||||
[Direct-indexing accounts](../reference/cli/import.md#direct-indexing-accounts).
|
|
||||||
|
|
||||||
That keeps the file readable, but it means zfin has no closed lots to
|
That keeps the file readable, but it means zfin has no closed lots to
|
||||||
work from, so it can't compute realized gain/loss -- and the harvested
|
work from, so it can't compute realized gain/loss -- and the harvested
|
||||||
total is the whole reason the account exists. Record it by hand
|
total is the whole reason the account exists. Record it by hand
|
||||||
|
|
|
||||||
|
|
@ -57,33 +57,6 @@ all brokerage accounts. It saves as `WFA_Portfolio_Positions_*.xls`.
|
||||||
- Cash is one lot per account: cash balance, sweep, and accrued
|
- Cash is one lot per account: cash balance, sweep, and accrued
|
||||||
interest combined, matching WF's own cash total.
|
interest combined, matching WF's own cash total.
|
||||||
|
|
||||||
## Direct-indexing accounts
|
|
||||||
|
|
||||||
An account flagged `direct_indexing:bool:true` in
|
|
||||||
[`accounts.srf`](../config/accounts-srf.md) churns too much to track lot
|
|
||||||
by lot (a tax-loss-harvesting sleeve, a mechanical rotation strategy).
|
|
||||||
Import never writes its individual holdings. Instead the account lives
|
|
||||||
as **one proxy lot** that you seed once by hand in the target file: a
|
|
||||||
`ticker::` alias to a fund that tracks the account, e.g.
|
|
||||||
|
|
||||||
```srf
|
|
||||||
symbol::DI-ROTH,ticker::VTI,shares:num:1000,open_date::2025-01-02,open_price:num:250,account::Sample Roth IRA,note::direct indexing proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
On every import, the proxy is carried forward unchanged except for
|
|
||||||
`price_ratio`, which is set so that shares x the ticker's close x
|
|
||||||
`price_ratio` equals the account's securities in the export. The close
|
|
||||||
used is the export's own price date (Wells Fargo's "Priced as of" date;
|
|
||||||
today for other sources). The account's cash still imports as a normal
|
|
||||||
cash lot, and the summary reports each re-pricing.
|
|
||||||
|
|
||||||
- Any share count works; pick `open_price` so shares x `open_price` is
|
|
||||||
the account's cost basis if you want gain/loss to mean something.
|
|
||||||
- Import refuses, with a ready-to-paste line, when the account has no
|
|
||||||
stock lot or more than one in the target file, and when the proxy
|
|
||||||
cannot be priced (no shares, no close for its ticker, or an account
|
|
||||||
that now holds only cash).
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
|
||||||
| `account_number` | string | No | -- | Matched with `institution` against export rows (WF: `1234` for `*1234`). Use a placeholder, not a full real number. |
|
| `account_number` | string | No | -- | Matched with `institution` against export rows (WF: `1234` for `*1234`). Use a placeholder, not a full real number. |
|
||||||
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
|
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
|
||||||
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
|
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
|
||||||
| `direct_indexing` | bool | No | `false` | Account held as one benchmark proxy lot: audit compares it as one row, import re-prices it, contributions loosen tolerances. |
|
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
|
||||||
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
|
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
|
||||||
| `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. |
|
| `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. |
|
||||||
| `harvested` | num | No | -- | Hand-declared cumulative tax-loss-harvested figure, for accounts whose realized P&L zfin cannot derive (see below). |
|
| `harvested` | num | No | -- | Hand-declared cumulative tax-loss-harvested figure, for accounts whose realized P&L zfin cannot derive (see below). |
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ const brokerage_types = @import("../../brokerage/types.zig");
|
||||||
const portfolio_mod = @import("../../models/portfolio.zig");
|
const portfolio_mod = @import("../../models/portfolio.zig");
|
||||||
const option = @import("../../models/option.zig");
|
const option = @import("../../models/option.zig");
|
||||||
const Date = @import("../../Date.zig");
|
const Date = @import("../../Date.zig");
|
||||||
const directIndexingProxy = @import("../../models/portfolio.zig").directIndexingProxy;
|
|
||||||
|
|
||||||
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
||||||
|
|
||||||
|
|
@ -227,52 +226,6 @@ fn consolidateBySymbol(
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace a direct-indexing account's non-cash rows with one row for
|
|
||||||
/// its proxy lot: the account's securities total, at the proxy's share
|
|
||||||
/// count. Cash rows stay as they are.
|
|
||||||
///
|
|
||||||
/// The portfolio holds such an account as one proxy lot (a `ticker::`
|
|
||||||
/// alias to a fund that tracks it, priced through `price_ratio`), so a
|
|
||||||
/// holding-by-holding comparison would report every real holding as
|
|
||||||
/// brokerage-only and the proxy as portfolio-only. One row checks what
|
|
||||||
/// can be checked - the account's total - and its implied price per
|
|
||||||
/// proxy share (`brokerage_value / brokerage_shares`) is exactly what
|
|
||||||
/// `ratioSuggestions` turns into a corrected `price_ratio`.
|
|
||||||
///
|
|
||||||
/// No securities rows (an all-cash account) leaves the rows untouched,
|
|
||||||
/// so the proxy shows up as portfolio-only: it no longer has anything
|
|
||||||
/// to stand for.
|
|
||||||
fn collapseOntoProxy(allocator: std.mem.Allocator, rows: *std.ArrayList(BrokeragePosition), proxy: zfin.Lot) !void {
|
|
||||||
if (rows.items.len == 0) return;
|
|
||||||
const account_number = rows.items[0].account_number;
|
|
||||||
const account_name = rows.items[0].account_name;
|
|
||||||
|
|
||||||
var total: f64 = 0;
|
|
||||||
var cost: ?f64 = null;
|
|
||||||
var kept: usize = 0;
|
|
||||||
for (rows.items) |row| {
|
|
||||||
if (row.is_cash) {
|
|
||||||
rows.items[kept] = row;
|
|
||||||
kept += 1;
|
|
||||||
} else {
|
|
||||||
total += row.current_value orelse 0;
|
|
||||||
cost = sumOptional(cost, row.cost_basis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (kept == rows.items.len) return;
|
|
||||||
rows.shrinkRetainingCapacity(kept);
|
|
||||||
try rows.append(allocator, .{
|
|
||||||
.account_number = account_number,
|
|
||||||
.account_name = account_name,
|
|
||||||
.symbol = proxy.symbol,
|
|
||||||
.description = "direct-indexing holdings",
|
|
||||||
.quantity = proxy.effectiveShares(),
|
|
||||||
.current_value = total,
|
|
||||||
.cost_basis = cost,
|
|
||||||
.is_cash = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sumOptional(a: ?f64, b: ?f64) ?f64 {
|
fn sumOptional(a: ?f64, b: ?f64) ?f64 {
|
||||||
if (a == null and b == null) return null;
|
if (a == null and b == null) return null;
|
||||||
return (a orelse 0) + (b orelse 0);
|
return (a orelse 0) + (b orelse 0);
|
||||||
|
|
@ -332,20 +285,7 @@ pub fn compareAccounts(
|
||||||
{
|
{
|
||||||
var acct_it = brokerage_accounts.iterator();
|
var acct_it = brokerage_accounts.iterator();
|
||||||
while (acct_it.next()) |kv| {
|
while (acct_it.next()) |kv| {
|
||||||
var consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items);
|
const consolidated = try consolidateBySymbol(allocator, kv.value_ptr.items);
|
||||||
errdefer consolidated.deinit(allocator);
|
|
||||||
// A direct-indexing account is one proxy lot in the
|
|
||||||
// portfolio, so compare its holdings as one row against it.
|
|
||||||
if (account_map.findByInstitutionAccount(institution, kv.key_ptr.*)) |account| {
|
|
||||||
if (account_map.isDirectIndexing(account)) {
|
|
||||||
switch (directIndexingProxy(portfolio.lots, account, as_of)) {
|
|
||||||
.found => |proxy| try collapseOntoProxy(allocator, &consolidated, proxy.*),
|
|
||||||
// No unique proxy: compare holding by holding,
|
|
||||||
// which surfaces the mismatch plainly.
|
|
||||||
.not_unique => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try consolidated_accounts.put(kv.key_ptr.*, consolidated);
|
try consolidated_accounts.put(kv.key_ptr.*, consolidated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -775,9 +715,8 @@ pub const RatioSuggestion = struct {
|
||||||
/// expressed by nudging a ratio that starts at 1.0.
|
/// expressed by nudging a ratio that starts at 1.0.
|
||||||
///
|
///
|
||||||
/// Skips unmatched / cash / option rows, non-stock lots, symbol
|
/// Skips unmatched / cash / option rows, non-stock lots, symbol
|
||||||
/// mismatches, missing or zero retail prices, and no-drift lots (a
|
/// mismatches, missing or zero retail prices, and no-drift lots
|
||||||
/// ratio change worth less than `value_tolerance` of the lot's value).
|
/// (`current == suggested`). At most one suggestion per comparison row.
|
||||||
/// At most one suggestion per comparison row.
|
|
||||||
/// Caller owns the returned slice; strings borrow from the inputs.
|
/// Caller owns the returned slice; strings borrow from the inputs.
|
||||||
pub fn ratioSuggestions(
|
pub fn ratioSuggestions(
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
|
|
@ -816,12 +755,7 @@ pub fn ratioSuggestions(
|
||||||
|
|
||||||
const current_ratio = lot.price_ratio;
|
const current_ratio = lot.price_ratio;
|
||||||
const suggested_ratio = inst_nav / retail_price;
|
const suggested_ratio = inst_nav / retail_price;
|
||||||
// No drift worth a suggestion: the change would move the
|
if (current_ratio == suggested_ratio) break; // no drift
|
||||||
// lot's value by less than the `value_tolerance` slack the
|
|
||||||
// comparison itself allows. Catches rounding, e.g. a
|
|
||||||
// direct-indexing ratio that import just set from this
|
|
||||||
// same export, one ulp away from what audit recomputes.
|
|
||||||
if (@abs(suggested_ratio - current_ratio) * lot.effectiveShares() * retail_price < value_tolerance) break;
|
|
||||||
|
|
||||||
try out.append(allocator, .{
|
try out.append(allocator, .{
|
||||||
.account_name = acct.account_name,
|
.account_name = acct.account_name,
|
||||||
|
|
@ -1762,31 +1696,6 @@ test "ratioSuggestions: zero drift produces no suggestion" {
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "ratioSuggestions: sub-dollar drift is rounding, not a suggestion" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
var lots = [_]zfin.Lot{
|
|
||||||
.{ .symbol = "VTTHX", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .account = "Sample IRA", .price_ratio = 5.0 },
|
|
||||||
};
|
|
||||||
const portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
|
||||||
defer prices.deinit();
|
|
||||||
try prices.put("VTTHX", 27.0);
|
|
||||||
|
|
||||||
// Ratio one ulp off 5.0, and then a change worth $0.27 on a $13,500
|
|
||||||
// lot: both inside the $1 the comparison already tolerates.
|
|
||||||
for ([_]f64{ std.math.nextAfter(f64, 5.0, 6.0) * 27.0, 5.0001 * 27.0 }) |nav| {
|
|
||||||
const cmps = [_]SymbolComparison{
|
|
||||||
.{ .symbol = "VTTHX", .portfolio_shares = 100, .brokerage_shares = 100, .portfolio_price = 135, .brokerage_price = nav, .portfolio_value = 13500, .brokerage_value = 100 * nav, .shares_delta = 0, .value_delta = 100 * nav - 13500, .is_cash = false, .is_option = false, .only_in_brokerage = false, .only_in_portfolio = false },
|
|
||||||
};
|
|
||||||
const results = [_]AccountComparison{
|
|
||||||
.{ .account_name = "Sample IRA", .brokerage_name = "IRA", .account_number = "1234", .comparisons = &cmps, .portfolio_total = 13500, .brokerage_total = 100 * nav, .total_delta = 100 * nav - 13500, .option_value_delta = 0, .has_discrepancies = false },
|
|
||||||
};
|
|
||||||
const sugg = try ratioSuggestions(allocator, &results, portfolio, prices, null);
|
|
||||||
defer allocator.free(sugg);
|
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
|
test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
var lots = [_]portfolio_mod.Lot{
|
var lots = [_]portfolio_mod.Lot{
|
||||||
|
|
@ -1845,135 +1754,3 @@ test "compareAccounts: a cash lot closed in the portfolio is not flagged portfol
|
||||||
try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
|
try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Direct-indexing accounts ----
|
|
||||||
|
|
||||||
const DirectIndexingAudit = struct {
|
|
||||||
/// Proxy for a Roth whose real holdings are AAPL and SMPLX.
|
|
||||||
fn proxy(price_ratio: f64) zfin.Lot {
|
|
||||||
return .{ .symbol = "DI-ROTH", .ticker = "VTI", .shares = 10, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 250, .account = "Sample Roth", .price_ratio = price_ratio };
|
|
||||||
}
|
|
||||||
|
|
||||||
const cash_lot: zfin.Lot = .{ .symbol = "", .shares = 50, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 1, .account = "Sample Roth", .security_type = .cash };
|
|
||||||
|
|
||||||
const export_rows = [_]BrokeragePosition{
|
|
||||||
.{ .account_number = "1234", .account_name = "*1234", .symbol = "", .description = "Cash Balance", .quantity = null, .current_value = 50, .cost_basis = null, .is_cash = true },
|
|
||||||
.{ .account_number = "1234", .account_name = "*1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
|
|
||||||
.{ .account_number = "1234", .account_name = "*1234", .symbol = "SMPLX", .description = "", .quantity = 100, .current_value = 1000, .cost_basis = 900, .is_cash = false },
|
|
||||||
};
|
|
||||||
|
|
||||||
fn entries() [1]analysis.AccountTaxEntry {
|
|
||||||
return .{.{ .account = "Sample Roth", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234", .direct_indexing = true }};
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare(lots: []zfin.Lot, rows: []const BrokeragePosition, map: analysis.AccountMap, prices: std.StringHashMap(f64)) ![]AccountComparison {
|
|
||||||
const portfolio = zfin.Portfolio{ .lots = lots, .allocator = std.testing.allocator };
|
|
||||||
return compareAccounts(std.testing.allocator, portfolio, rows, map, "wells_fargo", prices, Date.fromYmd(2026, 10, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn free(results: []AccountComparison) void {
|
|
||||||
for (results) |r| std.testing.allocator.free(r.comparisons);
|
|
||||||
std.testing.allocator.free(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find(results: []const AccountComparison, symbol: []const u8) ?SymbolComparison {
|
|
||||||
for (results[0].comparisons) |c| {
|
|
||||||
if (std.mem.eql(u8, c.symbol, symbol)) return c;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
test "compareAccounts: a direct-indexing account's holdings are compared as one row against its proxy" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(1.0), DirectIndexingAudit.cash_lot };
|
|
||||||
var entries = DirectIndexingAudit.entries();
|
|
||||||
const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
|
||||||
defer prices.deinit();
|
|
||||||
try prices.put("VTI", 300);
|
|
||||||
|
|
||||||
const results = try DirectIndexingAudit.compare(&lots, &DirectIndexingAudit.export_rows, map, prices);
|
|
||||||
defer DirectIndexingAudit.free(results);
|
|
||||||
|
|
||||||
// Cash plus the one proxy row: no AAPL / SMPLX brokerage-only rows,
|
|
||||||
// no proxy portfolio-only row.
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), results[0].comparisons.len);
|
|
||||||
const row = DirectIndexingAudit.find(results, "DI-ROTH").?;
|
|
||||||
try std.testing.expectEqual(@as(f64, 3000), row.brokerage_value.?);
|
|
||||||
try std.testing.expectEqual(@as(f64, 10), row.brokerage_shares.?);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 0), row.value_delta.?, 1e-9);
|
|
||||||
try std.testing.expect(!row.only_in_brokerage and !row.only_in_portfolio);
|
|
||||||
// 10 x $300 x 1.0 = $3000 of holdings, and the cash matches.
|
|
||||||
try std.testing.expect(!results[0].has_discrepancies);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "compareAccounts: a drifted proxy shows the delta, and ratioSuggestions corrects it" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(0.9), DirectIndexingAudit.cash_lot };
|
|
||||||
var entries = DirectIndexingAudit.entries();
|
|
||||||
const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
|
||||||
defer prices.deinit();
|
|
||||||
try prices.put("VTI", 300);
|
|
||||||
|
|
||||||
const results = try DirectIndexingAudit.compare(&lots, &DirectIndexingAudit.export_rows, map, prices);
|
|
||||||
defer DirectIndexingAudit.free(results);
|
|
||||||
const row = DirectIndexingAudit.find(results, "DI-ROTH").?;
|
|
||||||
// Portfolio says 10 x $300 x 0.9 = $2700; the account holds $3000.
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 300), row.value_delta.?, 1e-9);
|
|
||||||
try std.testing.expect(results[0].has_discrepancies);
|
|
||||||
|
|
||||||
const portfolio = zfin.Portfolio{ .lots = &lots, .allocator = allocator };
|
|
||||||
const sugg = try ratioSuggestions(allocator, results, portfolio, prices, map);
|
|
||||||
defer allocator.free(sugg);
|
|
||||||
try std.testing.expectEqual(@as(usize, 1), sugg.len);
|
|
||||||
try std.testing.expectEqualStrings("DI-ROTH", sugg[0].lot_symbol);
|
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), sugg[0].suggested_ratio, 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "compareAccounts: without a unique proxy, a direct-indexing account is compared holding by holding" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
var second = DirectIndexingAudit.proxy(1.0);
|
|
||||||
second.symbol = "DI-ROTH2";
|
|
||||||
var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(1.0), second, DirectIndexingAudit.cash_lot };
|
|
||||||
var entries = DirectIndexingAudit.entries();
|
|
||||||
const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
|
||||||
defer prices.deinit();
|
|
||||||
try prices.put("VTI", 300);
|
|
||||||
|
|
||||||
const results = try DirectIndexingAudit.compare(&lots, &DirectIndexingAudit.export_rows, map, prices);
|
|
||||||
defer DirectIndexingAudit.free(results);
|
|
||||||
try std.testing.expect(DirectIndexingAudit.find(results, "AAPL").?.only_in_brokerage);
|
|
||||||
// Both lots price through VTI, so they surface as one VTI position.
|
|
||||||
try std.testing.expect(DirectIndexingAudit.find(results, "VTI").?.only_in_portfolio);
|
|
||||||
try std.testing.expect(results[0].has_discrepancies);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "compareAccounts: an all-cash direct-indexing account leaves its proxy portfolio-only" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
var lots = [_]zfin.Lot{ DirectIndexingAudit.proxy(1.0), DirectIndexingAudit.cash_lot };
|
|
||||||
var entries = DirectIndexingAudit.entries();
|
|
||||||
const map = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
|
||||||
var prices = std.StringHashMap(f64).init(allocator);
|
|
||||||
defer prices.deinit();
|
|
||||||
try prices.put("VTI", 300);
|
|
||||||
|
|
||||||
const results = try DirectIndexingAudit.compare(&lots, DirectIndexingAudit.export_rows[0..1], map, prices);
|
|
||||||
defer DirectIndexingAudit.free(results);
|
|
||||||
try std.testing.expect(DirectIndexingAudit.find(results, "VTI").?.only_in_portfolio);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "collapseOntoProxy: every allocation failure is clean" {
|
|
||||||
const S = struct {
|
|
||||||
fn run(allocator: std.mem.Allocator) !void {
|
|
||||||
var rows: std.ArrayList(BrokeragePosition) = .empty;
|
|
||||||
defer rows.deinit(allocator);
|
|
||||||
try rows.appendSlice(allocator, &DirectIndexingAudit.export_rows);
|
|
||||||
try collapseOntoProxy(allocator, &rows, DirectIndexingAudit.proxy(1.0));
|
|
||||||
try std.testing.expectEqual(@as(usize, 2), rows.items.len);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try std.testing.checkAllAllocationFailures(std.testing.allocator, S.run, .{});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ const std = @import("std");
|
||||||
const zfin = @import("../../root.zig");
|
const zfin = @import("../../root.zig");
|
||||||
const analysis = @import("../../analytics/analysis.zig");
|
const analysis = @import("../../analytics/analysis.zig");
|
||||||
const Date = @import("../../Date.zig");
|
const Date = @import("../../Date.zig");
|
||||||
const directIndexingProxy = @import("../../models/portfolio.zig").directIndexingProxy;
|
|
||||||
const common = @import("common.zig");
|
const common = @import("common.zig");
|
||||||
const schwab_parser = @import("../../brokerage/schwab.zig");
|
const schwab_parser = @import("../../brokerage/schwab.zig");
|
||||||
|
|
||||||
|
|
@ -159,8 +158,7 @@ pub fn hasSchwabDiscrepancies(results: []const SchwabAccountComparison) bool {
|
||||||
/// suggested_ratio = target_stock_value / (shares * retail_price)
|
/// suggested_ratio = target_stock_value / (shares * retail_price)
|
||||||
///
|
///
|
||||||
/// Skips non-direct-indexing accounts, unmapped accounts, sub-cent
|
/// Skips non-direct-indexing accounts, unmapped accounts, sub-cent
|
||||||
/// deltas, accounts without a unique proxy lot (`directIndexingProxy`:
|
/// deltas, accounts without exactly one stock lot, and zero
|
||||||
/// exactly one open stock lot as of `as_of`), and zero
|
|
||||||
/// share/price/value. Caller owns the returned slice; strings borrow
|
/// share/price/value. Caller owns the returned slice; strings borrow
|
||||||
/// from the inputs.
|
/// from the inputs.
|
||||||
pub fn summaryRatioSuggestions(
|
pub fn summaryRatioSuggestions(
|
||||||
|
|
@ -169,7 +167,6 @@ pub fn summaryRatioSuggestions(
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
account_map: ?analysis.AccountMap,
|
account_map: ?analysis.AccountMap,
|
||||||
as_of: Date,
|
|
||||||
) ![]common.RatioSuggestion {
|
) ![]common.RatioSuggestion {
|
||||||
var out: std.ArrayList(common.RatioSuggestion) = .empty;
|
var out: std.ArrayList(common.RatioSuggestion) = .empty;
|
||||||
errdefer out.deinit(allocator);
|
errdefer out.deinit(allocator);
|
||||||
|
|
@ -182,10 +179,18 @@ pub fn summaryRatioSuggestions(
|
||||||
const total_delta = r.total_delta orelse continue;
|
const total_delta = r.total_delta orelse continue;
|
||||||
if (@abs(total_delta) < 0.01) continue;
|
if (@abs(total_delta) < 0.01) continue;
|
||||||
|
|
||||||
const lot = switch (directIndexingProxy(portfolio.lots, r.account_name, as_of)) {
|
// Find the single stock lot for this account.
|
||||||
.found => |p| p.*,
|
var stock_lot: ?zfin.Lot = null;
|
||||||
.not_unique => continue,
|
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 price_sym = lot.priceSymbol();
|
||||||
const retail_price = prices.get(price_sym) orelse continue;
|
const retail_price = prices.get(price_sym) orelse continue;
|
||||||
|
|
@ -636,7 +641,7 @@ test "summaryRatioSuggestions: single-lot direct-indexing yields exact ratio + d
|
||||||
const results = [_]SchwabAccountComparison{
|
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 },
|
.{ .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 },
|
||||||
};
|
};
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am);
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 1), sugg.len);
|
try std.testing.expectEqual(@as(usize, 1), sugg.len);
|
||||||
try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol);
|
try std.testing.expectEqualStrings("SPY", sugg[0].lot_symbol);
|
||||||
|
|
@ -660,7 +665,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
|
|
||||||
// null account_map -> empty.
|
// null account_map -> empty.
|
||||||
{
|
{
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, null);
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||||
}
|
}
|
||||||
|
|
@ -670,7 +675,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = false },
|
||||||
};
|
};
|
||||||
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &results, portfolio, prices, am);
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||||
}
|
}
|
||||||
|
|
@ -683,7 +688,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
const tiny = [_]SchwabAccountComparison{
|
const tiny = [_]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 = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
|
.{ .account_name = "Sample Brokerage", .schwab_name = "Brokerage", .account_number = "1234", .portfolio_cash = 0, .schwab_cash = 0, .cash_delta = 0, .portfolio_total = 50000, .schwab_total = 50000.005, .total_delta = 0.005, .has_discrepancy = false },
|
||||||
};
|
};
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &tiny, portfolio, prices, am);
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||||
}
|
}
|
||||||
|
|
@ -698,7 +703,7 @@ test "summaryRatioSuggestions: null map, non-DI, sub-cent delta, multi-lot all s
|
||||||
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
.{ .account = "Sample Brokerage", .tax_type = .taxable, .institution = "schwab", .account_number = "1234", .direct_indexing = true },
|
||||||
};
|
};
|
||||||
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
const am = analysis.AccountMap{ .entries = &entries, .allocator = allocator };
|
||||||
const sugg = try summaryRatioSuggestions(allocator, &results, pf2, prices, am, Date.fromYmd(2026, 6, 19));
|
const sugg = try summaryRatioSuggestions(allocator, &results, pf2, prices, am);
|
||||||
defer allocator.free(sugg);
|
defer allocator.free(sugg);
|
||||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,23 +97,6 @@ pub fn positionsSheet(wb: *const biff8.Workbook) Error!*const Sheet {
|
||||||
return wb.sheet(sheet_name) orelse error.NotPositionsExport;
|
return wb.sheet(sheet_name) orelse error.NotPositionsExport;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The date the export's values are priced at: the close named in its
|
|
||||||
/// "Priced as of Close on MM/DD/YYYY" banner. A download made over a
|
|
||||||
/// weekend is still priced at Friday's close, so this, not the
|
|
||||||
/// download date, is the date to price anything against. Null when the
|
|
||||||
/// banner is missing or unreadable.
|
|
||||||
pub fn pricedAsOf(sheet: *const Sheet) ?Date {
|
|
||||||
// The banner sits in the first few rows, above every section.
|
|
||||||
const scan_rows = @min(sheet.rows.len, 10);
|
|
||||||
for (0..scan_rows) |r| {
|
|
||||||
const text = trimmedText(sheet.cell(r, 0));
|
|
||||||
if (!std.mem.startsWith(u8, text, "Priced as of")) continue;
|
|
||||||
if (text.len < "MM/DD/YYYY".len) return null;
|
|
||||||
return Date.parseMdy(text[text.len - "MM/DD/YYYY".len ..]) catch null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Anything reading an export file can fail with: the spreadsheet
|
/// Anything reading an export file can fail with: the spreadsheet
|
||||||
/// decode, then the sheet layout.
|
/// decode, then the sheet layout.
|
||||||
pub const ExportError = biff8.ParseError || Error;
|
pub const ExportError = biff8.ParseError || Error;
|
||||||
|
|
@ -726,16 +709,3 @@ test "exportHint: names the right download for each failure" {
|
||||||
try testing.expect(std.mem.indexOf(u8, exportHint(error.Truncated), "again") != null);
|
try testing.expect(std.mem.indexOf(u8, exportHint(error.Truncated), "again") != null);
|
||||||
try testing.expectEqualStrings("", exportHint(error.MissingValue));
|
try testing.expectEqualStrings("", exportHint(error.MissingValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "pricedAsOf: reads the banner date" {
|
|
||||||
try testing.expect(Date.fromYmd(2026, 10, 2).eql(pricedAsOf(&fixture).?));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "pricedAsOf: null without a readable banner" {
|
|
||||||
const no_banner = sheetWith(&.{&.{txt("Account Number: HOUSEHOLD")}});
|
|
||||||
try testing.expect(pricedAsOf(&no_banner) == null);
|
|
||||||
const bad_date = sheetWith(&.{&.{txt("Priced as of Close on 13/45/2026")}});
|
|
||||||
try testing.expect(pricedAsOf(&bad_date) == null);
|
|
||||||
const too_short = sheetWith(&.{&.{txt("Priced as of")}});
|
|
||||||
try testing.expect(pricedAsOf(&too_short) == null);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer allocator.free(results);
|
defer allocator.free(results);
|
||||||
|
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
try schwab.displaySchwabResults(results, color, out);
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||||
|
|
||||||
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
const present = try common.presentNumbers(allocator, schwab.SchwabAccountComparison, results);
|
||||||
defer allocator.free(present);
|
defer allocator.free(present);
|
||||||
|
|
|
||||||
|
|
@ -1159,7 +1159,7 @@ pub fn runHygieneCheck(
|
||||||
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
if (verbose or schwab.hasSchwabDiscrepancies(results)) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
try schwab.displaySchwabResults(results, color, out);
|
try schwab.displaySchwabResults(results, color, out);
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||||
} else {
|
} else {
|
||||||
var acct_count: usize = 0;
|
var acct_count: usize = 0;
|
||||||
for (results) |r| {
|
for (results) |r| {
|
||||||
|
|
@ -1169,7 +1169,7 @@ pub fn runHygieneCheck(
|
||||||
// Always show ratio suggestions even in compact
|
// Always show ratio suggestions even in compact
|
||||||
// mode - direct-indexing drift may cause a
|
// mode - direct-indexing drift may cause a
|
||||||
// non-zero delta that still deserves a nudge.
|
// non-zero delta that still deserves a nudge.
|
||||||
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of, color, out);
|
try schwab.displaySchwabSummaryRatioSuggestions(allocator, results, portfolio, prices, account_map, color, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
try accumulatePresent(allocator, &schwab_present, schwab.SchwabAccountComparison, results);
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,10 @@ pub fn displaySchwabSummaryRatioSuggestions(
|
||||||
portfolio: zfin.Portfolio,
|
portfolio: zfin.Portfolio,
|
||||||
prices: std.StringHashMap(f64),
|
prices: std.StringHashMap(f64),
|
||||||
account_map: ?analysis.AccountMap,
|
account_map: ?analysis.AccountMap,
|
||||||
as_of: Date,
|
|
||||||
color: bool,
|
color: bool,
|
||||||
out: *std.Io.Writer,
|
out: *std.Io.Writer,
|
||||||
) !void {
|
) !void {
|
||||||
const suggestions = try reconcile.summaryRatioSuggestions(allocator, results, portfolio, prices, account_map, as_of);
|
const suggestions = try reconcile.summaryRatioSuggestions(allocator, results, portfolio, prices, account_map);
|
||||||
defer allocator.free(suggestions);
|
defer allocator.free(suggestions);
|
||||||
if (suggestions.len == 0) return;
|
if (suggestions.len == 0) return;
|
||||||
|
|
||||||
|
|
@ -315,7 +314,7 @@ test "displaySchwabSummaryRatioSuggestions: emits ratio drift for single-lot dir
|
||||||
|
|
||||||
var buf: [2048]u8 = undefined;
|
var buf: [2048]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, Date.fromYmd(2026, 6, 19), false, &w);
|
try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w);
|
||||||
const out = w.buffered();
|
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, "Ratio updates") != null);
|
||||||
|
|
@ -334,7 +333,7 @@ test "displaySchwabSummaryRatioSuggestions: no account_map produces no output" {
|
||||||
};
|
};
|
||||||
var buf: [512]u8 = undefined;
|
var buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, null, Date.fromYmd(2026, 6, 19), false, &w);
|
try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, null, false, &w);
|
||||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,6 +358,6 @@ test "displaySchwabSummaryRatioSuggestions: non-direct-indexing account is skipp
|
||||||
};
|
};
|
||||||
var buf: [512]u8 = undefined;
|
var buf: [512]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
var w: std.Io.Writer = .fixed(&buf);
|
||||||
try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, Date.fromYmd(2026, 6, 19), false, &w);
|
try displaySchwabSummaryRatioSuggestions(allocator, &results, portfolio, prices, acct_map, false, &w);
|
||||||
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
try std.testing.expectEqual(@as(usize, 0), w.buffered().len);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,6 @@
|
||||||
//! back to the prior lot as below. Hand-edited fields and the note are
|
//! back to the prior lot as below. Hand-edited fields and the note are
|
||||||
//! inherited the same way for every source.
|
//! inherited the same way for every source.
|
||||||
//!
|
//!
|
||||||
//! ## Direct-indexing accounts
|
|
||||||
//!
|
|
||||||
//! An account flagged `direct_indexing` in accounts.srf is never written
|
|
||||||
//! lot by lot. Its one hand-seeded proxy lot is carried forward from the
|
|
||||||
//! existing file with only `price_ratio` re-set, so the proxy is worth
|
|
||||||
//! the account's securities on the export's price date. See
|
|
||||||
//! `applyDirectIndexing`.
|
|
||||||
//!
|
|
||||||
//! ## Re-import merge
|
//! ## Re-import merge
|
||||||
//!
|
//!
|
||||||
//! When the target portfolio file already exists, `import` reads
|
//! When the target portfolio file already exists, `import` reads
|
||||||
|
|
@ -148,7 +140,6 @@ const framework = @import("framework.zig");
|
||||||
const Date = @import("../Date.zig");
|
const Date = @import("../Date.zig");
|
||||||
const Lot = @import("../models/portfolio.zig").Lot;
|
const Lot = @import("../models/portfolio.zig").Lot;
|
||||||
const LotType = @import("../models/portfolio.zig").LotType;
|
const LotType = @import("../models/portfolio.zig").LotType;
|
||||||
const directIndexingProxy = @import("../models/portfolio.zig").directIndexingProxy;
|
|
||||||
const cache = @import("../cache/store.zig");
|
const cache = @import("../cache/store.zig");
|
||||||
const atomic = @import("../atomic.zig");
|
const atomic = @import("../atomic.zig");
|
||||||
const fidelity = @import("../brokerage/fidelity.zig");
|
const fidelity = @import("../brokerage/fidelity.zig");
|
||||||
|
|
@ -156,7 +147,6 @@ const schwab = @import("../brokerage/schwab.zig");
|
||||||
const wells_fargo = @import("../brokerage/wells_fargo.zig");
|
const wells_fargo = @import("../brokerage/wells_fargo.zig");
|
||||||
const brokerage_types = @import("../brokerage/types.zig");
|
const brokerage_types = @import("../brokerage/types.zig");
|
||||||
const analysis = @import("../analytics/analysis.zig");
|
const analysis = @import("../analytics/analysis.zig");
|
||||||
const format = @import("../format.zig");
|
|
||||||
|
|
||||||
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
const BrokeragePosition = brokerage_types.BrokeragePosition;
|
||||||
|
|
||||||
|
|
@ -255,8 +245,6 @@ pub const meta: framework.Meta = .{
|
||||||
CannotReadAccountsFile,
|
CannotReadAccountsFile,
|
||||||
UnmappedAccount,
|
UnmappedAccount,
|
||||||
InvalidExport,
|
InvalidExport,
|
||||||
ProxyLotRequired,
|
|
||||||
ProxyLotUnpriceable,
|
|
||||||
UserDeclined,
|
UserDeclined,
|
||||||
WriteFailed,
|
WriteFailed,
|
||||||
},
|
},
|
||||||
|
|
@ -416,38 +404,12 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
//
|
//
|
||||||
// `UnmappedAccount` arrives with the offending account numbers
|
// `UnmappedAccount` arrives with the offending account numbers
|
||||||
// already printed to stderr.
|
// already printed to stderr.
|
||||||
var lots = switch (parsed.source) {
|
const lots = switch (parsed.source) {
|
||||||
.wells_fargo => try wellsFargoLots(io, allocator, wf_sheet.?, source_path, positions, account_map, ctx.today, prior_lookup_opt),
|
.wells_fargo => try wellsFargoLots(io, allocator, wf_sheet.?, source_path, positions, account_map, ctx.today, prior_lookup_opt),
|
||||||
else => try synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt),
|
else => try synthesizeLots(io, allocator, positions, account_map, parsed.source, ctx.today, prior_lookup_opt),
|
||||||
};
|
};
|
||||||
defer freeLots(allocator, lots);
|
defer freeLots(allocator, lots);
|
||||||
|
|
||||||
// Direct-indexing accounts: one re-priced proxy lot each.
|
|
||||||
//
|
|
||||||
// Priced at the export's own date when it states one (a weekend
|
|
||||||
// Wells Fargo download is priced at Friday's close), else today.
|
|
||||||
var proxy_updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer proxy_updates.deinit(allocator);
|
|
||||||
const price_date: Date = switch (parsed.source) {
|
|
||||||
.wells_fargo => wells_fargo.pricedAsOf(wf_sheet.?) orelse ctx.today,
|
|
||||||
else => ctx.today,
|
|
||||||
};
|
|
||||||
lots = try applyDirectIndexing(
|
|
||||||
io,
|
|
||||||
allocator,
|
|
||||||
svc,
|
|
||||||
cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy),
|
|
||||||
lots,
|
|
||||||
positions,
|
|
||||||
account_map,
|
|
||||||
parsed.source.label(),
|
|
||||||
if (prior_portfolio_opt) |pf| pf.lots else &.{},
|
|
||||||
ctx.today,
|
|
||||||
price_date,
|
|
||||||
target_path,
|
|
||||||
&proxy_updates,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Confirm overwrite when target exists ──────────────────
|
// ── Confirm overwrite when target exists ──────────────────
|
||||||
//
|
//
|
||||||
// Inline access() check: a single call site doesn't earn its
|
// Inline access() check: a single call site doesn't earn its
|
||||||
|
|
@ -503,18 +465,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
parsed.source.label(),
|
parsed.source.label(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
for (proxy_updates.items) |u| {
|
|
||||||
var pct_buf: [16]u8 = undefined;
|
|
||||||
try out.print(" {s}: proxy {s} ({s} close {f}) price_ratio {d:.6} -> {d:.6} ({s})\n", .{
|
|
||||||
u.account,
|
|
||||||
u.symbol,
|
|
||||||
u.ticker,
|
|
||||||
u.priced_on,
|
|
||||||
u.old_ratio,
|
|
||||||
u.new_ratio,
|
|
||||||
format.fmtPct(&pct_buf, (u.new_ratio - u.old_ratio) / u.old_ratio, .{ .decimals = 2, .signed = true }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try out.flush();
|
try out.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -896,289 +846,6 @@ fn inheritFromPrior(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Direct-indexing accounts ----
|
|
||||||
//
|
|
||||||
// An account flagged `direct_indexing:bool:true` in accounts.srf churns
|
|
||||||
// too much to track lot by lot: a tax-loss-harvesting sleeve, a mechanical
|
|
||||||
// rotation strategy. In the portfolio it is ONE hand-seeded proxy lot, a
|
|
||||||
// `ticker::` alias to a benchmark that tracks it, whose `price_ratio`
|
|
||||||
// absorbs the tracking drift. Import never writes such an account's
|
|
||||||
// individual holdings. It carries the proxy forward from the existing file
|
|
||||||
// and re-sets only its `price_ratio`, so the proxy is worth what the export
|
|
||||||
// says the account's securities are worth on the export's price date.
|
|
||||||
// Shares and every other field stay exactly as seeded; the account's cash
|
|
||||||
// imports normally.
|
|
||||||
//
|
|
||||||
// The proxy is "the account's one open stock lot" (`directIndexingProxy`,
|
|
||||||
// shared with audit). Anything else is refused with instructions, never
|
|
||||||
// guessed.
|
|
||||||
|
|
||||||
/// What the export says about one direct-indexing account.
|
|
||||||
const DirectIndexingAccount = struct {
|
|
||||||
/// Portfolio account name (borrowed from the account map).
|
|
||||||
account: []const u8,
|
|
||||||
/// Market value of the account's non-cash rows.
|
|
||||||
securities_value: f64 = 0,
|
|
||||||
/// Their total cost, using current value where a row has no cost.
|
|
||||||
/// Feeds the suggested proxy line when none exists yet.
|
|
||||||
cost: f64 = 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// One proxy re-pricing, for the import summary.
|
|
||||||
const ProxyUpdate = struct {
|
|
||||||
account: []const u8,
|
|
||||||
symbol: []const u8,
|
|
||||||
ticker: []const u8,
|
|
||||||
old_ratio: f64,
|
|
||||||
new_ratio: f64,
|
|
||||||
/// The close actually used. Earlier than the export's price date
|
|
||||||
/// when the candle cache has nothing newer.
|
|
||||||
priced_on: Date,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Every direct-indexing account in the export, with its securities
|
|
||||||
/// totals, in order of first appearance. An account whose rows are all
|
|
||||||
/// cash still gets an entry (with zero securities), so a proxy that can
|
|
||||||
/// no longer be priced is noticed instead of silently dropped.
|
|
||||||
fn directIndexingAccounts(
|
|
||||||
allocator: std.mem.Allocator,
|
|
||||||
positions: []const BrokeragePosition,
|
|
||||||
account_map: analysis.AccountMap,
|
|
||||||
institution: []const u8,
|
|
||||||
) ![]DirectIndexingAccount {
|
|
||||||
var out: std.ArrayList(DirectIndexingAccount) = .empty;
|
|
||||||
errdefer out.deinit(allocator);
|
|
||||||
for (positions) |pos| {
|
|
||||||
const account = account_map.findByInstitutionAccount(institution, pos.account_number) orelse continue;
|
|
||||||
if (!account_map.isDirectIndexing(account)) continue;
|
|
||||||
const entry = for (out.items) |*e| {
|
|
||||||
if (std.mem.eql(u8, e.account, account)) break e;
|
|
||||||
} else blk: {
|
|
||||||
try out.append(allocator, .{ .account = account });
|
|
||||||
break :blk &out.items[out.items.len - 1];
|
|
||||||
};
|
|
||||||
if (pos.is_cash) continue;
|
|
||||||
const value = pos.current_value orelse 0;
|
|
||||||
entry.securities_value += value;
|
|
||||||
entry.cost += pos.cost_basis orelse value;
|
|
||||||
}
|
|
||||||
return out.toOwnedSlice(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `price_ratio` that makes `proxy` worth `securities_value` when
|
|
||||||
/// its ticker closes at `close`. Split-aware via `effectiveShares`, the
|
|
||||||
/// same way `Lot.marketValue` will value it.
|
|
||||||
fn proxyRatio(securities_value: f64, proxy: Lot, close: f64) f64 {
|
|
||||||
return securities_value / (proxy.effectiveShares() * close);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn isDirectIndexingSecurity(lot: Lot, account_map: analysis.AccountMap) bool {
|
|
||||||
if (lot.security_type == .cash) return false;
|
|
||||||
const account = lot.account orelse return false;
|
|
||||||
return account_map.isDirectIndexing(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy of `lot` whose strings are owned by `allocator` (the set
|
|
||||||
/// `freeLot` frees).
|
|
||||||
fn dupeLot(allocator: std.mem.Allocator, lot: Lot) !Lot {
|
|
||||||
var copy = lot;
|
|
||||||
copy.symbol = try allocator.dupe(u8, lot.symbol);
|
|
||||||
errdefer allocator.free(copy.symbol);
|
|
||||||
copy.account = if (lot.account) |a| try allocator.dupe(u8, a) else null;
|
|
||||||
errdefer if (copy.account) |a| allocator.free(a);
|
|
||||||
copy.note = if (lot.note) |n| try allocator.dupe(u8, n) else null;
|
|
||||||
errdefer if (copy.note) |n| allocator.free(n);
|
|
||||||
copy.label = if (lot.label) |l| try allocator.dupe(u8, l) else null;
|
|
||||||
errdefer if (copy.label) |l| allocator.free(l);
|
|
||||||
copy.ticker = if (lot.ticker) |t| try allocator.dupe(u8, t) else null;
|
|
||||||
errdefer if (copy.ticker) |t| allocator.free(t);
|
|
||||||
copy.underlying = if (lot.underlying) |u| try allocator.dupe(u8, u) else null;
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace each direct-indexing account's securities in `lots` with its
|
|
||||||
/// proxy from `prior_lots`, re-priced so it is worth the account's
|
|
||||||
/// securities on `price_date`. Records each re-pricing in `updates`.
|
|
||||||
///
|
|
||||||
/// On success the old `lots` slice is consumed and a new one returned;
|
|
||||||
/// with no direct-indexing account in the export, `lots` itself comes
|
|
||||||
/// back untouched. On error `lots` is left exactly as it was (every
|
|
||||||
/// proxy is resolved before anything is moved), so the caller's
|
|
||||||
/// `freeLots` still applies.
|
|
||||||
fn applyDirectIndexing(
|
|
||||||
io: std.Io,
|
|
||||||
allocator: std.mem.Allocator,
|
|
||||||
svc: *zfin.DataService,
|
|
||||||
fetch_options: zfin.FetchOptions,
|
|
||||||
lots: []Lot,
|
|
||||||
positions: []const BrokeragePosition,
|
|
||||||
account_map: analysis.AccountMap,
|
|
||||||
institution: []const u8,
|
|
||||||
prior_lots: []const Lot,
|
|
||||||
today: Date,
|
|
||||||
price_date: Date,
|
|
||||||
target_path: []const u8,
|
|
||||||
updates: *std.ArrayList(ProxyUpdate),
|
|
||||||
) ![]Lot {
|
|
||||||
const accounts = try directIndexingAccounts(allocator, positions, account_map, institution);
|
|
||||||
defer allocator.free(accounts);
|
|
||||||
if (accounts.len == 0) return lots;
|
|
||||||
|
|
||||||
var proxies: std.ArrayList(Lot) = .empty;
|
|
||||||
defer proxies.deinit(allocator);
|
|
||||||
errdefer for (proxies.items) |p| freeLot(allocator, p);
|
|
||||||
const updates_start = updates.items.len;
|
|
||||||
errdefer updates.shrinkRetainingCapacity(updates_start);
|
|
||||||
|
|
||||||
for (accounts) |acct| {
|
|
||||||
const proxy = switch (directIndexingProxy(prior_lots, acct.account, today)) {
|
|
||||||
.found => |p| p,
|
|
||||||
.not_unique => |count| {
|
|
||||||
// An all-cash account with no proxy has nothing to price.
|
|
||||||
if (count == 0 and acct.securities_value == 0) continue;
|
|
||||||
reportProxyRequired(io, target_path, acct, count, earliestOpenDate(lots, acct.account));
|
|
||||||
return error.ProxyLotRequired;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (acct.securities_value <= 0) {
|
|
||||||
reportProxyProblem(io, acct.account, proxy.symbol, "holds no securities in this export, so its proxy cannot be priced. If the account is now all cash, delete the proxy lot.");
|
|
||||||
return error.ProxyLotUnpriceable;
|
|
||||||
}
|
|
||||||
if (proxy.effectiveShares() <= 0) {
|
|
||||||
reportProxyProblem(io, acct.account, proxy.symbol, "has a proxy lot with no shares. Give it any positive share count; import sets price_ratio.");
|
|
||||||
return error.ProxyLotUnpriceable;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticker = proxy.priceSymbol();
|
|
||||||
const close = try proxyClose(io, svc, fetch_options, ticker, price_date, acct.account);
|
|
||||||
|
|
||||||
var copy = try dupeLot(allocator, proxy.*);
|
|
||||||
copy.price_ratio = proxyRatio(acct.securities_value, copy, close.close);
|
|
||||||
proxies.append(allocator, copy) catch |err| {
|
|
||||||
freeLot(allocator, copy);
|
|
||||||
return err;
|
|
||||||
};
|
|
||||||
try updates.append(allocator, .{
|
|
||||||
.account = acct.account,
|
|
||||||
.symbol = proxy.symbol,
|
|
||||||
.ticker = ticker,
|
|
||||||
.old_ratio = proxy.price_ratio,
|
|
||||||
.new_ratio = copy.price_ratio,
|
|
||||||
.priced_on = close.date,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything that can fail is done except this one allocation; from
|
|
||||||
// here ownership only moves.
|
|
||||||
var kept: usize = 0;
|
|
||||||
for (lots) |lot| {
|
|
||||||
if (!isDirectIndexingSecurity(lot, account_map)) kept += 1;
|
|
||||||
}
|
|
||||||
const result = try allocator.alloc(Lot, kept + proxies.items.len);
|
|
||||||
var i: usize = 0;
|
|
||||||
for (lots) |lot| {
|
|
||||||
if (isDirectIndexingSecurity(lot, account_map)) {
|
|
||||||
freeLot(allocator, lot);
|
|
||||||
} else {
|
|
||||||
result[i] = lot;
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@memcpy(result[i..], proxies.items);
|
|
||||||
proxies.clearRetainingCapacity();
|
|
||||||
allocator.free(lots);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The proxy ticker's close on (or the last trading day before)
|
|
||||||
/// `price_date`.
|
|
||||||
fn proxyClose(
|
|
||||||
io: std.Io,
|
|
||||||
svc: *zfin.DataService,
|
|
||||||
fetch_options: zfin.FetchOptions,
|
|
||||||
ticker: []const u8,
|
|
||||||
price_date: Date,
|
|
||||||
account: []const u8,
|
|
||||||
) !zfin.valuation.CandleAtDate {
|
|
||||||
const candles = svc.getCandles(ticker, fetch_options) catch |err| {
|
|
||||||
if (err == error.Canceled) return err;
|
|
||||||
if (!builtin.is_test) {
|
|
||||||
var msg_buf: [512]u8 = undefined;
|
|
||||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot price {s}, the proxy for {s}: {s}\n", .{ ticker, account, @errorName(err) }) catch "Error: Cannot price a direct-indexing proxy\n";
|
|
||||||
cli.stderrPrint(io, msg);
|
|
||||||
}
|
|
||||||
return error.ProxyLotUnpriceable;
|
|
||||||
};
|
|
||||||
defer candles.deinit();
|
|
||||||
return zfin.valuation.candleCloseOnOrBefore(candles.data, price_date) orelse {
|
|
||||||
if (!builtin.is_test) {
|
|
||||||
var msg_buf: [512]u8 = undefined;
|
|
||||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: No {s} close on or before {f} to price the proxy for {s}\n", .{ ticker, price_date, account }) catch "Error: No close to price a direct-indexing proxy\n";
|
|
||||||
cli.stderrPrint(io, msg);
|
|
||||||
}
|
|
||||||
return error.ProxyLotUnpriceable;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Earliest real buy date among `account`'s non-cash lots, for the
|
|
||||||
/// suggested proxy line.
|
|
||||||
fn earliestOpenDate(lots: []const Lot, account: []const u8) ?Date {
|
|
||||||
var earliest: ?Date = null;
|
|
||||||
for (lots) |lot| {
|
|
||||||
if (lot.security_type == .cash) continue;
|
|
||||||
const lot_account = lot.account orelse continue;
|
|
||||||
if (!std.mem.eql(u8, lot_account, account)) continue;
|
|
||||||
if (lot.open_date.eql(Date.epoch)) continue;
|
|
||||||
if (earliest == null or lot.open_date.lessThan(earliest.?)) earliest = lot.open_date;
|
|
||||||
}
|
|
||||||
return earliest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shares in the suggested proxy line. Arbitrary: import sets the ratio.
|
|
||||||
const suggested_proxy_shares: f64 = 1000;
|
|
||||||
|
|
||||||
/// The proxy line a user can paste to seed a direct-indexing account:
|
|
||||||
/// cost basis preserved via `open_price`, ticker left for them to pick.
|
|
||||||
fn writeSuggestedProxyLine(w: *std.Io.Writer, acct: DirectIndexingAccount, earliest: ?Date) !void {
|
|
||||||
try w.print("symbol::DI-PROXY,ticker::<PROXY>,shares:num:{d},open_date::{f},open_price:num:{d:.4},account::{s},note::direct indexing proxy", .{
|
|
||||||
suggested_proxy_shares,
|
|
||||||
earliest orelse Date.epoch,
|
|
||||||
acct.cost / suggested_proxy_shares,
|
|
||||||
acct.account,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reportProxyRequired(io: std.Io, target_path: []const u8, acct: DirectIndexingAccount, count: usize, earliest: ?Date) void {
|
|
||||||
if (builtin.is_test) return;
|
|
||||||
var buf: [2048]u8 = undefined;
|
|
||||||
var w = std.Io.File.stderr().writer(io, &buf);
|
|
||||||
printProxyRequired(&w.interface, target_path, acct, count, earliest) catch |err| {
|
|
||||||
std.log.debug("direct-indexing message write failed: {t}", .{err});
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
w.interface.flush() catch |err| std.log.debug("direct-indexing message flush failed: {t}", .{err});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn printProxyRequired(w: *std.Io.Writer, target_path: []const u8, acct: DirectIndexingAccount, count: usize, earliest: ?Date) !void {
|
|
||||||
try w.print("Error: {s} is direct_indexing in accounts.srf, so import keeps it as one proxy lot,\n", .{acct.account});
|
|
||||||
if (count == 0) {
|
|
||||||
try w.print(" but {s} has no stock lot for it. Add one:\n", .{target_path});
|
|
||||||
} else {
|
|
||||||
try w.print(" but {s} has {d} stock lots for it. Replace them with one:\n", .{ target_path, count });
|
|
||||||
}
|
|
||||||
try w.writeAll(" ");
|
|
||||||
try writeSuggestedProxyLine(w, acct, earliest);
|
|
||||||
try w.writeAll("\n <PROXY> is a ticker that tracks the account (e.g. VTI). Any share count works:\n" ++
|
|
||||||
" import sets price_ratio so the lot is worth the account's securities.\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reportProxyProblem(io: std.Io, account: []const u8, symbol: []const u8, problem: []const u8) void {
|
|
||||||
if (builtin.is_test) return;
|
|
||||||
var msg_buf: [512]u8 = undefined;
|
|
||||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: {s} (proxy {s}) {s}\n", .{ account, symbol, problem }) catch "Error: direct-indexing proxy problem\n";
|
|
||||||
cli.stderrPrint(io, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Free per-lot allocator-owned strings + the slice. Mirror of the
|
/// Free per-lot allocator-owned strings + the slice. Mirror of the
|
||||||
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
|
/// internal cleanup in `Portfolio.deinit` (which we'd use directly
|
||||||
/// except we don't construct a Portfolio here - `serializePortfolio`
|
/// except we don't construct a Portfolio here - `serializePortfolio`
|
||||||
|
|
@ -2067,293 +1734,3 @@ test "badWellsFargoExport: allocation failure stays an allocation failure" {
|
||||||
try testing.expectError(error.OutOfMemory, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.OutOfMemory)));
|
try testing.expectError(error.OutOfMemory, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.OutOfMemory)));
|
||||||
try testing.expectError(error.InvalidExport, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.NotCompoundFile)));
|
try testing.expectError(error.InvalidExport, @as(error{ InvalidExport, OutOfMemory }!void, badWellsFargoExport(testing.io, "wf.xls", error.NotCompoundFile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Direct-indexing accounts ----
|
|
||||||
|
|
||||||
fn directIndexingTestMap(entries: []analysis.AccountTaxEntry) analysis.AccountMap {
|
|
||||||
return .{ .entries = entries, .allocator = testing.allocator };
|
|
||||||
}
|
|
||||||
|
|
||||||
fn directIndexingTestEntries() [2]analysis.AccountTaxEntry {
|
|
||||||
return .{
|
|
||||||
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234", .direct_indexing = true },
|
|
||||||
.{ .account = "Sample IRA", .tax_type = .traditional, .institution = "wells_fargo", .account_number = "5678" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A Roth with two holdings and cash, plus another account.
|
|
||||||
const direct_indexing_positions = [_]BrokeragePosition{
|
|
||||||
.{ .account_number = "1234", .account_name = "*1234", .symbol = "", .description = "Cash Balance", .quantity = null, .current_value = 50, .cost_basis = null, .is_cash = true },
|
|
||||||
.{ .account_number = "1234", .account_name = "*1234", .symbol = "AAPL", .description = "", .quantity = 10, .current_value = 2000, .cost_basis = 1500, .is_cash = false },
|
|
||||||
.{ .account_number = "1234", .account_name = "*1234", .symbol = "SMPLX", .description = "", .quantity = 100, .current_value = 1000, .cost_basis = null, .is_cash = false },
|
|
||||||
.{ .account_number = "5678", .account_name = "*5678", .symbol = "VTI", .description = "", .quantity = 5, .current_value = 1500, .cost_basis = 1000, .is_cash = false },
|
|
||||||
};
|
|
||||||
|
|
||||||
test "directIndexingAccounts: totals securities per direct-indexing account only" {
|
|
||||||
var entries = directIndexingTestEntries();
|
|
||||||
const accounts = try directIndexingAccounts(testing.allocator, &direct_indexing_positions, directIndexingTestMap(&entries), "wells_fargo");
|
|
||||||
defer testing.allocator.free(accounts);
|
|
||||||
|
|
||||||
try testing.expectEqual(@as(usize, 1), accounts.len);
|
|
||||||
try testing.expectEqualStrings("Sample Roth", accounts[0].account);
|
|
||||||
// Cash excluded; SMPLX has no cost, so its value stands in.
|
|
||||||
try testing.expectEqual(@as(f64, 3000), accounts[0].securities_value);
|
|
||||||
try testing.expectEqual(@as(f64, 2500), accounts[0].cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "directIndexingAccounts: an all-cash direct-indexing account still gets an entry" {
|
|
||||||
var entries = directIndexingTestEntries();
|
|
||||||
const cash_only = [_]BrokeragePosition{direct_indexing_positions[0]};
|
|
||||||
const accounts = try directIndexingAccounts(testing.allocator, &cash_only, directIndexingTestMap(&entries), "wells_fargo");
|
|
||||||
defer testing.allocator.free(accounts);
|
|
||||||
try testing.expectEqual(@as(usize, 1), accounts.len);
|
|
||||||
try testing.expectEqual(@as(f64, 0), accounts[0].securities_value);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "proxyRatio: values the proxy at the account's securities, split-aware" {
|
|
||||||
const proxy: Lot = .{ .symbol = "DI-ROTH", .shares = 100, .open_date = Date.epoch, .open_price = 1, .split_factor = 2 };
|
|
||||||
// 200 effective shares at $15 = $3000 at ratio 1; $3300 needs 1.1.
|
|
||||||
try testing.expectApproxEqAbs(@as(f64, 1.1), proxyRatio(3300, proxy, 15), 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "printProxyRequired: names the problem and suggests a pasteable line" {
|
|
||||||
var buf: [1024]u8 = undefined;
|
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
|
||||||
const acct: DirectIndexingAccount = .{ .account = "Sample Roth", .securities_value = 3000, .cost = 2500 };
|
|
||||||
try printProxyRequired(&w, "portfolio_wells_fargo.srf", acct, 2, Date.fromYmd(2025, 3, 15));
|
|
||||||
const text = w.buffered();
|
|
||||||
try testing.expect(std.mem.indexOf(u8, text, "Sample Roth is direct_indexing") != null);
|
|
||||||
try testing.expect(std.mem.indexOf(u8, text, "has 2 stock lots for it") != null);
|
|
||||||
// Cost basis carried by open_price: 2500 / 1000 shares.
|
|
||||||
try testing.expect(std.mem.indexOf(u8, text, "symbol::DI-PROXY,ticker::<PROXY>,shares:num:1000,open_date::2025-03-15,open_price:num:2.5000,account::Sample Roth") != null);
|
|
||||||
|
|
||||||
var none_buf: [1024]u8 = undefined;
|
|
||||||
var none: std.Io.Writer = .fixed(&none_buf);
|
|
||||||
try printProxyRequired(&none, "portfolio_wells_fargo.srf", acct, 0, null);
|
|
||||||
try testing.expect(std.mem.indexOf(u8, none.buffered(), "has no stock lot for it") != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Owned lots as `synthesizeLots` / `wells_fargo.parseLots` would
|
|
||||||
/// produce for `direct_indexing_positions`.
|
|
||||||
fn directIndexingTestLots(allocator: std.mem.Allocator) ![]Lot {
|
|
||||||
const lots = try allocator.alloc(Lot, 4);
|
|
||||||
var n: usize = 0;
|
|
||||||
errdefer {
|
|
||||||
for (lots[0..n]) |l| freeLot(allocator, l);
|
|
||||||
allocator.free(lots);
|
|
||||||
}
|
|
||||||
const plain = [_]Lot{
|
|
||||||
.{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash },
|
|
||||||
.{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2025, 3, 15), .open_price = 150, .account = "Sample Roth" },
|
|
||||||
.{ .symbol = "SMPLX", .shares = 100, .open_date = Date.epoch, .open_price = 10, .account = "Sample Roth" },
|
|
||||||
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 200, .account = "Sample IRA" },
|
|
||||||
};
|
|
||||||
for (plain) |p| {
|
|
||||||
lots[n] = try dupeLot(allocator, p);
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
return lots;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A cache directory holding VTI candles that end on 2026-10-01.
|
|
||||||
const DirectIndexingFixture = struct {
|
|
||||||
tmp: std.testing.TmpDir,
|
|
||||||
/// Sentinel-terminated, as `realPathFileAlloc` returns it, so the
|
|
||||||
/// free matches the allocation.
|
|
||||||
dir_path: [:0]const u8,
|
|
||||||
svc: zfin.DataService,
|
|
||||||
|
|
||||||
fn init(self: *DirectIndexingFixture, with_candles: bool) !void {
|
|
||||||
const io = testing.io;
|
|
||||||
self.tmp = std.testing.tmpDir(.{});
|
|
||||||
self.dir_path = try self.tmp.dir.realPathFileAlloc(io, ".", testing.allocator);
|
|
||||||
self.svc = zfin.DataService.init(io, testing.allocator, .{ .cache_dir = self.dir_path });
|
|
||||||
if (with_candles) {
|
|
||||||
const candles = [_]zfin.Candle{
|
|
||||||
.{ .date = Date.fromYmd(2026, 9, 30), .open = 290, .high = 300, .low = 290, .close = 295, .adj_close = 295, .volume = 1 },
|
|
||||||
.{ .date = Date.fromYmd(2026, 10, 1), .open = 295, .high = 301, .low = 294, .close = 300, .adj_close = 300, .volume = 1 },
|
|
||||||
};
|
|
||||||
var store = cache.Store.init(io, testing.allocator, self.dir_path);
|
|
||||||
store.cacheCandles("VTI", &candles, .{ .provider = .tiingo }, 9_999_999_999);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deinit(self: *DirectIndexingFixture) void {
|
|
||||||
self.svc.deinit();
|
|
||||||
testing.allocator.free(self.dir_path);
|
|
||||||
self.tmp.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(self: *DirectIndexingFixture, lots: []Lot, prior: []const Lot, price_date: Date, updates: *std.ArrayList(ProxyUpdate)) ![]Lot {
|
|
||||||
var entries = directIndexingTestEntries();
|
|
||||||
return applyDirectIndexing(
|
|
||||||
testing.io,
|
|
||||||
testing.allocator,
|
|
||||||
&self.svc,
|
|
||||||
.{ .skip_network = true },
|
|
||||||
lots,
|
|
||||||
&direct_indexing_positions,
|
|
||||||
directIndexingTestMap(&entries),
|
|
||||||
"wells_fargo",
|
|
||||||
prior,
|
|
||||||
Date.fromYmd(2026, 10, 4),
|
|
||||||
price_date,
|
|
||||||
"portfolio_wells_fargo.srf",
|
|
||||||
updates,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const roth_proxy: Lot = .{
|
|
||||||
.symbol = "DI-ROTH",
|
|
||||||
.ticker = "VTI",
|
|
||||||
.shares = 10,
|
|
||||||
.open_date = Date.fromYmd(2025, 1, 2),
|
|
||||||
.open_price = 250,
|
|
||||||
.account = "Sample Roth",
|
|
||||||
.note = "direct indexing proxy",
|
|
||||||
.label = "Roth sleeve",
|
|
||||||
};
|
|
||||||
|
|
||||||
test "applyDirectIndexing: the account's securities become its re-priced proxy; cash and other accounts untouched" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(true);
|
|
||||||
defer fx.deinit();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(testing.allocator);
|
|
||||||
|
|
||||||
// Priced on a Saturday: the Friday (2026-10-01 here) close is used.
|
|
||||||
const lots = try fx.apply(try directIndexingTestLots(testing.allocator), &.{roth_proxy}, Date.fromYmd(2026, 10, 3), &updates);
|
|
||||||
defer freeLots(testing.allocator, lots);
|
|
||||||
|
|
||||||
try testing.expectEqual(@as(usize, 3), lots.len);
|
|
||||||
try testing.expectEqual(LotType.cash, lots[0].security_type);
|
|
||||||
try testing.expectEqualStrings("Sample Roth", lots[0].account.?);
|
|
||||||
try testing.expectEqualStrings("Sample IRA", lots[1].account.?);
|
|
||||||
try testing.expectEqualStrings("VTI", lots[1].symbol);
|
|
||||||
|
|
||||||
const proxy = lots[2];
|
|
||||||
try testing.expectEqualStrings("DI-ROTH", proxy.symbol);
|
|
||||||
// Worth the account's $3000 of securities at the $300 close used.
|
|
||||||
try testing.expectApproxEqAbs(@as(f64, 3000), proxy.marketValue(300, false), 1e-9);
|
|
||||||
// Everything else exactly as seeded.
|
|
||||||
try testing.expectEqual(@as(f64, 10), proxy.shares);
|
|
||||||
try testing.expect(Date.fromYmd(2025, 1, 2).eql(proxy.open_date));
|
|
||||||
try testing.expectEqual(@as(f64, 250), proxy.open_price);
|
|
||||||
try testing.expectEqualStrings("direct indexing proxy", proxy.note.?);
|
|
||||||
try testing.expectEqualStrings("Roth sleeve", proxy.label.?);
|
|
||||||
|
|
||||||
try testing.expectEqual(@as(usize, 1), updates.items.len);
|
|
||||||
try testing.expectEqual(@as(f64, 1.0), updates.items[0].old_ratio);
|
|
||||||
try testing.expect(Date.fromYmd(2026, 10, 1).eql(updates.items[0].priced_on));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "applyDirectIndexing: a drifted proxy gets a new ratio" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(true);
|
|
||||||
defer fx.deinit();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(testing.allocator);
|
|
||||||
|
|
||||||
var drifted = roth_proxy;
|
|
||||||
drifted.price_ratio = 0.9;
|
|
||||||
// Priced on 2026-09-30: $295 close.
|
|
||||||
const lots = try fx.apply(try directIndexingTestLots(testing.allocator), &.{drifted}, Date.fromYmd(2026, 9, 30), &updates);
|
|
||||||
defer freeLots(testing.allocator, lots);
|
|
||||||
try testing.expectApproxEqAbs(@as(f64, 3000.0 / (10.0 * 295.0)), lots[2].price_ratio, 1e-12);
|
|
||||||
try testing.expectEqual(@as(f64, 0.9), updates.items[0].old_ratio);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "applyDirectIndexing: no direct-indexing account leaves the lots as they are" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(true);
|
|
||||||
defer fx.deinit();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(testing.allocator);
|
|
||||||
|
|
||||||
var entries = [_]analysis.AccountTaxEntry{
|
|
||||||
.{ .account = "Sample Roth", .tax_type = .roth, .institution = "wells_fargo", .account_number = "1234" },
|
|
||||||
};
|
|
||||||
const lots = try directIndexingTestLots(testing.allocator);
|
|
||||||
defer freeLots(testing.allocator, lots);
|
|
||||||
const same = try applyDirectIndexing(testing.io, testing.allocator, &fx.svc, .{ .skip_network = true }, lots, &direct_indexing_positions, directIndexingTestMap(&entries), "wells_fargo", &.{}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates);
|
|
||||||
try testing.expectEqual(lots.ptr, same.ptr);
|
|
||||||
try testing.expectEqual(@as(usize, 0), updates.items.len);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "applyDirectIndexing: refusals leave the lots intact" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(true);
|
|
||||||
defer fx.deinit();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(testing.allocator);
|
|
||||||
|
|
||||||
const lots = try directIndexingTestLots(testing.allocator);
|
|
||||||
// Ownership stays with this test on every refusal; freed once.
|
|
||||||
defer freeLots(testing.allocator, lots);
|
|
||||||
|
|
||||||
// No proxy in the existing file.
|
|
||||||
try testing.expectError(error.ProxyLotRequired, fx.apply(lots, &.{}, Date.fromYmd(2026, 10, 3), &updates));
|
|
||||||
// Two candidates: ambiguous.
|
|
||||||
try testing.expectError(error.ProxyLotRequired, fx.apply(lots, &.{ roth_proxy, roth_proxy }, Date.fromYmd(2026, 10, 3), &updates));
|
|
||||||
// A proxy with no shares cannot be priced.
|
|
||||||
var empty = roth_proxy;
|
|
||||||
empty.shares = 0;
|
|
||||||
try testing.expectError(error.ProxyLotUnpriceable, fx.apply(lots, &.{empty}, Date.fromYmd(2026, 10, 3), &updates));
|
|
||||||
// No close on or before the price date.
|
|
||||||
try testing.expectError(error.ProxyLotUnpriceable, fx.apply(lots, &.{roth_proxy}, Date.fromYmd(2026, 1, 2), &updates));
|
|
||||||
try testing.expectEqual(@as(usize, 0), updates.items.len);
|
|
||||||
try testing.expectEqual(@as(usize, 4), lots.len);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "applyDirectIndexing: a proxy whose ticker has no candles is refused" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(false);
|
|
||||||
defer fx.deinit();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(testing.allocator);
|
|
||||||
const lots = try directIndexingTestLots(testing.allocator);
|
|
||||||
defer freeLots(testing.allocator, lots);
|
|
||||||
try testing.expectError(error.ProxyLotUnpriceable, fx.apply(lots, &.{roth_proxy}, Date.fromYmd(2026, 10, 3), &updates));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "applyDirectIndexing: an all-cash account is fine without a proxy, refused with one" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(true);
|
|
||||||
defer fx.deinit();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(testing.allocator);
|
|
||||||
|
|
||||||
var entries = directIndexingTestEntries();
|
|
||||||
const cash_only = [_]BrokeragePosition{direct_indexing_positions[0]};
|
|
||||||
const cash_lot: Lot = .{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash };
|
|
||||||
|
|
||||||
const lots = try testing.allocator.alloc(Lot, 1);
|
|
||||||
lots[0] = try dupeLot(testing.allocator, cash_lot);
|
|
||||||
const kept = try applyDirectIndexing(testing.io, testing.allocator, &fx.svc, .{ .skip_network = true }, lots, &cash_only, directIndexingTestMap(&entries), "wells_fargo", &.{}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates);
|
|
||||||
defer freeLots(testing.allocator, kept);
|
|
||||||
try testing.expectEqual(@as(usize, 1), kept.len);
|
|
||||||
try testing.expectEqual(LotType.cash, kept[0].security_type);
|
|
||||||
|
|
||||||
try testing.expectError(error.ProxyLotUnpriceable, applyDirectIndexing(testing.io, testing.allocator, &fx.svc, .{ .skip_network = true }, kept, &cash_only, directIndexingTestMap(&entries), "wells_fargo", &.{roth_proxy}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates));
|
|
||||||
}
|
|
||||||
|
|
||||||
test "applyDirectIndexing: every allocation failure is clean" {
|
|
||||||
var fx: DirectIndexingFixture = undefined;
|
|
||||||
try fx.init(true);
|
|
||||||
defer fx.deinit();
|
|
||||||
const S = struct {
|
|
||||||
fn run(allocator: std.mem.Allocator, svc: *zfin.DataService) !void {
|
|
||||||
var entries = directIndexingTestEntries();
|
|
||||||
var updates: std.ArrayList(ProxyUpdate) = .empty;
|
|
||||||
defer updates.deinit(allocator);
|
|
||||||
const lots = try directIndexingTestLots(allocator);
|
|
||||||
const result = applyDirectIndexing(testing.io, allocator, svc, .{ .skip_network = true }, lots, &direct_indexing_positions, directIndexingTestMap(&entries), "wells_fargo", &.{roth_proxy}, Date.fromYmd(2026, 10, 4), Date.fromYmd(2026, 10, 3), "p.srf", &updates) catch |err| {
|
|
||||||
freeLots(allocator, lots);
|
|
||||||
return err;
|
|
||||||
};
|
|
||||||
freeLots(allocator, result);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try testing.checkAllAllocationFailures(testing.allocator, S.run, .{&fx.svc});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -843,35 +843,6 @@ pub const Position = struct {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The result of looking for a direct-indexing account's proxy lot.
|
|
||||||
pub const DirectIndexingProxy = union(enum) {
|
|
||||||
found: *const Lot,
|
|
||||||
/// How many open stock lots the account has instead (0 or 2+).
|
|
||||||
not_unique: usize,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The proxy lot of a direct-indexing account (`direct_indexing` in
|
|
||||||
/// accounts.srf): its ONE open stock lot, a `ticker::` alias whose
|
|
||||||
/// `price_ratio` stands in for every holding the account really has.
|
|
||||||
///
|
|
||||||
/// The single definition of "which lot is the proxy", shared by import
|
|
||||||
/// (which re-prices it from an export) and audit (which compares the
|
|
||||||
/// account's export total against it and suggests a ratio). Zero or
|
|
||||||
/// several candidates is reported, never resolved by guessing.
|
|
||||||
pub fn directIndexingProxy(lots: []const Lot, account: []const u8, as_of: Date) DirectIndexingProxy {
|
|
||||||
var found: ?*const Lot = null;
|
|
||||||
var count: usize = 0;
|
|
||||||
for (lots) |*lot| {
|
|
||||||
if (lot.security_type != .stock) continue;
|
|
||||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
|
||||||
const lot_account = lot.account orelse continue;
|
|
||||||
if (!std.mem.eql(u8, lot_account, account)) continue;
|
|
||||||
found = lot;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
return if (count == 1) .{ .found = found.? } else .{ .not_unique = count };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A portfolio is a collection of lots.
|
/// A portfolio is a collection of lots.
|
||||||
pub const Portfolio = struct {
|
pub const Portfolio = struct {
|
||||||
lots: []Lot,
|
lots: []Lot,
|
||||||
|
|
@ -1939,26 +1910,6 @@ test "isOpen respects maturity_date" {
|
||||||
try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8)));
|
try std.testing.expect(stock.isOpen(Date.fromYmd(2026, 5, 8)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- directIndexingProxy ----
|
|
||||||
|
|
||||||
test "directIndexingProxy: the account's one open stock lot, else how many there are" {
|
|
||||||
const as_of = Date.fromYmd(2026, 10, 4);
|
|
||||||
const lots = [_]Lot{
|
|
||||||
.{ .symbol = "DI-ROTH", .ticker = "VTI", .shares = 100, .open_date = Date.fromYmd(2025, 1, 2), .open_price = 30, .account = "Sample Roth" },
|
|
||||||
// Not candidates: cash, a closed lot, another account's lot.
|
|
||||||
.{ .symbol = "", .shares = 50, .open_date = Date.epoch, .open_price = 1, .account = "Sample Roth", .security_type = .cash },
|
|
||||||
.{ .symbol = "OLD", .shares = 1, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 1, .close_date = Date.fromYmd(2025, 1, 2), .close_price = 2, .account = "Sample Roth" },
|
|
||||||
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2024, 1, 2), .open_price = 200, .account = "Sample IRA" },
|
|
||||||
};
|
|
||||||
switch (directIndexingProxy(&lots, "Sample Roth", as_of)) {
|
|
||||||
.found => |p| try std.testing.expectEqualStrings("DI-ROTH", p.symbol),
|
|
||||||
.not_unique => return error.TestUnexpectedResult,
|
|
||||||
}
|
|
||||||
try std.testing.expectEqual(DirectIndexingProxy{ .not_unique = 0 }, directIndexingProxy(&lots, "Sample HSA", as_of));
|
|
||||||
const two = [_]Lot{ lots[0], lots[0] };
|
|
||||||
try std.testing.expectEqual(DirectIndexingProxy{ .not_unique = 2 }, directIndexingProxy(&two, "Sample Roth", as_of));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── lotIsOpenAsOf ────────────────────────────────────────────
|
// ── lotIsOpenAsOf ────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
|
// `isOpen()` asks "is this lot held right now (wall-clock today)?"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue