single definition of closed lot
This commit is contained in:
parent
b3239eefda
commit
c0dca12623
13 changed files with 617 additions and 238 deletions
12
AGENTS.md
12
AGENTS.md
|
|
@ -133,6 +133,13 @@ already exist and have caught me out:
|
|||
branches are load-bearing. Render it via `cli.printDirection`
|
||||
(CLI) or `theme.directionStyle` (TUI) - never map the enum by
|
||||
hand at a call site.
|
||||
- `Lot.lotIsOpenAsOf` / `isSoldAsOf` / `hasMaturedAsOf` /
|
||||
`hasEndedAsOf` / `closePriceAsOf` / `realizedGainLossAsOf` - **the**
|
||||
answers to "is this lot held / sold / over, and at what price". They
|
||||
are different questions on purpose: `contributions` needs *sold* (an
|
||||
expired option recorded with a close is still a sale), holdings need
|
||||
*held*. There used to be three definitions of "closed" that disagreed;
|
||||
never test `close_date` or `close_price` directly outside `Lot`.
|
||||
|
||||
**Never open-code a per-day up/down comparison.** `close >= open` is
|
||||
unconditionally true for anything priced once per day at NAV - mutual
|
||||
|
|
@ -177,6 +184,11 @@ grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/
|
|||
# Per-day up/down direction - there must be exactly one rule.
|
||||
# Any hit outside models/candle.zig is a bug:
|
||||
grep -rn "close >= .*open\|close > .*open" src/
|
||||
|
||||
# Lot liveness - held/sold/ended/matured are defined ONCE on `Lot`.
|
||||
# Any close_date/close_price read outside models/portfolio.zig is a bug
|
||||
# (maturity_date reads for sorting, display or identity are fine):
|
||||
grep -rn "\.close_date\|\.close_price" src/ | grep -v "models/portfolio.zig" | grep -v "test \"\|expect\|close_date = \|close_price = \|close_date:\|close_price:"
|
||||
```
|
||||
|
||||
If the search turns up an existing helper that does what you need,
|
||||
|
|
|
|||
|
|
@ -174,18 +174,16 @@ cannot be out of date.
|
|||
|
||||
#### 2. Lot dates (`portfolio*.srf`)
|
||||
|
||||
These fields parse fine but describe a lot that can't exist. They matter
|
||||
more than they look: most of zfin decides whether a lot is held by
|
||||
comparing `close_date` to today, but `zfin contributions` treats a lot
|
||||
as sold the moment it has any `close_date` at all -- so a bad date makes
|
||||
the two disagree.
|
||||
These fields parse fine but describe a lot that can't exist. Each one
|
||||
changes what zfin counts -- a future `close_date` keeps the lot held
|
||||
until that day -- and each is almost certainly not what you meant.
|
||||
|
||||
| Report | What zfin does with it |
|
||||
|--------------------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `close_date ... is in the future` | Counts the lot as held until that date. Usually a mistyped year. |
|
||||
| `close_date ... is before open_date` | The lot is never counted as held on any date. |
|
||||
| `has close_date but no close_price` | Stock lots only. The sale's realized gain is recorded as 0. |
|
||||
| `has close_price but no close_date` | The lot stays held. On a stock lot, its row shows the close price instead of the live one. |
|
||||
| `has close_price but no close_date` | The lot stays held, and the close price is ignored. |
|
||||
| `open_date ... is in the future` | The lot is left out of your positions until that date. |
|
||||
| `price_date ... is in the future` | Stock lots only. The manual price counts as fresh, so it is never flagged as stale. |
|
||||
|
||||
|
|
|
|||
|
|
@ -132,9 +132,12 @@ two things beyond bookkeeping:
|
|||
enough for a recent sale and wrong for an old one.
|
||||
|
||||
A `close_date` must not be in the future. A close dated today is already
|
||||
closed, but a later date leaves the lot counted as held until then --
|
||||
while `zfin contributions` treats it as sold the moment the field
|
||||
appears. For a scheduled end (a CD or option), use `maturity_date`.
|
||||
closed, but a later date leaves the lot counted as held -- everywhere,
|
||||
including `zfin contributions` -- until that day. For a scheduled end (a
|
||||
CD or option), use `maturity_date`.
|
||||
|
||||
A `close_price` only counts once its `close_date` has arrived. Until
|
||||
then the lot is held and priced live, like any other held lot.
|
||||
|
||||
`zfin audit` and `zfin doctor` flag these lot-lifecycle mistakes:
|
||||
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ pub fn compareAccounts(
|
|||
|
||||
if (bp.is_cash) {
|
||||
saw_cash_row = true;
|
||||
pf_shares = portfolio.cashForAccount(portfolio_acct_name.?);
|
||||
pf_shares = portfolio.cashForAccount(as_of, portfolio_acct_name.?);
|
||||
pf_value = pf_shares;
|
||||
} else {
|
||||
const acct_positions = portfolio.positionsForAccount(as_of, allocator, portfolio_acct_name.?) catch &.{};
|
||||
|
|
@ -554,7 +554,7 @@ pub fn compareAccounts(
|
|||
// the balance is zero. Absence is strong evidence, not proof, and
|
||||
// `audit` exists to surface things for a human to decide.
|
||||
if (!saw_cash_row) {
|
||||
const pf_cash = portfolio.cashForAccount(pa);
|
||||
const pf_cash = portfolio.cashForAccount(as_of, pa);
|
||||
if (@abs(pf_cash) > cash_tolerance) {
|
||||
portfolio_total += pf_cash;
|
||||
has_discrepancies = true;
|
||||
|
|
@ -1719,3 +1719,38 @@ test "ratioSuggestions: cash/option/only rows and missing prices are skipped" {
|
|||
defer allocator.free(sugg);
|
||||
try std.testing.expectEqual(@as(usize, 0), sugg.len);
|
||||
}
|
||||
|
||||
test "compareAccounts: a cash lot closed in the portfolio is not flagged portfolio-only" {
|
||||
// The companion to "cash the broker no longer reports": once the
|
||||
// user records the close, the lot is gone as of today. It used to
|
||||
// still be counted by `cashForAccount`, so closing it didn't clear
|
||||
// the flag - only deleting the line did.
|
||||
const allocator = std.testing.allocator;
|
||||
var lots = [_]portfolio_mod.Lot{
|
||||
.{ .symbol = "FDRXX", .shares = 39.08, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 1.0, .security_type = .cash, .account = "Sample 401k BL", .close_date = Date.fromYmd(2026, 7, 1) },
|
||||
.{ .symbol = "FDSCX", .shares = 3018.809, .open_date = Date.fromYmd(2026, 2, 26), .open_price = 33.82, .account = "Sample 401k BL" },
|
||||
};
|
||||
const portfolio = portfolio_mod.Portfolio{ .lots = &lots, .allocator = allocator };
|
||||
|
||||
var brokerage = [_]BrokeragePosition{
|
||||
.{ .account_number = "1234", .account_name = "BrokerageLink", .symbol = "FDSCX", .description = "FIDELITY STOCK SELECTOR SMALL CAP", .quantity = 3018.809, .current_value = 152963.05, .cost_basis = 104700.15, .is_cash = false },
|
||||
};
|
||||
var entries = [_]analysis.AccountTaxEntry{
|
||||
.{ .account = "Sample 401k BL", .tax_type = .traditional, .institution = "fidelity", .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("FDSCX", 50.67);
|
||||
|
||||
const results = try compareAccounts(allocator, portfolio, &brokerage, acct_map, "fidelity", prices, Date.fromYmd(2026, 8, 1));
|
||||
defer {
|
||||
for (results) |r| allocator.free(r.comparisons);
|
||||
allocator.free(results);
|
||||
}
|
||||
try std.testing.expectEqual(@as(usize, 1), results.len);
|
||||
for (results[0].comparisons) |cmp| {
|
||||
try std.testing.expect(!(cmp.is_cash and cmp.only_in_portfolio));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ pub fn compareSchwabSummary(
|
|||
var expectation: common.AccountValueExpectation = .{};
|
||||
|
||||
if (portfolio_acct) |pa| {
|
||||
pf_cash = portfolio.cashForAccount(pa);
|
||||
pf_cash = portfolio.cashForAccount(as_of, pa);
|
||||
pf_total = portfolio.totalForAccount(as_of, allocator, pa, prices);
|
||||
expectation = common.accountValueExpectation(portfolio, as_of, pa);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -487,6 +487,14 @@ pub fn portfolioSummary(
|
|||
var total_realized: f64 = 0;
|
||||
|
||||
for (positions) |pos| {
|
||||
// Realized P&L first, before either skip below. It is already
|
||||
// known from the closed lots and needs no price - and the
|
||||
// positions it matters most for are the ones those skips drop:
|
||||
// a fully sold symbol (`shares == 0`) exists here ONLY to carry
|
||||
// its realized gain, and used to be discarded along with it.
|
||||
// Same for a held symbol with no current price.
|
||||
total_realized += pos.realized_gain_loss;
|
||||
|
||||
if (pos.shares <= 0) continue;
|
||||
const raw_price = prices.get(pos.symbol) orelse continue;
|
||||
const is_manual = if (manual_prices) |mp| mp.contains(pos.symbol) else false;
|
||||
|
|
@ -494,7 +502,6 @@ pub fn portfolioSummary(
|
|||
const mv = pos.marketValue(raw_price, is_manual);
|
||||
total_value += mv;
|
||||
total_cost += pos.total_cost;
|
||||
total_realized += pos.realized_gain_loss;
|
||||
|
||||
try allocs.append(allocator, .{
|
||||
.symbol = pos.symbol,
|
||||
|
|
@ -2614,3 +2621,30 @@ test "mergeAllocsBySymbol rolls up same-ticker different-ratio allocations" {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "portfolioSummary: realized P&L includes fully sold and unpriced symbols" {
|
||||
// Regression: the `shares <= 0` and no-price skips ran BEFORE the
|
||||
// realized sum, so a symbol you had sold entirely - the most common
|
||||
// source of realized gains - never reached "Realized P&L".
|
||||
const alloc = std.testing.allocator;
|
||||
var prices = std.StringHashMap(f64).init(alloc);
|
||||
defer prices.deinit();
|
||||
try prices.put("HELD", 20.0);
|
||||
|
||||
const positions = [_]portfolio_mod.Position{
|
||||
// Held and priced, with a partial sale behind it.
|
||||
.{ .symbol = "HELD", .shares = 10, .avg_cost = 10, .total_cost = 100, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = 30 },
|
||||
// Fully sold: exists only to carry its realized gain.
|
||||
.{ .symbol = "SOLD", .shares = 0, .avg_cost = 0, .total_cost = 0, .open_lots = 0, .closed_lots = 2, .realized_gain_loss = 500 },
|
||||
// Held but no current price; its realized loss still counts.
|
||||
.{ .symbol = "NOPRICE", .shares = 5, .avg_cost = 8, .total_cost = 40, .open_lots = 1, .closed_lots = 1, .realized_gain_loss = -20 },
|
||||
};
|
||||
const empty_pf = portfolio_mod.Portfolio{ .lots = &.{}, .allocator = alloc };
|
||||
var summary = try portfolioSummary(Date.fromYmd(2026, 5, 8), alloc, empty_pf, &positions, prices, null);
|
||||
defer summary.deinit(alloc);
|
||||
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 510), summary.realized_gain_loss, 0.001);
|
||||
// The skips still apply to everything else: only HELD is valued.
|
||||
try std.testing.expectEqual(@as(usize, 1), summary.allocations.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 200), summary.total_value, 0.001);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1737,8 +1737,8 @@ fn outflowUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
|
|||
/// (on the trade that motivated this, by $48,963.65 on $467k), which
|
||||
/// is why the fallback defers to `outflowUnitValue`'s current-price
|
||||
/// proxy rather than reaching for `open_price`.
|
||||
fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64)) f64 {
|
||||
if (lot.close_price) |cp| {
|
||||
fn closeUnitValue(lot: Lot, prices: *const std.StringHashMap(f64), as_of: Date) f64 {
|
||||
if (lot.closePriceAsOf(as_of)) |cp| {
|
||||
return switch (lot.security_type) {
|
||||
// close_price is in the lot's own share-class terms
|
||||
// (preadjusted), so the ratio must NOT be applied again.
|
||||
|
|
@ -2115,9 +2115,14 @@ fn computeReport(
|
|||
// metadata comparison: a lot that was both closed and
|
||||
// repriced in the same window is a sale, not a price
|
||||
// edit. Checked first for exactly that reason.
|
||||
//
|
||||
// "Sold", not "ended": an expired option or matured CD
|
||||
// has already ended on both sides, so the documented
|
||||
// record-the-close-after-expiry workflow would never
|
||||
// show a transition. See `Lot.isSoldAsOf`.
|
||||
const before_lot = before_agg.lot;
|
||||
if (before_lot.close_date == null and lot.close_date != null) {
|
||||
const unit_value = closeUnitValue(lot, prices);
|
||||
if (!before_lot.isSoldAsOf(as_of) and lot.isSoldAsOf(as_of)) {
|
||||
const unit_value = closeUnitValue(lot, prices, as_of);
|
||||
try changes.append(allocator, .{
|
||||
.kind = .position_closed,
|
||||
.symbol = sym,
|
||||
|
|
@ -2213,16 +2218,10 @@ fn computeReport(
|
|||
|
||||
var kind: ChangeKind = .lot_removed;
|
||||
if (lot.security_type == .cd) {
|
||||
if (lot.maturity_date) |mat| {
|
||||
// "matured" if maturity_date <= as_of (i.e. NOT as_of.lessThan(mat))
|
||||
if (!as_of.lessThan(mat)) {
|
||||
kind = .cd_matured;
|
||||
} else {
|
||||
kind = .cd_removed_early;
|
||||
}
|
||||
} else {
|
||||
kind = .cd_removed_early; // no maturity - treat as flagged-ish
|
||||
}
|
||||
// Matured specifically, not "ended": a CD redeemed early
|
||||
// (a past `close_date`) and then deleted was still removed
|
||||
// early. No maturity at all is treated the same way.
|
||||
kind = if (lot.hasMaturedAsOf(as_of)) .cd_matured else .cd_removed_early;
|
||||
}
|
||||
|
||||
// Value the outflow. `unit_value` makes `value()` the (negative)
|
||||
|
|
@ -8332,3 +8331,49 @@ test "collectUnmatchedLargeLots: a large cash_contribution surfaces, undated, al
|
|||
}
|
||||
try std.testing.expect(cash_seen and stock_seen);
|
||||
}
|
||||
|
||||
test "computeReport: an expired option recorded with a close afterwards is still a sale" {
|
||||
// Why the close transition tests "sold", not "ended": the option
|
||||
// ended at maturity on BOTH sides, so an ended-based test would see
|
||||
// no transition and the documented record-the-close-after-expiry
|
||||
// workflow would emit nothing.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const allocator = arena_state.allocator();
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const opt: Lot = .{ .symbol = "AAPL 06/20/2025 200.00 C", .shares = -2, .open_date = Date.fromYmd(2025, 1, 15), .open_price = 12.5, .security_type = .option, .maturity_date = Date.fromYmd(2025, 6, 20), .underlying = "AAPL", .strike = 200, .account = "Sample Brokerage" };
|
||||
var closed = opt;
|
||||
closed.close_date = Date.fromYmd(2025, 6, 20);
|
||||
closed.close_price = 0;
|
||||
|
||||
const before = [_]Lot{opt};
|
||||
const after = [_]Lot{closed};
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2025, 7, 1), .{});
|
||||
var saw_close = false;
|
||||
for (report.changes) |c| {
|
||||
if (c.kind == .position_closed) saw_close = true;
|
||||
}
|
||||
try std.testing.expect(saw_close);
|
||||
}
|
||||
|
||||
test "computeReport: a close_date that hasn't arrived is not yet a sale" {
|
||||
// The other side of the same definition. The lint flags a future
|
||||
// close_date; until it arrives, the lot is held and no sale is booked.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const allocator = arena_state.allocator();
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
const lot: Lot = .{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 15), .open_price = 200, .account = "Sample Brokerage" };
|
||||
var later = lot;
|
||||
later.close_date = Date.fromYmd(2062, 3, 14);
|
||||
later.close_price = 300;
|
||||
|
||||
const before = [_]Lot{lot};
|
||||
const after = [_]Lot{later};
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
|
||||
for (report.changes) |c| try std.testing.expect(c.kind != .position_closed);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
var prior_lookup_opt: ?PriorLotsLookup = null;
|
||||
defer if (prior_lookup_opt) |*p| p.deinit();
|
||||
if (prior_portfolio_opt) |pf| {
|
||||
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots);
|
||||
prior_lookup_opt = try PriorLotsLookup.init(allocator, pf.lots, ctx.today);
|
||||
}
|
||||
|
||||
// ── Synthesize lots ───────────────────────────────────────
|
||||
|
|
@ -585,7 +585,7 @@ const PriorLotsLookup = struct {
|
|||
map: std.StringHashMap(*const portfolio_mod.Lot),
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot) !PriorLotsLookup {
|
||||
fn init(allocator: std.mem.Allocator, lots: []const portfolio_mod.Lot, today: Date) !PriorLotsLookup {
|
||||
var map = std.StringHashMap(*const portfolio_mod.Lot).init(allocator);
|
||||
errdefer {
|
||||
var it = map.keyIterator();
|
||||
|
|
@ -594,12 +594,14 @@ const PriorLotsLookup = struct {
|
|||
}
|
||||
|
||||
for (lots) |*lot| {
|
||||
// Skip closed lots: they shouldn't anchor a re-import's
|
||||
// Skip sold lots: they shouldn't anchor a re-import's
|
||||
// open_date for a position the brokerage shows as held.
|
||||
// (Today's import doesn't write `close_date`/`close_price`
|
||||
// anyway, so this is also defensive against hand-edited
|
||||
// closed lots in the file.)
|
||||
if (lot.close_date != null) continue;
|
||||
// closed lots in the file.) "Sold", not "ended": import
|
||||
// carries `maturity_date` forward, so a brokered CD
|
||||
// re-imported on its maturity day must still anchor.
|
||||
if (lot.isSoldAsOf(today)) continue;
|
||||
// Cash lots have no symbol/account-meaningful identity
|
||||
// for matching across imports - skip.
|
||||
if (lot.security_type == .cash) continue;
|
||||
|
|
@ -994,7 +996,7 @@ test "synthesizeLots: lots are byte-identical across imports when prior_lookup m
|
|||
.note = "imported fidelity 2024-01-15",
|
||||
},
|
||||
};
|
||||
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior_lookup = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior_lookup.deinit();
|
||||
|
||||
const lots_a = try synthesizeLots(testing.io, allocator, &positions, account_map, .{ .fidelity = "" }, Date.fromYmd(2026, 5, 21), prior_lookup);
|
||||
|
|
@ -1135,7 +1137,7 @@ test "synthesizeLots: prior lot for (symbol, account) preserves open_date and op
|
|||
.note = "imported fidelity 2024-06-01",
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
// Export shows 120 shares now (user bought more) at avg
|
||||
|
|
@ -1188,7 +1190,7 @@ test "synthesizeLots: every hand-edited field is preserved on re-import" {
|
|||
.drip = true,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1258,7 +1260,7 @@ test "synthesizeLots: new position with no prior match gets sentinel + today's n
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1320,7 +1322,7 @@ test "synthesizeLots: when prior has multiple lots for same (symbol, account), e
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1360,7 +1362,7 @@ test "synthesizeLots: prior closed lot does NOT anchor a held position" {
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
const positions = [_]BrokeragePosition{
|
||||
|
|
@ -1407,7 +1409,7 @@ test "synthesizeLots: positions dropped from new export are excluded (closed-lot
|
|||
.security_type = .stock,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
// Export only shows AAPL (user sold all GOOG).
|
||||
|
|
@ -1440,7 +1442,7 @@ test "PriorLotsLookup: cash lots are excluded from the lookup" {
|
|||
.security_type = .cash,
|
||||
},
|
||||
};
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots);
|
||||
var prior = try PriorLotsLookup.init(allocator, &prior_lots, Date.fromYmd(2026, 5, 21));
|
||||
defer prior.deinit();
|
||||
|
||||
try testing.expect((try prior.find("FZFXX", "Sample Brokerage")) == null);
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ pub fn display(
|
|||
summary.unrealized_gain_loss,
|
||||
watch_symbols,
|
||||
watch_prices,
|
||||
as_of,
|
||||
);
|
||||
try out.print("\n", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
|
|
@ -436,10 +437,10 @@ pub fn display(
|
|||
|
||||
// Lot detail rows (always expanded for CLI)
|
||||
if (is_multi) {
|
||||
// Check if any lots are DRIP
|
||||
// Check if any lots go into the DRIP summary
|
||||
var has_drip = false;
|
||||
for (lots_for_sym.items) |lot| {
|
||||
if (lot.drip) {
|
||||
if (fmt.isDripSummarized(as_of, lot)) {
|
||||
has_drip = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -448,13 +449,13 @@ pub fn display(
|
|||
if (!has_drip) {
|
||||
// No DRIP: show all individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
|
||||
}
|
||||
} else {
|
||||
// Show non-DRIP lots individually
|
||||
// Show every lot the summary doesn't cover individually
|
||||
for (lots_for_sym.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot), w);
|
||||
if (!fmt.isDripSummarized(as_of, lot)) {
|
||||
try printLotRow(as_of, out, color, lot, views.effectivePriceFor(summary.allocations, lot, as_of), w);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -605,8 +606,10 @@ pub fn display(
|
|||
try out.print("{s}\n", .{fmt.fmtCashSep(&cash_sep_buf)});
|
||||
try cli.reset(out, color);
|
||||
|
||||
// Held lots only, so the rows add up to the TOTAL below.
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .cash) continue;
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
const acct2: []const u8 = lot.account orelse "Unknown";
|
||||
var row_buf: [160]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtCashRow(&row_buf, acct2, lot.shares, lot.note)});
|
||||
|
|
@ -629,8 +632,10 @@ pub fn display(
|
|||
try out.print("{s}\n", .{fmt.fmtIlliquidSep(&il_sep_buf1)});
|
||||
try cli.reset(out, color);
|
||||
|
||||
// Held lots only, so the rows add up to the TOTAL below.
|
||||
for (portfolio.lots) |lot| {
|
||||
if (lot.security_type != .illiquid) continue;
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
var il_row_buf: [160]u8 = undefined;
|
||||
try out.print("{s}\n", .{fmt.fmtIlliquidRow(&il_row_buf, lot.displaySymbol(), lot.shares, lot.note)});
|
||||
}
|
||||
|
|
@ -683,7 +688,7 @@ pub fn display(
|
|||
/// Render one lot-detail row under its position row.
|
||||
///
|
||||
/// `effective_price` is the LOT's price, not its position's: pass
|
||||
/// `views.effectivePriceFor(allocations, lot)`, which applies the lot's
|
||||
/// `views.effectivePriceFor(allocations, lot, as_of)`, which applies the lot's
|
||||
/// `price_ratio` and resolves `close_price`. Passing an
|
||||
/// `Allocation.current_price` straight through is the bug this
|
||||
/// parameter name exists to prevent - that price is the raw base-ticker
|
||||
|
|
@ -1471,7 +1476,7 @@ test "printLotRow: renders effective (split-adjusted) shares, cost, and value" {
|
|||
};
|
||||
const no_allocs: []const zfin.valuation.Allocation = &.{};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
|
||||
const widths = views.computeWidths(no_allocs, &lots, 0, 0, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
|
|
@ -1517,11 +1522,11 @@ test "printLotRow: a ratio'd lot values off its effective price and shows it" {
|
|||
.unrealized_return = 0.875,
|
||||
}};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null);
|
||||
const widths = views.computeWidths(&allocs, &lots, 927824.086, 433004.078, no_watch, null, zfin.Date.fromYmd(2026, 6, 1));
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
var w = std.Io.Writer.fixed(&buf);
|
||||
const eff_price = views.effectivePriceFor(&allocs, lots[0]);
|
||||
const eff_price = views.effectivePriceFor(&allocs, lots[0], zfin.Date.fromYmd(2026, 6, 1));
|
||||
try printLotRow(zfin.Date.fromYmd(2026, 8, 26), &w, false, lots[0], eff_price, widths);
|
||||
const out = w.buffered();
|
||||
|
||||
|
|
|
|||
|
|
@ -742,10 +742,19 @@ pub const DripAggregation = struct {
|
|||
|
||||
/// Aggregate DRIP lots into short-term and long-term buckets.
|
||||
/// Classifies using `capitalGainsIndicator` (LT if held > 1 year).
|
||||
/// Is `lot` folded into the DRIP ST/LT summary rather than shown as its
|
||||
/// own row? Only held DRIP lots are: a sold one is a closed lot like
|
||||
/// any other, so it gets an individual row with its closed status, and
|
||||
/// the summary's share count stays what you actually hold. The one test
|
||||
/// for the aggregator and both renderers (CLI `portfolio`, TUI tab).
|
||||
pub fn isDripSummarized(as_of: Date, lot: Lot) bool {
|
||||
return lot.drip and lot.lotIsOpenAsOf(as_of);
|
||||
}
|
||||
|
||||
pub fn aggregateDripLots(as_of: Date, lots: []const Lot) DripAggregation {
|
||||
var result: DripAggregation = .{};
|
||||
for (lots) |lot| {
|
||||
if (!lot.drip) continue;
|
||||
if (!isDripSummarized(as_of, lot)) continue;
|
||||
const is_lt = std.mem.eql(u8, capitalGainsIndicator(as_of, lot.open_date), "LT");
|
||||
const bucket: *DripSummary = if (is_lt) &result.lt else &result.st;
|
||||
bucket.lot_count += 1;
|
||||
|
|
@ -1786,3 +1795,31 @@ test "fmtSharpeOpt: asterisk and decimals" {
|
|||
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
|
||||
try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
|
||||
}
|
||||
|
||||
test "aggregateDripLots: sold DRIP lots stay out of the summary" {
|
||||
// The summary is a count of what you hold. A sold DRIP lot used to
|
||||
// be folded in, inflating shares and cost; it now gets its own
|
||||
// closed row instead (`isDripSummarized`).
|
||||
const as_of = Date.fromYmd(2026, 1, 1);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "VTI", .shares = 1.5, .open_date = Date.fromYmd(2023, 3, 1), .open_price = 200, .drip = true },
|
||||
.{ .symbol = "VTI", .shares = 2.0, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 210, .drip = true, .close_date = Date.fromYmd(2025, 12, 1), .close_price = 280 },
|
||||
.{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2023, 1, 1), .open_price = 190 },
|
||||
};
|
||||
const agg = aggregateDripLots(as_of, &lots);
|
||||
try std.testing.expectEqual(@as(usize, 1), agg.lt.lot_count);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1.5), agg.lt.shares, 0.001);
|
||||
try std.testing.expect(agg.st.isEmpty());
|
||||
}
|
||||
|
||||
test "isDripSummarized: held DRIP only" {
|
||||
const as_of = Date.fromYmd(2026, 1, 1);
|
||||
const held: Lot = .{ .symbol = "VTI", .shares = 1, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .drip = true };
|
||||
try std.testing.expect(isDripSummarized(as_of, held));
|
||||
var sold = held;
|
||||
sold.close_date = Date.fromYmd(2025, 6, 1);
|
||||
try std.testing.expect(!isDripSummarized(as_of, sold));
|
||||
var not_drip = held;
|
||||
not_drip.drip = false;
|
||||
try std.testing.expect(!isDripSummarized(as_of, not_drip));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ const srf_lint = @import("../srf_lint.zig");
|
|||
// a merged group.
|
||||
//
|
||||
// So: per-lot display sites MUST price through
|
||||
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot)`,
|
||||
// `views/portfolio_sections.zig:effectivePriceFor(allocations, lot, as_of)`,
|
||||
// which resolves `close_price`, discriminates on `price_ratio`, and
|
||||
// applies the ratio via `Lot.effectivePrice`. Never read
|
||||
// `Allocation.current_price` into a per-lot calculation, and never
|
||||
|
|
@ -367,30 +367,66 @@ pub const Lot = struct {
|
|||
return self.lotIsOpenAsOf(as_of);
|
||||
}
|
||||
|
||||
/// Was the lot held at end-of-day on `as_of`?
|
||||
// ── Lot lifecycle: THE definition of open, sold, and ended ──
|
||||
//
|
||||
// Every "is this lot closed?" question in zfin is one of these
|
||||
// three. Nothing outside this struct reads `close_date`,
|
||||
// `close_price` or `maturity_date` to decide liveness - that was
|
||||
// how three different definitions of "closed" came to disagree
|
||||
// (holdings went by date, `contributions` and `import` by whether
|
||||
// `close_date` was present at all, lot rows by `close_price`). The
|
||||
// AGENTS.md grep rule keeps it that way.
|
||||
//
|
||||
// End-of-day semantics throughout: a date equal to `as_of` has
|
||||
// already happened. They are deliberately different questions:
|
||||
//
|
||||
// hasMaturedAsOf `maturity_date` arrived (options, CDs).
|
||||
// isSoldAsOf a SALE happened. `close_date` only. What
|
||||
// `contributions` needs: an option that expired
|
||||
// and is then recorded with a close is a sale, but
|
||||
// it had already ended at maturity, so testing
|
||||
// "ended" would never see the transition.
|
||||
// hasEndedAsOf sold OR matured. The lot is over, whether or
|
||||
// not it was ever bought (a not-yet-bought lot
|
||||
// hasn't ended).
|
||||
// lotIsOpenAsOf held: bought, and not ended.
|
||||
|
||||
/// Has a sale happened on or before `as_of`? True once `close_date`
|
||||
/// arrives; maturity doesn't count.
|
||||
pub fn isSoldAsOf(self: Lot, as_of: Date) bool {
|
||||
const cd = self.close_date orelse return false;
|
||||
return !as_of.lessThan(cd);
|
||||
}
|
||||
|
||||
/// Has `maturity_date` arrived (options, CDs)? A sale doesn't count.
|
||||
pub fn hasMaturedAsOf(self: Lot, as_of: Date) bool {
|
||||
const mat = self.maturity_date orelse return false;
|
||||
return !as_of.lessThan(mat);
|
||||
}
|
||||
|
||||
/// Is the lot over as of `as_of` - sold, or matured?
|
||||
pub fn hasEndedAsOf(self: Lot, as_of: Date) bool {
|
||||
return self.isSoldAsOf(as_of) or self.hasMaturedAsOf(as_of);
|
||||
}
|
||||
|
||||
/// Was the lot held at end-of-day on `as_of`? Bought, and not
|
||||
/// ended (see `hasEndedAsOf`).
|
||||
///
|
||||
/// Used by historical snapshot backfill (`zfin snapshot --as-of`)
|
||||
/// where "open" must be evaluated against the target date rather
|
||||
/// than wall-clock today. `isOpen()` delegates to this with
|
||||
/// today as `as_of`.
|
||||
///
|
||||
/// End-of-day semantics (see tests):
|
||||
/// - `open_date > as_of` -> not yet bought -> false
|
||||
/// - `close_date` on/before as_of -> sold that day or earlier -> false
|
||||
/// - `maturity_date` on/before as_of -> matured that day or earlier -> false
|
||||
/// - otherwise -> true
|
||||
/// than wall-clock today. `isOpen()` delegates to this.
|
||||
pub fn lotIsOpenAsOf(self: Lot, as_of: Date) bool {
|
||||
// Not yet bought on `as_of`.
|
||||
if (as_of.lessThan(self.open_date)) return false;
|
||||
// Sold on or before `as_of`.
|
||||
if (self.close_date) |cd| {
|
||||
if (!as_of.lessThan(cd)) return false;
|
||||
}
|
||||
// Matured on or before `as_of` (options, CDs).
|
||||
if (self.maturity_date) |mat| {
|
||||
if (!as_of.lessThan(mat)) return false;
|
||||
}
|
||||
return true;
|
||||
return !self.hasEndedAsOf(as_of);
|
||||
}
|
||||
|
||||
/// The price the lot was sold at, once the sale has happened.
|
||||
/// Null before `close_date` arrives even when `close_price` is set:
|
||||
/// a close price on a lot that's still held is not a price at all.
|
||||
pub fn closePriceAsOf(self: Lot, as_of: Date) ?f64 {
|
||||
if (!self.isSoldAsOf(as_of)) return null;
|
||||
return self.close_price;
|
||||
}
|
||||
|
||||
/// Cost basis: RAW shares x RAW open_price. Split-invariant
|
||||
|
|
@ -428,12 +464,13 @@ pub const Lot = struct {
|
|||
return self.effectiveShares() * self.effectivePrice(raw_price, is_preadjusted);
|
||||
}
|
||||
|
||||
/// Realized gain/loss for a closed lot: shares * (close_price - open_price).
|
||||
/// Returns null if the lot is still open. Stays on RAW shares - a
|
||||
/// closed lot is a completed round-trip whose recorded open/close are
|
||||
/// consistent; `enrichSplits` leaves closed lots at `split_factor 1.0`.
|
||||
pub fn realizedGainLoss(self: Lot) ?f64 {
|
||||
const cp = self.close_price orelse return null;
|
||||
/// Realized gain/loss once the lot is sold: shares * (close_price -
|
||||
/// open_price). Null until the sale happens, or when it was recorded
|
||||
/// without a `close_price`. Stays on RAW shares - a sold lot is a
|
||||
/// completed round-trip whose recorded open/close are consistent;
|
||||
/// `enrichSplits` leaves closed lots at `split_factor 1.0`.
|
||||
pub fn realizedGainLossAsOf(self: Lot, as_of: Date) ?f64 {
|
||||
const cp = self.closePriceAsOf(as_of) orelse return null;
|
||||
return self.shares * (cp - self.open_price);
|
||||
}
|
||||
|
||||
|
|
@ -442,9 +479,11 @@ pub const Lot = struct {
|
|||
return self.effectiveShares() * (current_price - self.effectiveOpenPrice());
|
||||
}
|
||||
|
||||
pub fn returnPct(self: Lot, current_price: f64) f64 {
|
||||
/// Return on the lot: against the sale price once sold, otherwise
|
||||
/// against `current_price`.
|
||||
pub fn returnPct(self: Lot, current_price: f64, as_of: Date) f64 {
|
||||
if (self.open_price == 0) return 0;
|
||||
const price = if (self.close_price) |cp| cp else current_price;
|
||||
const price = self.closePriceAsOf(as_of) orelse current_price;
|
||||
return (price / self.effectiveOpenPrice()) - 1.0;
|
||||
}
|
||||
|
||||
|
|
@ -623,21 +662,12 @@ pub const srf_schema = struct {
|
|||
/// Lot lifecycle rules - dates and close fields that parse fine but
|
||||
/// describe a lot that cannot exist.
|
||||
///
|
||||
/// These matter more than they look because zfin has THREE
|
||||
/// definitions of "closed", and bad lifecycle data is exactly what
|
||||
/// makes them disagree:
|
||||
///
|
||||
/// - `lotIsOpenAsOf` (holdings, snapshots, analysis): closed iff
|
||||
/// `close_date <= as_of`.
|
||||
/// - `commands/contributions.zig`'s lot differ and
|
||||
/// `commands/import.zig`: closed iff `close_date != null`.
|
||||
/// - `views/portfolio_sections.zig`'s `effectivePriceFor` (lot
|
||||
/// rows): priced at `close_price` whenever it is set.
|
||||
///
|
||||
/// A future `close_date` is therefore held by the first, sold by the
|
||||
/// second, and priced as sold by the third. Stopping the input here
|
||||
/// is the cheap fix; reconciling the three definitions is separate
|
||||
/// work.
|
||||
/// zfin now reads all of these the same way everywhere (see the
|
||||
/// lifecycle block on `Lot`: `isSoldAsOf`, `hasEndedAsOf`,
|
||||
/// `lotIsOpenAsOf`), so none of them makes the program disagree
|
||||
/// with itself any more. They are still flagged because each one
|
||||
/// almost certainly isn't what you meant: a future `close_date` is
|
||||
/// usually a mistyped year, and it leaves the lot held until then.
|
||||
///
|
||||
/// Watch lots are skipped throughout: `Portfolio.watchSymbols` reads
|
||||
/// only `symbol`, so their dates are inert.
|
||||
|
|
@ -676,16 +706,12 @@ pub const srf_schema = struct {
|
|||
));
|
||||
}
|
||||
} else if (rec.close_price != null) {
|
||||
// Only a stock lot's row consults `close_price` while open
|
||||
// (`effectivePriceFor`); for every other type it is inert.
|
||||
const consequence: []const u8 = if (rec.security_type == .stock)
|
||||
"the lot is still held, and its row shows the close price instead of the live one"
|
||||
else
|
||||
"the lot is still held and the close price is ignored";
|
||||
// `Lot.closePriceAsOf` ignores a close price until the sale
|
||||
// happens, so without a `close_date` it is never used.
|
||||
try sink.addOwned(.semantic, "close_price", try std.fmt.allocPrint(
|
||||
a,
|
||||
"{s} has close_price but no close_date - {s}",
|
||||
.{ name, consequence },
|
||||
"{s} has close_price but no close_date - the lot is still held and the close price is ignored",
|
||||
.{name},
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -975,7 +1001,7 @@ pub const Portfolio = struct {
|
|||
const not_yet_opened = as_of.lessThan(lot.open_date);
|
||||
if (!not_yet_opened) {
|
||||
pos.closed_lots += 1;
|
||||
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
||||
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1036,9 +1062,12 @@ pub const Portfolio = struct {
|
|||
pos.shares += lot.effectiveShares();
|
||||
pos.total_cost += lot.costBasis();
|
||||
pos.open_lots += 1;
|
||||
} else {
|
||||
} else if (!as_of.lessThan(lot.open_date)) {
|
||||
// Not held and already bought, so it has ended. A
|
||||
// not-yet-bought lot is neither - `positionsAsOf` skips
|
||||
// it the same way instead of counting it as closed.
|
||||
pos.closed_lots += 1;
|
||||
pos.realized_gain_loss += lot.realizedGainLoss() orelse 0;
|
||||
pos.realized_gain_loss += lot.realizedGainLossAsOf(as_of) orelse 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1057,11 +1086,15 @@ pub const Portfolio = struct {
|
|||
return final.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Total cash for a single account.
|
||||
pub fn cashForAccount(self: Portfolio, account_name: []const u8) f64 {
|
||||
/// Total cash held by a single account on `as_of`. Closed cash
|
||||
/// lots are excluded, like every other total - this one used to
|
||||
/// count them, and audit's reconciliation then reported a
|
||||
/// discrepancy against a brokerage that (correctly) didn't.
|
||||
pub fn cashForAccount(self: Portfolio, as_of: Date, account_name: []const u8) f64 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type != .cash) continue;
|
||||
if (!lot.lotIsOpenAsOf(as_of)) continue;
|
||||
const lot_acct = lot.account orelse continue;
|
||||
if (std.mem.eql(u8, lot_acct, account_name)) total += lot.shares;
|
||||
}
|
||||
|
|
@ -1146,12 +1179,12 @@ pub const Portfolio = struct {
|
|||
return total;
|
||||
}
|
||||
|
||||
/// Total realized P&L from all closed stock lots.
|
||||
pub fn totalRealizedGainLoss(self: Portfolio) f64 {
|
||||
/// Total realized P&L from stock lots sold on or before `as_of`.
|
||||
pub fn totalRealizedGainLoss(self: Portfolio, as_of: Date) f64 {
|
||||
var total: f64 = 0;
|
||||
for (self.lots) |lot| {
|
||||
if (lot.security_type == .stock) {
|
||||
if (lot.realizedGainLoss()) |pnl| total += pnl;
|
||||
if (lot.realizedGainLossAsOf(as_of)) |pnl| total += pnl;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
|
|
@ -1413,7 +1446,7 @@ test "lot basics" {
|
|||
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0, true), 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedGainLoss(200.0), 0.01);
|
||||
try std.testing.expect(lot.realizedGainLoss() == null);
|
||||
try std.testing.expect(lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)) == null);
|
||||
}
|
||||
|
||||
test "closed lot" {
|
||||
|
|
@ -1426,8 +1459,8 @@ test "closed lot" {
|
|||
.close_price = 200.0,
|
||||
};
|
||||
try std.testing.expect(!lot.isOpen(Date.fromYmd(2026, 5, 8)));
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLoss().?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedGainLossAsOf(Date.fromYmd(2026, 5, 8)).?, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0, Date.fromYmd(2026, 5, 8)), 0.001);
|
||||
}
|
||||
|
||||
test "portfolio positions" {
|
||||
|
|
@ -1499,15 +1532,18 @@ test "Position.displaySymbol: label orelse symbol" {
|
|||
}
|
||||
|
||||
test "Lot.returnPct" {
|
||||
const as_of = Date.fromYmd(2026, 5, 8);
|
||||
// Open lot: uses current_price param
|
||||
const open_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150), 0.001);
|
||||
// Closed lot: uses close_price, ignores current_price
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), open_lot.returnPct(150, as_of), 0.001);
|
||||
// Sold lot: uses close_price, ignores current_price
|
||||
const closed_lot = Lot{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 100, .close_date = Date.fromYmd(2024, 6, 1), .close_price = 120 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.2), closed_lot.returnPct(999, as_of), 0.001);
|
||||
// Sale not yet happened: still priced live, close_price ignored
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.5), closed_lot.returnPct(150, Date.fromYmd(2024, 5, 31)), 0.001);
|
||||
// Zero open_price: returns 0
|
||||
const zero_lot = Lot{ .symbol = "X", .shares = 1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 0 };
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), zero_lot.returnPct(100, as_of), 0.001);
|
||||
}
|
||||
|
||||
test "Portfolio totals" {
|
||||
|
|
@ -1525,7 +1561,7 @@ test "Portfolio totals" {
|
|||
// totalCostBasis: only open stock lots -> 10 * 150 = 1500
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1500.0), portfolio.totalCostBasis(Date.fromYmd(2026, 5, 8)), 0.01);
|
||||
// totalRealizedGainLoss: closed stock lots -> 5 * (160-140) = 100
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(), 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 100.0), portfolio.totalRealizedGainLoss(Date.fromYmd(2026, 5, 8)), 0.01);
|
||||
// totalCash
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), portfolio.totalCash(Date.fromYmd(2026, 5, 8)), 0.01);
|
||||
// totalIlliquid
|
||||
|
|
@ -2546,20 +2582,16 @@ test "lifecycle: cash and CD lots closed without a price are fine" {
|
|||
, &.{});
|
||||
}
|
||||
|
||||
test "lifecycle: close_price without close_date - stock row shows the wrong price" {
|
||||
test "lifecycle: close_price without close_date is ignored, for every type" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\symbol::SPY,shares:num:5,open_date::2024-01-15,open_price:num:470,close_price:num:560
|
||||
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:1,close_price:num:1
|
||||
\\
|
||||
, &.{"2: SPY has close_price but no close_date - the lot is still held, and its row shows the close price instead of the live one"});
|
||||
}
|
||||
|
||||
test "lifecycle: close_price without close_date - non-stock says it is ignored" {
|
||||
try expectLifecycle(
|
||||
\\#!srfv1
|
||||
\\security_type::cd,symbol::CD1,shares:num:10000,open_date::2024-01-01,open_price:num:10000,close_price:num:10000
|
||||
\\
|
||||
, &.{"2: CD1 has close_price but no close_date - the lot is still held and the close price is ignored"});
|
||||
, &.{
|
||||
"2: SPY has close_price but no close_date - the lot is still held and the close price is ignored",
|
||||
"3: CD1 has close_price but no close_date - the lot is still held and the close price is ignored",
|
||||
});
|
||||
}
|
||||
|
||||
test "lifecycle: future open_date is flagged" {
|
||||
|
|
@ -2709,3 +2741,84 @@ test "fromParsed: every other type requires all three, and names the missing one
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "cashForAccount: counts only cash held on as_of" {
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "CASH", .shares = 1000, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA" },
|
||||
.{ .symbol = "CASH", .shares = 250, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample IRA", .close_date = Date.fromYmd(2026, 3, 1) },
|
||||
.{ .symbol = "CASH", .shares = 99, .open_date = Date.fromYmd(2025, 1, 1), .open_price = 1, .security_type = .cash, .account = "Sample Roth" },
|
||||
};
|
||||
const pf = testPortfolio(&lots);
|
||||
// Before the close, both Sample IRA lots count; after, only one.
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1250), pf.cashForAccount(Date.fromYmd(2026, 2, 1), "Sample IRA"), 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 1000), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA"), 0.001);
|
||||
// And it agrees with the portfolio-wide total that already filtered.
|
||||
try std.testing.expectApproxEqAbs(pf.totalCash(Date.fromYmd(2026, 3, 1)), pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample IRA") + pf.cashForAccount(Date.fromYmd(2026, 3, 1), "Sample Roth"), 0.001);
|
||||
}
|
||||
|
||||
// ── Lot lifecycle ──
|
||||
|
||||
test "lifecycle predicates: sold, matured, ended, held" {
|
||||
const d = Date.fromYmd;
|
||||
const bought = d(2024, 1, 1);
|
||||
const plain: Lot = .{ .symbol = "X", .shares = 1, .open_date = bought, .open_price = 1 };
|
||||
|
||||
// Held, nothing scheduled.
|
||||
try std.testing.expect(!plain.isSoldAsOf(d(2025, 1, 1)));
|
||||
try std.testing.expect(!plain.hasMaturedAsOf(d(2025, 1, 1)));
|
||||
try std.testing.expect(!plain.hasEndedAsOf(d(2025, 1, 1)));
|
||||
try std.testing.expect(plain.lotIsOpenAsOf(d(2025, 1, 1)));
|
||||
|
||||
// Sold: end-of-day, so the close date itself is already sold.
|
||||
var sold = plain;
|
||||
sold.close_date = d(2025, 6, 1);
|
||||
try std.testing.expect(!sold.isSoldAsOf(d(2025, 5, 31)));
|
||||
try std.testing.expect(sold.isSoldAsOf(d(2025, 6, 1)));
|
||||
try std.testing.expect(sold.hasEndedAsOf(d(2025, 6, 1)));
|
||||
try std.testing.expect(!sold.hasMaturedAsOf(d(2025, 6, 1)));
|
||||
try std.testing.expect(!sold.lotIsOpenAsOf(d(2025, 6, 1)));
|
||||
|
||||
// Matured: ended and not held, but NOT sold - this is the case
|
||||
// `contributions` depends on, so an expired option recorded with a
|
||||
// close afterwards is still seen as a sale.
|
||||
var matured = plain;
|
||||
matured.maturity_date = d(2025, 3, 1);
|
||||
try std.testing.expect(matured.hasMaturedAsOf(d(2025, 3, 1)));
|
||||
try std.testing.expect(matured.hasEndedAsOf(d(2025, 3, 1)));
|
||||
try std.testing.expect(!matured.isSoldAsOf(d(2025, 3, 1)));
|
||||
try std.testing.expect(!matured.lotIsOpenAsOf(d(2025, 3, 1)));
|
||||
|
||||
// Not yet bought: neither held nor ended.
|
||||
try std.testing.expect(!plain.lotIsOpenAsOf(d(2023, 12, 31)));
|
||||
try std.testing.expect(!plain.hasEndedAsOf(d(2023, 12, 31)));
|
||||
}
|
||||
|
||||
test "closePriceAsOf / realizedGainLossAsOf: only once sold" {
|
||||
const d = Date.fromYmd;
|
||||
var lot: Lot = .{ .symbol = "X", .shares = 10, .open_date = d(2024, 1, 1), .open_price = 100, .close_price = 130 };
|
||||
// A close price with no close date is not a sale.
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.closePriceAsOf(d(2026, 1, 1)));
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
|
||||
// Nor is one whose date hasn't arrived.
|
||||
lot.close_date = d(2025, 6, 1);
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2025, 5, 31)));
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 300), lot.realizedGainLossAsOf(d(2025, 6, 1)).?, 0.001);
|
||||
// Sold but recorded without a price: nothing to realize from.
|
||||
lot.close_price = null;
|
||||
try std.testing.expectEqual(@as(?f64, null), lot.realizedGainLossAsOf(d(2026, 1, 1)));
|
||||
}
|
||||
|
||||
test "positionsForAccount: a not-yet-bought lot isn't counted as closed" {
|
||||
// It used to land in `closed_lots` because "not held" was taken to
|
||||
// mean "closed"; `positionsAsOf` already skipped it.
|
||||
var lots = [_]Lot{
|
||||
.{ .symbol = "VTI", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 200, .account = "Sample IRA" },
|
||||
.{ .symbol = "VTI", .shares = 5, .open_date = Date.fromYmd(2027, 1, 1), .open_price = 250, .account = "Sample IRA" },
|
||||
};
|
||||
const pf = testPortfolio(&lots);
|
||||
const positions = try pf.positionsForAccount(Date.fromYmd(2026, 1, 1), std.testing.allocator, "Sample IRA");
|
||||
defer std.testing.allocator.free(positions);
|
||||
try std.testing.expectEqual(@as(usize, 1), positions.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), positions[0].closed_lots);
|
||||
try std.testing.expectEqual(@as(usize, 1), positions[0].open_lots);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1071,10 +1071,10 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
}
|
||||
std.mem.sort(zfin.Lot, matching.items, app.today, fmt.lotSortFn);
|
||||
|
||||
// Check if any lots are DRIP
|
||||
// Check if any lots go into the DRIP summary
|
||||
var has_drip = false;
|
||||
for (matching.items) |lot| {
|
||||
if (lot.drip) {
|
||||
if (fmt.isDripSummarized(app.today, lot)) {
|
||||
has_drip = true;
|
||||
break;
|
||||
}
|
||||
|
|
@ -1091,9 +1091,9 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
}) catch continue;
|
||||
}
|
||||
} else {
|
||||
// Has DRIP lots: show non-DRIP individually, summarize DRIP as ST/LT
|
||||
// Has DRIP lots: show the rest individually, summarize held DRIP as ST/LT
|
||||
for (matching.items) |lot| {
|
||||
if (!lot.drip) {
|
||||
if (!fmt.isDripSummarized(app.today, lot)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .lot,
|
||||
.symbol = lot.symbol,
|
||||
|
|
@ -1273,7 +1273,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
var cash_lots: std.ArrayList(zfin.Lot) = .empty;
|
||||
defer cash_lots.deinit(app.allocator);
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .cash and matchesAccountFilter(state, lot.account)) {
|
||||
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today) and matchesAccountFilter(state, lot.account)) {
|
||||
cash_lots.append(app.allocator, lot) catch continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -1302,7 +1302,8 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
});
|
||||
if (state.cash_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .cash) {
|
||||
// Held lots only, so the rows add up to the total.
|
||||
if (lot.security_type == .cash and lot.lotIsOpenAsOf(app.today)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .cash_row,
|
||||
.symbol = lot.account orelse "Unknown",
|
||||
|
|
@ -1327,7 +1328,7 @@ fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void {
|
|||
});
|
||||
if (state.illiquid_expanded) {
|
||||
for (pf.lots) |lot| {
|
||||
if (lot.security_type == .illiquid) {
|
||||
if (lot.security_type == .illiquid and lot.lotIsOpenAsOf(app.today)) {
|
||||
state.rows.append(app.allocator, .{
|
||||
.kind = .illiquid_row,
|
||||
.symbol = lot.symbol,
|
||||
|
|
@ -1867,6 +1868,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
s.unrealized_gain_loss,
|
||||
watch_syms.items,
|
||||
app.portfolio.watchlist_prices,
|
||||
app.today,
|
||||
);
|
||||
} else break :blk views.PositionsWidths{};
|
||||
};
|
||||
|
|
@ -2028,7 +2030,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
// base-ticker price, so a ratio'd lot must go
|
||||
// through `effectivePriceFor` or its Value and
|
||||
// Gain/Loss come out scaled by its ratio.
|
||||
const use_price = views.effectivePriceFor(s.allocations, lot);
|
||||
const use_price = views.effectivePriceFor(s.allocations, lot, app.today);
|
||||
const gl = lot.effectiveShares() * (use_price - lot.effectiveOpenPrice());
|
||||
lot_positive = gl >= 0;
|
||||
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{f}", .{
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ pub fn computeWidths(
|
|||
total_gl: f64,
|
||||
watch_syms: []const []const u8,
|
||||
watch_prices: ?std.StringHashMap(f64),
|
||||
as_of: Date,
|
||||
) PositionsWidths {
|
||||
var w: PositionsWidths = .{};
|
||||
|
||||
|
|
@ -117,7 +118,7 @@ pub fn computeWidths(
|
|||
if (lot.security_type != .stock) continue;
|
||||
w.shares_w = @max(w.shares_w, sharesCols(lot.effectiveShares()));
|
||||
w.price_w = @max(w.price_w, moneyCols(lot.effectiveOpenPrice()));
|
||||
const use_price = effectivePriceFor(allocations, lot);
|
||||
const use_price = effectivePriceFor(allocations, lot, as_of);
|
||||
// A ratio'd lot renders its own effective price in the Price
|
||||
// cell (see `hasOwnPrice`), and that price can exceed every
|
||||
// allocation's raw price - an 8.6x institutional ratio puts a
|
||||
|
|
@ -270,10 +271,13 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
|
|||
/// block in `models/portfolio.zig`. The signal is `price_ratio`, and it
|
||||
/// is answering ONE question: has this allocation been merged?
|
||||
///
|
||||
/// - `close_price` short-circuits first. It is the price the lot
|
||||
/// actually closed at, already in the lot's own terms, so the ratio
|
||||
/// is NOT reapplied. Matches how the contributions pipeline values
|
||||
/// closed lots (`effectivePrice(close_price, true)`).
|
||||
/// - A sold lot's `close_price` short-circuits first
|
||||
/// (`Lot.closePriceAsOf`). It is the price the lot actually closed
|
||||
/// at, already in the lot's own terms, so the ratio is NOT
|
||||
/// reapplied. Matches how the contributions pipeline values closed
|
||||
/// lots (`effectivePrice(close_price, true)`). A `close_price` on a
|
||||
/// lot whose `close_date` hasn't arrived is ignored: that lot is
|
||||
/// still held, and its row says so.
|
||||
/// - `a.price_ratio != 1.0` means the allocation is UNMERGED and
|
||||
/// carries this lot's own ratio (`positionsAsOf` groups by
|
||||
/// `(priceSymbol, price_ratio)` and propagates the ratio through).
|
||||
|
|
@ -306,8 +310,8 @@ fn padCell(arena: std.mem.Allocator, content: []const u8, cols: usize, justify:
|
|||
/// only case a ratio'd alias produces - are exact: with every
|
||||
/// component live off the same raw price `r`, `total_mv / norm_shares`
|
||||
/// reduces to `r`.
|
||||
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot) f64 {
|
||||
if (lot.close_price) |cp| return lot.effectivePrice(cp, true);
|
||||
pub fn effectivePriceFor(allocations: []const Allocation, lot: Lot, as_of: Date) f64 {
|
||||
if (lot.closePriceAsOf(as_of)) |cp| return lot.effectivePrice(cp, true);
|
||||
for (allocations) |a| {
|
||||
if (!std.mem.eql(u8, a.symbol, lot.priceSymbol())) continue;
|
||||
return lot.effectivePrice(a.current_price, a.price_ratio != 1.0);
|
||||
|
|
@ -479,12 +483,13 @@ pub const Options = struct {
|
|||
expired_count: usize,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
/// Expired option rows (matured strictly before `as_of`).
|
||||
/// Ended option rows: expired or closed on or before `as_of`
|
||||
/// (`Lot.hasEndedAsOf`).
|
||||
pub fn expiredItems(self: Options) []const Option {
|
||||
return self.items[0..self.expired_count];
|
||||
}
|
||||
|
||||
/// Active option rows (not yet expired, including null-maturity).
|
||||
/// Active option rows (not ended, including null-maturity).
|
||||
pub fn activeItems(self: Options) []const Option {
|
||||
return self.items[self.expired_count..];
|
||||
}
|
||||
|
|
@ -509,49 +514,56 @@ pub const Options = struct {
|
|||
}
|
||||
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturityThenSymbolSortFn);
|
||||
|
||||
// Ended rows first, then active, each in maturity order. A
|
||||
// maturity sort alone no longer yields that prefix: an option
|
||||
// closed early (past `close_date`, future maturity) has ended
|
||||
// but sorts among the active ones.
|
||||
var expired_count: usize = 0;
|
||||
for (tmp.items) |lot| {
|
||||
const qty = lot.shares;
|
||||
const cost_per = lot.open_price;
|
||||
const premium = @abs(qty) * cost_per * lot.multiplier;
|
||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
|
||||
if (is_expired) expired_count += 1;
|
||||
const received = qty < 0;
|
||||
for ([_]bool{ true, false }) |want_ended| {
|
||||
for (tmp.items) |lot| {
|
||||
const is_expired = lot.hasEndedAsOf(as_of);
|
||||
if (is_expired != want_ended) continue;
|
||||
const qty = lot.shares;
|
||||
const cost_per = lot.open_price;
|
||||
const premium = @abs(qty) * cost_per * lot.multiplier;
|
||||
if (is_expired) expired_count += 1;
|
||||
const received = qty < 0;
|
||||
|
||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
||||
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
|
||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
||||
const premium_style: fmt.StyleIntent = if (is_expired) .muted else if (received) .positive else .negative;
|
||||
|
||||
var cost_buf: [24]u8 = undefined;
|
||||
var prem_val_buf: [24]u8 = undefined;
|
||||
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
|
||||
var prem_buf: [20]u8 = undefined;
|
||||
const prem_str = if (received)
|
||||
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
|
||||
else
|
||||
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
|
||||
const acct = lot.account orelse "";
|
||||
var cost_buf: [24]u8 = undefined;
|
||||
var prem_val_buf: [24]u8 = undefined;
|
||||
const prem_money = std.fmt.bufPrint(&prem_val_buf, "{f}", .{Money.from(premium)}) catch "$?";
|
||||
var prem_buf: [20]u8 = undefined;
|
||||
const prem_str = if (received)
|
||||
std.fmt.bufPrint(&prem_buf, "+{s}", .{prem_money}) catch "?"
|
||||
else
|
||||
std.fmt.bufPrint(&prem_buf, "-{s}", .{prem_money}) catch "?";
|
||||
const acct = lot.account orelse "";
|
||||
|
||||
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
|
||||
lot.displaySymbol(),
|
||||
qty,
|
||||
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
|
||||
prem_str,
|
||||
acct,
|
||||
});
|
||||
const text = try std.fmt.allocPrint(allocator, OptionsLayout.data_row, .{
|
||||
lot.displaySymbol(),
|
||||
qty,
|
||||
std.fmt.bufPrint(&cost_buf, "{f}", .{Money.from(cost_per)}) catch "$?",
|
||||
prem_str,
|
||||
acct,
|
||||
});
|
||||
|
||||
try list.append(allocator, .{
|
||||
.lot = lot,
|
||||
.premium = premium,
|
||||
.received = received,
|
||||
.is_expired = is_expired,
|
||||
.row_style = row_style,
|
||||
.premium_style = premium_style,
|
||||
.columns = .{
|
||||
.{ .text = text, .style = row_style },
|
||||
.{ .text = prem_str, .style = premium_style },
|
||||
},
|
||||
.premium_col_start = OptionsLayout.premium_col_start,
|
||||
});
|
||||
try list.append(allocator, .{
|
||||
.lot = lot,
|
||||
.premium = premium,
|
||||
.received = received,
|
||||
.is_expired = is_expired,
|
||||
.row_style = row_style,
|
||||
.premium_style = premium_style,
|
||||
.columns = .{
|
||||
.{ .text = text, .style = row_style },
|
||||
.{ .text = prem_str, .style = premium_style },
|
||||
},
|
||||
.premium_col_start = OptionsLayout.premium_col_start,
|
||||
});
|
||||
}
|
||||
}
|
||||
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
|
||||
}
|
||||
|
|
@ -607,12 +619,13 @@ pub const CDs = struct {
|
|||
expired_count: usize,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
/// Matured CD rows (maturity strictly before `as_of`).
|
||||
/// Ended CD rows: matured or closed on or before `as_of`
|
||||
/// (`Lot.hasEndedAsOf`).
|
||||
pub fn expiredItems(self: CDs) []const CD {
|
||||
return self.items[0..self.expired_count];
|
||||
}
|
||||
|
||||
/// Active CD rows (not yet matured, including null-maturity).
|
||||
/// Active CD rows (not ended, including null-maturity).
|
||||
pub fn activeItems(self: CDs) []const CD {
|
||||
return self.items[self.expired_count..];
|
||||
}
|
||||
|
|
@ -637,39 +650,43 @@ pub const CDs = struct {
|
|||
}
|
||||
std.mem.sort(Lot, tmp.items, {}, fmt.lotMaturitySortFn);
|
||||
|
||||
// Ended rows first, then active - see `Options.init`.
|
||||
var expired_count: usize = 0;
|
||||
for (tmp.items) |lot| {
|
||||
const is_expired = if (lot.maturity_date) |md| md.lessThan(as_of) else false;
|
||||
if (is_expired) expired_count += 1;
|
||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
||||
for ([_]bool{ true, false }) |want_ended| {
|
||||
for (tmp.items) |lot| {
|
||||
const is_expired = lot.hasEndedAsOf(as_of);
|
||||
if (is_expired != want_ended) continue;
|
||||
if (is_expired) expired_count += 1;
|
||||
const row_style: fmt.StyleIntent = if (is_expired) .muted else .normal;
|
||||
|
||||
var face_buf: [24]u8 = undefined;
|
||||
var mat_buf: [10]u8 = undefined;
|
||||
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
|
||||
var rate_buf: [10]u8 = undefined;
|
||||
const rate_str: []const u8 = if (lot.rate) |r|
|
||||
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
|
||||
else
|
||||
"--";
|
||||
const note_str: []const u8 = lot.note orelse "";
|
||||
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
||||
const acct = lot.account orelse "";
|
||||
var face_buf: [24]u8 = undefined;
|
||||
var mat_buf: [10]u8 = undefined;
|
||||
const mat_str: []const u8 = if (lot.maturity_date) |md| (std.fmt.bufPrint(&mat_buf, "{f}", .{md}) catch "????-??-??") else "--";
|
||||
var rate_buf: [10]u8 = undefined;
|
||||
const rate_str: []const u8 = if (lot.rate) |r|
|
||||
std.fmt.bufPrint(&rate_buf, "{d:.2}%", .{r}) catch "--"
|
||||
else
|
||||
"--";
|
||||
const note_str: []const u8 = lot.note orelse "";
|
||||
const note_display = if (note_str.len > 40) note_str[0..40] else note_str;
|
||||
const acct = lot.account orelse "";
|
||||
|
||||
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
|
||||
lot.displaySymbol(),
|
||||
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
|
||||
rate_str,
|
||||
mat_str,
|
||||
note_display,
|
||||
acct,
|
||||
});
|
||||
const text = try std.fmt.allocPrint(allocator, CDsLayout.data_row, .{
|
||||
lot.displaySymbol(),
|
||||
std.fmt.bufPrint(&face_buf, "{f}", .{Money.from(lot.shares)}) catch "$?",
|
||||
rate_str,
|
||||
mat_str,
|
||||
note_display,
|
||||
acct,
|
||||
});
|
||||
|
||||
try list.append(allocator, .{
|
||||
.lot = lot,
|
||||
.is_expired = is_expired,
|
||||
.row_style = row_style,
|
||||
.text = text,
|
||||
});
|
||||
try list.append(allocator, .{
|
||||
.lot = lot,
|
||||
.is_expired = is_expired,
|
||||
.row_style = row_style,
|
||||
.text = text,
|
||||
});
|
||||
}
|
||||
}
|
||||
return .{ .items = try list.toOwnedSlice(allocator), .expired_count = expired_count, .allocator = allocator };
|
||||
}
|
||||
|
|
@ -691,6 +708,9 @@ const testing = std.testing;
|
|||
const portfolio_mod = @import("../models/portfolio.zig");
|
||||
const valuation = @import("../analytics/valuation.zig");
|
||||
|
||||
/// `as_of` for lot-pricing tests: after every fixture date, so a lot
|
||||
/// with a `close_date` is sold and one without is held.
|
||||
const test_as_of = Date.fromYmd(2026, 6, 1);
|
||||
test "Options.init: expired rows form a prefix; active/expired slices split correctly" {
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
const lots = [_]Lot{
|
||||
|
|
@ -941,7 +961,7 @@ test "effectivePriceFor: live price gets the lot's ratio applied" {
|
|||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 461.240208,
|
||||
};
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot), 1e-6);
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&allocs, lot, test_as_of), 1e-6);
|
||||
}
|
||||
|
||||
test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
||||
|
|
@ -958,7 +978,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
|||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 97.50,
|
||||
};
|
||||
const eff_price = effectivePriceFor(&allocs, lot);
|
||||
const eff_price = effectivePriceFor(&allocs, lot, test_as_of);
|
||||
try testing.expectApproxEqRel(@as(f64, 182.8197), eff_price, 1e-6);
|
||||
// The whole point: value must land on the real figure, not the
|
||||
// ratio-skipped one.
|
||||
|
|
@ -969,7 +989,7 @@ test "effectivePriceFor: ratio below 1.0 scales the price down, not up" {
|
|||
test "effectivePriceFor: ratio 1.0 passes the raw price straight through" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lot = Lot{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
||||
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 60), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: unmerged live price is already effective, ratio NOT reapplied" {
|
||||
|
|
@ -992,11 +1012,11 @@ test "effectivePriceFor: unmerged live price is already effective, ratio NOT rea
|
|||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 106.99,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 144.04), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
// And the lot row must reconcile with its own position row.
|
||||
try testing.expectApproxEqRel(
|
||||
allocs[0].market_value,
|
||||
lot.effectiveShares() * effectivePriceFor(&allocs, lot),
|
||||
lot.effectiveShares() * effectivePriceFor(&allocs, lot, test_as_of),
|
||||
1e-9,
|
||||
);
|
||||
}
|
||||
|
|
@ -1019,7 +1039,7 @@ test "effectivePriceFor: unmerged manual price is also already effective" {
|
|||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 18.15,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 19.01), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
|
||||
|
|
@ -1038,7 +1058,7 @@ test "effectivePriceFor: a merged group's raw price DOES get the lot's ratio" {
|
|||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 461.240208,
|
||||
};
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot), 1e-6);
|
||||
try testing.expectApproxEqRel(@as(f64, 775.1715), effectivePriceFor(&merged, lot, test_as_of), 1e-6);
|
||||
}
|
||||
|
||||
test "effectivePriceFor: close_price wins over the allocation and skips the ratio" {
|
||||
|
|
@ -1053,25 +1073,46 @@ test "effectivePriceFor: close_price wins over the allocation and skips the rati
|
|||
.shares = 2412.601,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 106.99,
|
||||
.close_date = Date.fromYmd(2026, 5, 1),
|
||||
.close_price = 150.39,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: close_price is ignored until the sale happens" {
|
||||
// A `close_price` with no `close_date` - or one still in the future
|
||||
// - is on a lot that's still held (`Lot.closePriceAsOf`), so its row
|
||||
// is priced live like any other held lot. It used to show the close
|
||||
// price while the status column said "open".
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
||||
var lot = Lot{
|
||||
.symbol = "BENCH",
|
||||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2026, 2, 26),
|
||||
.open_price = 50,
|
||||
.close_price = 150.39,
|
||||
};
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
lot.close_date = Date.fromYmd(2027, 1, 1);
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
// ...and takes over the day it arrives.
|
||||
try testing.expectEqual(@as(f64, 150.39), effectivePriceFor(&allocs, lot, Date.fromYmd(2027, 1, 1)));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: orphan lot with no matching allocation yields 0" {
|
||||
const allocs = [_]Allocation{mkAlloc("ABC", 100, 50, 60, 6000, 1000)};
|
||||
const lot = Lot{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7 };
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot));
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, lot, test_as_of));
|
||||
}
|
||||
|
||||
test "effectivePriceFor: matches on priceSymbol, not the lot's own symbol" {
|
||||
// A CUSIP lot must find its allocation under the ticker alias.
|
||||
const allocs = [_]Allocation{mkAlloc("BENCH", 100, 50, 90.15, 9015, 4015)};
|
||||
const by_cusip = Lot{ .symbol = "02315N600", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 50 };
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip));
|
||||
try testing.expectEqual(@as(f64, 0), effectivePriceFor(&allocs, by_cusip, test_as_of));
|
||||
var aliased = by_cusip;
|
||||
aliased.ticker = "BENCH";
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased));
|
||||
try testing.expectEqual(@as(f64, 90.15), effectivePriceFor(&allocs, aliased, test_as_of));
|
||||
}
|
||||
|
||||
test "hasOwnPrice: only ratio'd lots occupy the Price cell" {
|
||||
|
|
@ -1144,7 +1185,7 @@ test "effectivePriceFor: lot rows sum to their merged position's market value" {
|
|||
var lot_mv_total: f64 = 0;
|
||||
var lot_gl_total: f64 = 0;
|
||||
for (lots) |lot| {
|
||||
const eff_price = effectivePriceFor(summary.allocations, lot);
|
||||
const eff_price = effectivePriceFor(summary.allocations, lot, test_as_of);
|
||||
lot_mv_total += lot.effectiveShares() * eff_price;
|
||||
lot_gl_total += lot.effectiveShares() * (eff_price - lot.effectiveOpenPrice());
|
||||
}
|
||||
|
|
@ -1199,7 +1240,7 @@ test "effectivePriceFor: a LONE ratio'd lot's row reconciles with its position"
|
|||
try testing.expectEqual(ratio, a.price_ratio);
|
||||
try testing.expectApproxEqRel(raw_price * ratio, a.current_price, 1e-9);
|
||||
|
||||
const eff_price = effectivePriceFor(summary.allocations, lots[0]);
|
||||
const eff_price = effectivePriceFor(summary.allocations, lots[0], test_as_of);
|
||||
try testing.expectApproxEqRel(raw_price * ratio, eff_price, 1e-9);
|
||||
try testing.expectApproxEqRel(a.market_value, lots[0].effectiveShares() * eff_price, 1e-9);
|
||||
// Magnitude pin: 1200 * 144.04 = $172,848, NOT 1200 * 720.20 = $864,240.
|
||||
|
|
@ -1275,7 +1316,7 @@ test "computeWidths: a ratio'd lot's effective price widens the Price column" {
|
|||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 100.0,
|
||||
}};
|
||||
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null);
|
||||
const w = computeWidths(&allocs, &lots, 9015, 1000, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 10), w.price_w);
|
||||
}
|
||||
|
||||
|
|
@ -1291,20 +1332,21 @@ test "computeWidths: the Price cell is measured only when the lot occupies it" {
|
|||
.shares = 10,
|
||||
.open_date = Date.fromYmd(2026, 2, 25),
|
||||
.open_price = 1.0,
|
||||
.close_date = Date.fromYmd(2026, 5, 1),
|
||||
.close_price = 99999.99, // "$99,999.99" = 10 cols
|
||||
}};
|
||||
|
||||
// Ratio 1.0: cell stays blank, so the 10-col price must not leak in.
|
||||
// $50.00 / $60.00 / $1.00 are all under the 8-col "Avg Cost" floor.
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
|
||||
|
||||
// Ratio'd: the cell is occupied, so the same price now widens it.
|
||||
lots[0].price_ratio = 2.0;
|
||||
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null).price_w);
|
||||
try testing.expectEqual(@as(usize, 10), computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of).price_w);
|
||||
}
|
||||
|
||||
test "computeWidths: empty portfolio sits at the header-label floors" {
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null);
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_symbol_w), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_shares_w), w.shares_w);
|
||||
try testing.expectEqual(@as(usize, PositionsLayout.min_price_w), w.price_w);
|
||||
|
|
@ -1321,7 +1363,7 @@ test "computeWidths: a small portfolio stays at the floors (tightens, no waste)"
|
|||
// at its minimum. This is the "don't waste space" half of
|
||||
// fit-to-content.
|
||||
const allocs = [_]Allocation{mkAlloc("IBM", 10, 150, 155, 1550, 50)};
|
||||
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null);
|
||||
const w = computeWidths(&allocs, &.{}, 1550, 50, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 6), w.symbol_w); // "Symbol"
|
||||
try testing.expectEqual(@as(usize, 6), w.shares_w); // "Shares"
|
||||
try testing.expectEqual(@as(usize, 8), w.price_w); // "Avg Cost"
|
||||
|
|
@ -1333,7 +1375,7 @@ test "computeWidths: large crypto-scale values grow each column" {
|
|||
// DOGE-USD(8) / 10000.0(7) / $42,000.00(10) / $420,000,000.00(15)
|
||||
// / +$5,000,000.00(14, sign + $5,000,000.00).
|
||||
const allocs = [_]Allocation{mkAlloc("DOGE-USD", 10000, 41500, 42000, 420000000, 5000000)};
|
||||
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null);
|
||||
const w = computeWidths(&allocs, &.{}, 420000000, 5000000, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 8), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, 7), w.shares_w);
|
||||
try testing.expectEqual(@as(usize, 10), w.price_w); // max("$41,500.00","$42,000.00") = 10
|
||||
|
|
@ -1352,10 +1394,10 @@ test "computeWidths: a single lot's gain/loss can exceed the netted position" {
|
|||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 199.0 },
|
||||
};
|
||||
// Allocations only: stays at the 9-col floor.
|
||||
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null);
|
||||
const no_lots = computeWidths(&allocs, &.{}, 20000, 0, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 9), no_lots.gainloss_w);
|
||||
// With lots: grows to fit the per-lot swing.
|
||||
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null);
|
||||
const w = computeWidths(&allocs, &lots, 20000, 0, &.{}, null, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 10), w.gainloss_w);
|
||||
}
|
||||
|
||||
|
|
@ -1368,13 +1410,13 @@ test "computeWidths: non-stock lots skipped, close_price honored, orphan lot tol
|
|||
// Closed stock lot: market value / gain-loss use close_price
|
||||
// (1234), not the allocation's current price (60). gl =
|
||||
// 100 * (1234 - 10) = 122400 -> "+$122,400.00" = 12 cols.
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_price = 1234.0 },
|
||||
.{ .symbol = "ABC", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 10.0, .close_date = Date.fromYmd(2025, 1, 1), .close_price = 1234.0 },
|
||||
// Stock lot whose symbol has no matching allocation and no
|
||||
// close_price: currentPriceFor falls back to 0 (exercises the
|
||||
// not-found path), contributing nothing.
|
||||
.{ .symbol = "ORPHAN", .shares = 5, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 7.0 },
|
||||
};
|
||||
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null);
|
||||
const w = computeWidths(&allocs, &lots, 6000, 1000, &.{}, null, test_as_of);
|
||||
// Non-stock lot skipped -> Shares stays at the 6-col floor (the
|
||||
// 999999.0 = 8-col share count was ignored).
|
||||
try testing.expectEqual(@as(usize, 6), w.shares_w);
|
||||
|
|
@ -1391,7 +1433,7 @@ test "computeWidths: watchlist symbols and prices widen Symbol / Price" {
|
|||
// "NOPRICE" has no map entry, so it widens Symbol but not Price
|
||||
// (exercises the price-absent branch).
|
||||
const watch = [_][]const u8{ "VERYLONGSYM", "NOPRICE" };
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp);
|
||||
const w = computeWidths(&.{}, &.{}, 0, 0, &watch, wp, test_as_of);
|
||||
try testing.expectEqual(@as(usize, 11), w.symbol_w);
|
||||
try testing.expectEqual(@as(usize, 9), w.price_w);
|
||||
}
|
||||
|
|
@ -1430,7 +1472,58 @@ test "computeWidths: lot columns size to effective (split-adjusted) shares" {
|
|||
};
|
||||
const no_allocs: []const Allocation = &.{};
|
||||
const no_watch: []const []const u8 = &.{};
|
||||
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null);
|
||||
const w = computeWidths(no_allocs, &lots, 0, 0, no_watch, null, test_as_of);
|
||||
try testing.expectEqual(sharesCols(1000.0), w.shares_w);
|
||||
try testing.expect(w.shares_w >= sharesCols(100.0));
|
||||
}
|
||||
|
||||
test "Options.init: an option on its maturity day has ended" {
|
||||
// End-of-day semantics, the same as the summary totals
|
||||
// (`Lot.hasEndedAsOf`). This section used `maturity < as_of` and
|
||||
// showed it active - and in its TOTAL - for that one day.
|
||||
const as_of = Date.fromYmd(2024, 6, 21);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "AAA 2024-06-21 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 6, 21) },
|
||||
};
|
||||
var opts = try Options.init(as_of, testing.allocator, &lots, null);
|
||||
defer opts.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), opts.expiredItems().len);
|
||||
try testing.expectEqual(@as(usize, 0), opts.activeItems().len);
|
||||
}
|
||||
|
||||
test "Options.init: an option closed before maturity has ended, and ended rows stay a prefix" {
|
||||
// Closed early: past close_date, future maturity. It sorts among the
|
||||
// active rows by maturity, so the partition - not the sort - has to
|
||||
// move it into the ended prefix, or `activeItems` (and the section
|
||||
// TOTAL built from it) would still include it.
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "AAA 2024-09-01 C100", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 2.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 9, 1) },
|
||||
.{ .symbol = "BBB 2024-12-01 C50", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .option, .maturity_date = Date.fromYmd(2024, 12, 1), .close_date = Date.fromYmd(2024, 3, 1), .close_price = 0.10 },
|
||||
.{ .symbol = "CCC 2025-03-01 C75", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.0, .security_type = .option, .maturity_date = Date.fromYmd(2025, 3, 1) },
|
||||
};
|
||||
var opts = try Options.init(as_of, testing.allocator, &lots, null);
|
||||
defer opts.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), opts.expired_count);
|
||||
try testing.expectEqualStrings("BBB 2024-12-01 C50", opts.expiredItems()[0].lot.symbol);
|
||||
try testing.expectEqual(@as(usize, 2), opts.activeItems().len);
|
||||
// Active rows keep their maturity order.
|
||||
try testing.expectEqualStrings("AAA 2024-09-01 C100", opts.activeItems()[0].lot.symbol);
|
||||
try testing.expectEqualStrings("CCC 2025-03-01 C75", opts.activeItems()[1].lot.symbol);
|
||||
for (opts.items[0..opts.expired_count]) |o| try testing.expect(o.is_expired);
|
||||
for (opts.items[opts.expired_count..]) |o| try testing.expect(!o.is_expired);
|
||||
}
|
||||
|
||||
test "CDs.init: a CD closed before maturity has ended; one maturing today has too" {
|
||||
const as_of = Date.fromYmd(2024, 6, 1);
|
||||
const lots = [_]Lot{
|
||||
.{ .symbol = "CD-TODAY", .shares = 5000, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2024, 6, 1) },
|
||||
.{ .symbol = "CD-BROKEN", .shares = 8000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 1, 1), .close_date = Date.fromYmd(2024, 4, 1) },
|
||||
.{ .symbol = "CD-ACTIVE", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .maturity_date = Date.fromYmd(2025, 6, 1) },
|
||||
};
|
||||
var cds = try CDs.init(as_of, testing.allocator, &lots, null);
|
||||
defer cds.deinit();
|
||||
try testing.expectEqual(@as(usize, 2), cds.expiredItems().len);
|
||||
try testing.expectEqual(@as(usize, 1), cds.activeItems().len);
|
||||
try testing.expectEqualStrings("CD-ACTIVE", cds.activeItems()[0].lot.symbol);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue