Compare commits

..

No commits in common. "23fd4e3b63272de9bb56f8835899de0705527119" and "6bd063d51f6c7053e2a49b68892f164787bc389d" have entirely different histories.

5 changed files with 75 additions and 309 deletions

View file

@ -66,15 +66,6 @@ cash, CDs, options, and anything with no `metadata.srf` row -- so a raw
sleeve, and would not be comparable against the portfolio row (which is sleeve, and would not be comparable against the portfolio row (which is
renormalized). Both rows therefore describe invested assets only. renormalized). Both rows therefore describe invested assets only.
The row labels say so, and they are renormalized to match: a leg reads
`SPYM (89.4% of benchmark)`, not its raw portfolio weight, so that
multiplying the printed weights by the printed returns reproduces the
printed blend. The blend row names the share of the portfolio it covers,
`Benchmark (94.9% of portfolio)`, and omits that clause entirely once
coverage reaches 99.5%. A leg's raw portfolio weight is still recoverable
as `of benchmark x of portfolio` -- here `0.894 x 0.949 = 84.9%`, which
is the figure the `Target allocation` line reports.
The year columns are **total return** (dividend-reinvested). The `Week` The year columns are **total return** (dividend-reinvested). The `Week`
column is price-only, because dividends over seven days are negligible; column is price-only, because dividends over seven days are negligible;
the table header says so. the table header says so.

View file

@ -1178,60 +1178,6 @@ fn collectUnmatchedLargeLots(
return out.toOwnedSlice(arena); return out.toOwnedSlice(arena);
} }
/// The kinds that carry real value but are deliberately left OUT of attribution -
/// and the only place that list lives.
///
/// Two consumers read it and they must never disagree: `compare`'s
/// "Uncounted in/out" headline and this report's own "Uncounted flows" total.
/// They would drift if each carried its own list, because these six kinds are
/// scattered across FOUR different print sections below (Cash deltas, Internal
/// purchases, Flagged for review, Lot edits) and nothing about the layout hints
/// that they belong to one bucket. That scattering is exactly why the headline
/// number was untraceable: every row was on screen and no total was.
///
/// `cash_delta` is the raw-balance-change bucket (an opted-in account's positive
/// delta is reclassified to `cash_contribution` at diff time and counted as a
/// contribution, so it cannot be double-counted here). The share-reduction kinds
/// are the other side of the same coin: `attributedValue()` pins them to 0 on the
/// grounds that they are a funding source rather than an outflow, which is right
/// for attribution and still worth SEEING.
///
/// Deliberately NOT here:
/// - `cd_matured` / `cd_removed_early` have their own section and always pair
/// with a cash increase in the same account, so counting both legs would
/// double-report one internal move.
/// - `transfer_in` / `transfer_out` / `unmatched_transfer` are DECLARED internal
/// moves (`transaction_log.srf`). The point of the total is UNdeclared
/// movement; a declared transfer is not a surprise.
/// - `price_only` carries no share change, so its value is zero anyway.
fn isUncountedKind(k: ChangeKind) bool {
return switch (k) {
.cash_delta, .drip_negative, .lot_removed, .position_closed, .lot_edited, .flagged => true,
else => false,
};
}
/// Gross in/out split of the uncounted flows, signed the way `compare` prints
/// them: `out` accumulates negatives, so `in + out` is the net and needs no
/// further sign juggling at either call site.
///
/// Uses `value()`, not `attributedValue()` - the whole point is the value that
/// attribution threw away. Note this is a DIFFERENT basis from the figure
/// `printCollapsedSales` shows for a sale, which uses `face_value` (what the
/// trade realized) and can legitimately differ from a current-price mark. That
/// is why only the Cash deltas section gets a per-section total below: there the
/// two bases coincide, so the section total and this one cannot disagree.
fn uncountedTotals(changes: []const Change) struct { in: f64, out: f64 } {
var in: f64 = 0;
var out: f64 = 0;
for (changes) |c| {
if (!isUncountedKind(c.kind)) continue;
const v = c.value();
if (v >= 0) in += v else out += v;
}
return .{ .in = in, .out = out };
}
fn summarizeAttribution(ctx: ReportContext) AttributionSummary { fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
// Aggregate. Classification logic matches the full-report sections: // Aggregate. Classification logic matches the full-report sections:
@ -1252,15 +1198,37 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
// `partial_transfer_in` -> residual only (attributedValue()). // `partial_transfer_in` -> residual only (attributedValue()).
var new_contributions: f64 = 0; var new_contributions: f64 = 0;
var drip: f64 = 0; var drip: f64 = 0;
var uncounted_in: f64 = 0;
var uncounted_out: f64 = 0;
for (ctx.report.changes) |c| switch (c.kind) { for (ctx.report.changes) |c| switch (c.kind) {
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(), .new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(), .new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
.partial_transfer_in => new_contributions += c.attributedValue(), .partial_transfer_in => new_contributions += c.attributedValue(),
// The uncounted kinds fall through to `uncountedTotals` below, which owns
// that list. Everything else contributes nothing. // Uncounted, and now reported rather than silently dropped. See
// `AttributionSummary.uncounted_in`.
//
// `cash_delta` is the raw-balance-change bucket (an opted-in account's
// positive delta is reclassified to `cash_contribution` at diff time and
// counted above, so it cannot be double-counted here). The share-reduction
// kinds are the other side of the same coin: `attributedValue()` pins them
// to 0 on the grounds that they are a funding source rather than an outflow,
// which is right for attribution and still worth SEEING.
.cash_delta, .drip_negative, .lot_removed, .position_closed, .lot_edited, .flagged => {
const v = c.value();
if (v >= 0) uncounted_in += v else uncounted_out += v;
},
// Deliberately NOT in the uncounted totals:
// - `cd_matured` / `cd_removed_early` have their own report section and
// always pair with a cash increase in the same account, so counting both
// legs would double-report one internal move.
// - `transfer_in` / `transfer_out` / `unmatched_transfer` are DECLARED
// internal moves (`transaction_log.srf`). The point of this line is
// UNdeclared movement; a declared transfer is not a surprise.
// - `price_only` carries no share change, so its value is zero anyway.
else => {}, else => {},
}; };
const unc = uncountedTotals(ctx.report.changes);
// Cash-dest transfer attribution is already removed by `attributedValue()` on // Cash-dest transfer attribution is already removed by `attributedValue()` on
// the per-Change side: `matchCashDestination` accumulates into // the per-Change side: `matchCashDestination` accumulates into
// `transfer_attributed`, so a fully-attributed cash Change contributes zero // `transfer_attributed`, so a fully-attributed cash Change contributes zero
@ -1269,8 +1237,8 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
return .{ return .{
.new_contributions = new_contributions, .new_contributions = new_contributions,
.drip = drip, .drip = drip,
.uncounted_in = unc.in, .uncounted_in = uncounted_in,
.uncounted_out = unc.out, .uncounted_out = uncounted_out,
}; };
} }
@ -3156,25 +3124,16 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
try out.writeAll("\n"); try out.writeAll("\n");
// ── Section: Cash deltas ── // ── Section: Cash deltas ──
//
// Totalled, unlike the other uncounted sections, because this is the one
// that usually carries the bulk of `compare`'s "Uncounted in" and the one a
// reader ends up adding by hand. Safe to total here because these rows print
// `value()`, the same basis `uncountedTotals` uses - see the note there for
// why the sale sections are left alone.
try printSection(out, "Cash deltas (raw balance changes)", color, h_color); try printSection(out, "Cash deltas (raw balance changes)", color, h_color);
any = false; any = false;
var cash_delta_total: f64 = 0;
for (report.changes) |c| switch (c.kind) { for (report.changes) |c| switch (c.kind) {
.cash_delta => { .cash_delta => {
any = true; any = true;
cash_delta_total += c.value();
try printCashDeltaLine(out, c, report, color); try printCashDeltaLine(out, c, report, color);
}, },
else => {}, else => {},
}; };
if (!any) try printNone(out, color, mut_color); if (!any) try printNone(out, color, mut_color);
if (any) try printTotalLine(out, "Total", cash_delta_total, color, h_color);
try out.writeAll("\n"); try out.writeAll("\n");
// ── Section: Internal purchases (internally funded - not counted) ── // ── Section: Internal purchases (internally funded - not counted) ──
@ -3274,10 +3233,13 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
try printSection(out, "Lot edits (same position, key rewritten - not counted)", color, h_color); try printSection(out, "Lot edits (same position, key rewritten - not counted)", color, h_color);
for (report.changes) |c| switch (c.kind) { for (report.changes) |c| switch (c.kind) {
.lot_edited => { .lot_edited => {
try cli.setFg(out, color, mut_color); var buf: [256]u8 = undefined;
try writeRowPrefix(out, c.symbol, c.account); const msg = std.fmt.bufPrint(
try out.writeAll(" (strict key broke, shares unchanged)\n"); &buf,
try cli.reset(out, color); " {s: <12} {s: <24} (strict key broke, shares unchanged)\n",
.{ c.symbol, c.account },
) catch " (lot edit)\n";
try cli.printFg(out, color, mut_color, "{s}", .{msg});
}, },
else => {}, else => {},
}; };
@ -3341,10 +3303,7 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
total_cd_int += cd_int; total_cd_int += cd_int;
const acct_label = if (acct.len == 0) "(no account)" else acct; const acct_label = if (acct.len == 0) "(no account)" else acct;
// Same width and the same gutter guarantee as the per-change rows, minus try out.print(" {s:<28}", .{acct_label});
// the symbol column this section has no use for.
try out.writeAll(" ");
try padTo(out, acct_label, acct_w);
try printSummaryCell(out, " new", t.new_money, color); try printSummaryCell(out, " new", t.new_money, color);
try printSummaryCell(out, " drip", t.drip_confirmed, color); try printSummaryCell(out, " drip", t.drip_confirmed, color);
try printSummaryCell(out, " rollup", t.rollup, color); try printSummaryCell(out, " rollup", t.rollup, color);
@ -3367,57 +3326,6 @@ fn printReport(out: *std.Io.Writer, report: *const Report, label: []const u8, co
// even though it originated inside the portfolio. // even though it originated inside the portfolio.
const grand = total_new + total_drip + total_rollup + total_cd_int; const grand = total_new + total_drip + total_rollup + total_cd_int;
try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)}); try cli.printFg(out, color, h_color, " Grand total: {f}\n", .{Money.from(grand)});
// The figures `compare` prints as "Uncounted in/out", restated here because
// that line says "see `zfin contributions`" and until now nothing in this
// report added up to them. Same helper, so they are the same numbers by
// construction rather than by hope. Named sections so the rows behind each
// side can actually be found.
const unc = uncountedTotals(report.changes);
if (@abs(unc.in) >= 0.005 or @abs(unc.out) >= 0.005) {
try out.writeAll("\n");
try cli.printFg(out, color, h_color, "Uncounted flows (inside `Investment gains`, not attributed)\n", .{});
try out.print(" In: {f}\n", .{Money.from(unc.in).signed()});
try out.print(" Out: {f}\n", .{Money.from(unc.out).signed()});
try cli.printFg(out, color, h_color, " Net: {f}\n", .{Money.from(unc.in + unc.out).signed()});
// Name only the sections that actually contributed. Listing all four
// unconditionally would point at headings that were never printed
// (Flagged and Lot edits are both conditional), and it would put their
// titles on screen even when the sections are absent - which is not just
// untidy, it defeats any "this section did not appear" check.
var listed: usize = 0;
for ([_]struct { name: []const u8, hit: bool }{
.{ .name = "Cash deltas", .hit = anyUncountedIn(report.changes, .cash_delta_group) },
.{ .name = "Internal purchases", .hit = anyUncountedIn(report.changes, .sale_group) },
.{ .name = "Flagged for review", .hit = anyUncountedIn(report.changes, .flagged_group) },
}) |g| {
if (!g.hit) continue;
try cli.printFg(out, color, mut_color, "{s}{s}", .{ if (listed == 0) " from: " else ", ", g.name });
listed += 1;
}
if (listed > 0) try out.writeAll("\n");
}
}
/// Which print section a given uncounted change shows up under. Used only to
/// caption the total; `Lot edits` is deliberately absent because `lot_edited`
/// values to zero, so it can never move the figure it would be credited for.
const UncountedGroup = enum { cash_delta_group, sale_group, flagged_group };
fn anyUncountedIn(changes: []const Change, group: UncountedGroup) bool {
for (changes) |c| {
if (!isUncountedKind(c.kind)) continue;
if (@abs(c.value()) < 0.005) continue;
const g: UncountedGroup = switch (c.kind) {
.cash_delta => .cash_delta_group,
.position_closed, .lot_removed, .drip_negative => if (isSaleKind(c)) .sale_group else .flagged_group,
.flagged => .flagged_group,
else => continue,
};
if (g == group) return true;
}
return false;
} }
fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void { fn printSection(out: *std.Io.Writer, title: []const u8, color: bool, hdr: [3]u8) !void {
@ -3429,66 +3337,6 @@ fn printNone(out: *std.Io.Writer, color: bool, muted: [3]u8) !void {
try cli.printFg(out, color, muted, " (none)\n", .{}); try cli.printFg(out, color, muted, " (none)\n", .{});
} }
/// Symbol column. 16 because `Engagement Ring` is 15 - illiquid assets carry
/// free-form names rather than tickers, so the old 14 was sized for a population
/// this report no longer only contains.
const sym_w = 16;
/// Account column. 30 because `Fidelity Emil 401(k) Roth BL` is 28.
const acct_w = 30;
/// Pad `s` into a `w`-wide field, always leaving at least one space behind it.
///
/// The guarantee is the point. `{s:<N}` OVERFLOWS instead of truncating, so a
/// single over-long value both shifted its own row right AND consumed the gutter,
/// welding itself to the next field. And no fixed `N` avoids it here: symbols in
/// this report run from a 3-character ticker to a 24-character option
/// (`AMZN 09/18/2026 280.00 C`), with hand-named illiquid assets in between. So
/// the widths above are chosen for the common case and this keeps the rare long
/// one readable - it loses its alignment, not its whitespace.
fn padTo(out: *std.Io.Writer, s: []const u8, w: usize) !void {
try out.writeAll(s);
try out.splatByteAll(' ', if (s.len >= w) 1 else w - s.len);
}
/// The ` symbol account ` prefix every per-change row opens with.
///
/// One function rather than a format-string literal repeated at fourteen call
/// sites. The literal is how the widths drifted out of step in the first place:
/// twelve rows said `{s:<14}{s:<24}`, the CD continuation said the same with
/// blank arguments, the lot-edit row said `{s: <12} {s: <24}`, and the account
/// summary said `{s:<28}` - four different answers to one question, and each new
/// long value found a different one of them.
///
/// Callers own the single space that follows, so content lands one column past
/// `acct_w`. Keep it: every section has to agree on that column or the report
/// shears between sections instead of within a row, which is harder to spot.
fn writeRowPrefix(out: *std.Io.Writer, symbol: []const u8, account: []const u8) !void {
try out.writeAll(" ");
try padTo(out, symbol, sym_w);
try padTo(out, account, acct_w);
}
test "padTo: pads short values to width and never welds a long one" {
var buf: [128]u8 = undefined;
// Short: padded to the column.
var w1 = std.Io.Writer.fixed(&buf);
try padTo(&w1, "CASH", 16);
try std.testing.expectEqualStrings("CASH ", w1.buffered());
// Exactly one under: still a gutter, and it is the last width that aligns.
var w2 = std.Io.Writer.fixed(&buf);
try padTo(&w2, "Engagement Ring", 16);
try std.testing.expectEqualStrings("Engagement Ring ", w2.buffered());
// Over: alignment is gone, whitespace is not. `{s:<16}` produced no space
// here at all, which ran the value into the next column.
var w3 = std.Io.Writer.fixed(&buf);
try padTo(&w3, "AMZN 09/18/2026 280.00 C", 16);
try std.testing.expectEqualStrings("AMZN 09/18/2026 280.00 C ", w3.buffered());
}
fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void { fn printTotalLine(out: *std.Io.Writer, label: []const u8, v: f64, color: bool, hdr: [3]u8) !void {
try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) }); try cli.printFg(out, color, hdr, " {s}: {f}\n", .{ label, Money.from(v) });
} }
@ -3502,7 +3350,7 @@ fn printChangeLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3]u8) !voi
const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?"; const val_str = std.fmt.bufPrint(&val_buf, "{f}", .{Money.from(c.value())}) catch "$?";
const acct = if (c.account.len == 0) "(no account)" else c.account; const acct = if (c.account.len == 0) "(no account)" else c.account;
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24}", .{ c.symbol, acct });
if (c.security_type == .cash) { if (c.security_type == .cash) {
try cli.printFg(out, color, pos, " {s}", .{val_str}); try cli.printFg(out, color, pos, " {s}", .{val_str});
} else { } else {
@ -3521,15 +3369,15 @@ fn printCdLine(out: *std.Io.Writer, c: Change, implied_interest: ?f64, color: bo
.cd_removed_early => "removed EARLY", .cd_removed_early => "removed EARLY",
else => "removed", else => "removed",
}; };
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} {s:<16} face {f} maturity {s}\n", .{
try out.print(" {s:<16} face {f} maturity {s}\n", .{ c.symbol,
acct,
verb, verb,
Money.from(c.face_value), Money.from(c.face_value),
mat_str, mat_str,
}); });
if (implied_interest) |i| { if (implied_interest) |i| {
try writeRowPrefix(out, "", ""); try cli.printFg(out, color, cli.CLR_POSITIVE, " {s:<14}{s:<24} implied interest: {f}\n", .{ "", "", Money.from(i) });
try cli.printFg(out, color, cli.CLR_POSITIVE, " implied interest: {f}\n", .{Money.from(i)});
} }
} }
@ -3537,8 +3385,7 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col
const v = c.value(); const v = c.value();
const acct = if (c.account.len == 0) "(no account)" else c.account; const acct = if (c.account.len == 0) "(no account)" else c.account;
const sign = if (v >= 0) "+" else "-"; const sign = if (v >= 0) "+" else "-";
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} cash ", .{ c.symbol, acct });
try out.writeAll(" cash ");
try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) }); try cli.printGainLoss(out, color, v, "{s}{f}", .{ sign, Money.from(@abs(v)) });
// Hint if a CD matured in the same account. // Hint if a CD matured in the same account.
@ -3553,9 +3400,9 @@ fn printCashDeltaLine(out: *std.Io.Writer, c: Change, report: *const Report, col
fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void { fn printPriceOnlyLine(out: *std.Io.Writer, c: Change, color: bool, muted: [3]u8) !void {
const acct = if (c.account.len == 0) "(no account)" else c.account; const acct = if (c.account.len == 0) "(no account)" else c.account;
try cli.setFg(out, color, muted); try cli.printFg(out, color, muted, " {s:<14}{s:<24} price {f} -> {f}\n", .{
try writeRowPrefix(out, c.symbol, acct); c.symbol,
try out.print(" price {f} -> {f}\n", .{ acct,
Money.from(c.old_price), Money.from(c.old_price),
Money.from(c.new_price), Money.from(c.new_price),
}); });
@ -3566,18 +3413,17 @@ fn printFlaggedLine(out: *std.Io.Writer, c: Change, color: bool, warn: [3]u8) !v
try cli.setFg(out, color, warn); try cli.setFg(out, color, warn);
switch (c.kind) { switch (c.kind) {
.flagged => { .flagged => {
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} {s}", .{ c.symbol, acct, c.detail orelse "edited" });
try out.print(" {s}", .{c.detail orelse "edited"});
}, },
.lot_removed => { .lot_removed => {
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} {s} lot removed (face {f})", .{
try out.print(" {s} lot removed (face {f})", .{ c.symbol, acct, @tagName(c.security_type), Money.from(c.face_value),
@tagName(c.security_type), Money.from(c.face_value),
}); });
}, },
.drip_negative => { .drip_negative => {
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} shares decreased on existing lot ({f})", .{
try out.print(" shares decreased on existing lot ({f})", .{Money.from(@abs(c.value()))}); c.symbol, acct, Money.from(@abs(c.value())),
});
}, },
else => {}, else => {},
} }
@ -3696,7 +3542,7 @@ fn printPartialTransferLine(out: *std.Io.Writer, c: Change, color: bool, pos: [3
const lot_value = c.value(); const lot_value = c.value();
const sym = if (c.symbol.len > 0) c.symbol else "cash"; const sym = if (c.symbol.len > 0) c.symbol else "cash";
try writeRowPrefix(out, sym, acct); try out.print(" {s:<14}{s:<24}", .{ sym, acct });
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg( try cli.printFg(
out, out,
@ -3718,7 +3564,7 @@ fn printCashFundedResidualLine(out: *std.Io.Writer, c: Change, color: bool, pos:
const lot_value = c.value(); const lot_value = c.value();
const sym = if (c.symbol.len > 0) c.symbol else "cash"; const sym = if (c.symbol.len > 0) c.symbol else "cash";
try writeRowPrefix(out, sym, acct); try out.print(" {s:<14}{s:<24}", .{ sym, acct });
try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)}); try cli.printFg(out, color, pos, " {f}", .{Money.from(residual)});
try cli.printFg( try cli.printFg(
out, out,
@ -3783,11 +3629,9 @@ fn printCollapsedSales(out: *std.Io.Writer, report: *const Report, color: bool,
// current-price proxy, so say which one the reader is looking at. // current-price proxy, so say which one the reader is looking at.
const basis: []const u8 = if (closed_in_place) "at close" else "at mark"; const basis: []const u8 = if (closed_in_place) "at close" else "at mark";
if (lots == 1) { if (lots == 1) {
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} sold {s} ({f})", .{ c.symbol, acct, basis, Money.from(proceeds) });
try out.print(" sold {s} ({f})", .{ basis, Money.from(proceeds) });
} else { } else {
try writeRowPrefix(out, c.symbol, acct); try out.print(" {s:<14}{s:<24} sold {d} lots {s} ({f})", .{ c.symbol, acct, lots, basis, Money.from(proceeds) });
try out.print(" sold {d} lots {s} ({f})", .{ lots, basis, Money.from(proceeds) });
} }
try cli.reset(out, color); try cli.reset(out, color);
try out.writeAll("\n"); try out.writeAll("\n");
@ -3805,8 +3649,7 @@ fn printInternalPurchaseLine(out: *std.Io.Writer, c: Change, color: bool, muted:
const lot_value = c.value(); const lot_value = c.value();
try cli.setFg(out, color, muted); try cli.setFg(out, color, muted);
try writeRowPrefix(out, sym, acct); try out.print(" {s:<14}{s:<24} {f} from existing cash", .{ sym, acct, Money.from(c.internal_funded) });
try out.print(" {f} from existing cash", .{Money.from(c.internal_funded)});
if (c.internal_funded + 0.005 < lot_value) { if (c.internal_funded + 0.005 < lot_value) {
try out.print(" (of {f} lot)", .{Money.from(lot_value)}); try out.print(" (of {f} lot)", .{Money.from(lot_value)});
} }

View file

@ -892,36 +892,27 @@ pub fn runBands(
"", "1 Year", "3 Year", "5 Year", "10 Year", "Week", "", "1 Year", "3 Year", "5 Year", "10 Year", "Week",
}); });
// Build return rows via view model. Weights are renormalized to the blend, // Build return rows via view model
// matching what `comparison.*_returns` were actually computed from.
const bw = view.benchmarkWeights(ctx.stock_pct, ctx.bond_pct);
var spy_bufs: [5][16]u8 = undefined; var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [48]u8 = undefined; var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow( const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmarkStock(), bw.stock), view.fmtBenchmarkLabel(&spy_label_buf, ctx.config.benchmarkStock(), ctx.stock_pct * 100),
comparison.stock_returns, comparison.stock_returns,
&spy_bufs, &spy_bufs,
false, false,
); );
var agg_bufs: [5][16]u8 = undefined; var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [48]u8 = undefined; var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow( const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmarkBond(), bw.bond), view.fmtBenchmarkLabel(&agg_label_buf, ctx.config.benchmarkBond(), ctx.bond_pct * 100),
comparison.bond_returns, comparison.bond_returns,
&agg_bufs, &agg_bufs,
false, false,
); );
var bench_bufs: [5][16]u8 = undefined; var bench_bufs: [5][16]u8 = undefined;
var bench_label_buf: [48]u8 = undefined; const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
const bench_row = view.buildReturnRow(
view.fmtBenchmarkAggregateLabel(&bench_label_buf, bw.covered),
comparison.benchmark_returns,
&bench_bufs,
true,
);
var port_bufs: [5][16]u8 = undefined; var port_bufs: [5][16]u8 = undefined;
const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true); const port_row = view.buildReturnRow("Your Portfolio", comparison.portfolio_returns, &port_bufs, true);

View file

@ -1137,13 +1137,11 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
.style = th.headerStyle(), .style = th.headerStyle(),
}); });
// Return rows. Weights renormalized to the blend - see `benchmarkWeights`. // Return rows
const bw = view.benchmarkWeights(stock_pct, pctx.bond_pct);
var spy_bufs: [5][16]u8 = undefined; var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [48]u8 = undefined; var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow( const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), bw.stock), view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), stock_pct * 100),
comparison.stock_returns, comparison.stock_returns,
&spy_bufs, &spy_bufs,
false, false,
@ -1151,9 +1149,9 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
try appendReturnRow(lines, arena, th, spy_row); try appendReturnRow(lines, arena, th, spy_row);
var agg_bufs: [5][16]u8 = undefined; var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [48]u8 = undefined; var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow( const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), bw.bond), view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), pctx.bond_pct * 100),
comparison.bond_returns, comparison.bond_returns,
&agg_bufs, &agg_bufs,
false, false,
@ -1161,13 +1159,7 @@ fn buildHeaderSection(state: *State, app: *App, arena: std.mem.Allocator, lines:
try appendReturnRow(lines, arena, th, agg_row); try appendReturnRow(lines, arena, th, agg_row);
var bench_bufs: [5][16]u8 = undefined; var bench_bufs: [5][16]u8 = undefined;
var bench_label_buf: [48]u8 = undefined; const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
const bench_row = view.buildReturnRow(
view.fmtBenchmarkAggregateLabel(&bench_label_buf, bw.covered),
comparison.benchmark_returns,
&bench_bufs,
true,
);
try appendReturnRow(lines, arena, th, bench_row); try appendReturnRow(lines, arena, th, bench_row);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -1895,13 +1887,11 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
.style = th.headerStyle(), .style = th.headerStyle(),
}); });
// Return rows. Weights renormalized to the blend - see `benchmarkWeights`. // Return rows
const bw = view.benchmarkWeights(stock_pct, ctx.bond_pct);
var spy_bufs: [5][16]u8 = undefined; var spy_bufs: [5][16]u8 = undefined;
var spy_label_buf: [48]u8 = undefined; var spy_label_buf: [32]u8 = undefined;
const spy_row = view.buildReturnRow( const spy_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), bw.stock), view.fmtBenchmarkLabel(&spy_label_buf, config.benchmarkStock(), stock_pct * 100),
comparison.stock_returns, comparison.stock_returns,
&spy_bufs, &spy_bufs,
false, false,
@ -1909,9 +1899,9 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
try appendReturnRow(&lines, arena, th, spy_row); try appendReturnRow(&lines, arena, th, spy_row);
var agg_bufs: [5][16]u8 = undefined; var agg_bufs: [5][16]u8 = undefined;
var agg_label_buf: [48]u8 = undefined; var agg_label_buf: [32]u8 = undefined;
const agg_row = view.buildReturnRow( const agg_row = view.buildReturnRow(
view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), bw.bond), view.fmtBenchmarkLabel(&agg_label_buf, config.benchmarkBond(), ctx.bond_pct * 100),
comparison.bond_returns, comparison.bond_returns,
&agg_bufs, &agg_bufs,
false, false,
@ -1919,13 +1909,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
try appendReturnRow(&lines, arena, th, agg_row); try appendReturnRow(&lines, arena, th, agg_row);
var bench_bufs: [5][16]u8 = undefined; var bench_bufs: [5][16]u8 = undefined;
var bench_label_buf: [48]u8 = undefined; const bench_row = view.buildReturnRow("Benchmark", comparison.benchmark_returns, &bench_bufs, true);
const bench_row = view.buildReturnRow(
view.fmtBenchmarkAggregateLabel(&bench_label_buf, bw.covered),
comparison.benchmark_returns,
&bench_bufs,
true,
);
try appendReturnRow(&lines, arena, th, bench_row); try appendReturnRow(&lines, arena, th, bench_row);
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });

View file

@ -144,52 +144,9 @@ pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f
return .{ .text = text, .style = style }; return .{ .text = text, .style = style };
} }
/// Benchmark weights as the blend actually uses them. /// Format the stock benchmark label with weight.
///
/// `deriveAllocationSplit` returns stock/bond as fractions of the WHOLE
/// portfolio, so they do not sum to 1 - the remainder is cash, CDs, options and
/// anything with no `metadata.srf` row. `blendReturns` then renormalizes to the
/// weight present (`benchmark.blendOptional`), which is what stops a cash sleeve
/// dragging the benchmark down against a portfolio return that excludes it too.
///
/// The labels have to agree with that arithmetic. Printing the raw 84.9%/10.0%
/// beside a row computed from 89.5%/10.5% invites the reader to multiply out and
/// get 17.14% where the row says 18.06%. Both numbers are correct and the
/// mismatch is entirely in the caption - the same trap that already cost an hour
/// on the `--as-of` table and is why `review` prints that table's date.
///
/// Nothing is lost by renormalizing the caption: the raw portfolio weight of
/// either leg is `stock/100 * covered`.
pub const BenchmarkWeights = struct {
/// Share of the BENCHMARK. Sums to 100 with `bond`.
stock: f64,
bond: f64,
/// Share of the PORTFOLIO the benchmark covers.
covered: f64,
};
pub fn benchmarkWeights(stock_pct: f64, bond_pct: f64) BenchmarkWeights {
const w = stock_pct + bond_pct;
if (w <= 0) return .{ .stock = 0, .bond = 0, .covered = 0 };
return .{
.stock = stock_pct / w * 100.0,
.bond = bond_pct / w * 100.0,
.covered = w * 100.0,
};
}
/// Format the benchmark leg label with its weight *within the benchmark*.
pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 { pub fn fmtBenchmarkLabel(buf: []u8, symbol: []const u8, weight_pct: f64) []const u8 {
return std.fmt.bufPrint(buf, "{s} ({d:.1}% of benchmark)", .{ symbol, weight_pct }) catch symbol; return std.fmt.bufPrint(buf, "{s} ({d:.1}% weight)", .{ symbol, weight_pct }) catch symbol;
}
/// Label for the blended row. Discloses how much of the portfolio the blend
/// covers, but only when it is materially short of all of it - on a fully
/// classified stock/bond portfolio there is nothing to disclose and the bare
/// word is less noise.
pub fn fmtBenchmarkAggregateLabel(buf: []u8, covered_pct: f64) []const u8 {
if (covered_pct >= 99.5) return "Benchmark";
return std.fmt.bufPrint(buf, "Benchmark ({d:.1}% of portfolio)", .{covered_pct}) catch "Benchmark";
} }
// ── Precomputed projection data (shared by CLI and TUI) ──────── // ── Precomputed projection data (shared by CLI and TUI) ────────