Compare commits
No commits in common. "23fd4e3b63272de9bb56f8835899de0705527119" and "6bd063d51f6c7053e2a49b68892f164787bc389d" have entirely different histories.
23fd4e3b63
...
6bd063d51f
5 changed files with 75 additions and 309 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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() });
|
||||||
|
|
|
||||||
|
|
@ -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) ────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue